taist 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.taistrc.json +18 -0
- package/LICENSE +20 -0
- package/README.md +471 -0
- package/index.js +147 -0
- package/instrument.js +233 -0
- package/lib/config-loader.js +102 -0
- package/lib/execution-tracer.js +350 -0
- package/lib/instrument-all.js +332 -0
- package/lib/logger.js +61 -0
- package/lib/module-hooks.js +109 -0
- package/lib/module-patcher.js +42 -0
- package/lib/output-formatter.js +151 -0
- package/lib/service-tracer.js +548 -0
- package/lib/toon-formatter.js +498 -0
- package/lib/trace-collector.js +221 -0
- package/lib/trace-context.js +80 -0
- package/lib/trace-reporter.js +329 -0
- package/lib/trace-session.js +119 -0
- package/lib/transform.js +362 -0
- package/lib/vitest-runner.js +436 -0
- package/lib/watch-handler.js +300 -0
- package/package.json +90 -0
- package/taist.js +527 -0
- package/testing.js +29 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TOON Formatter - Token-Optimized Output Notation
|
|
3
|
+
* Converts test results to a token-efficient format for AI consumption
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class ToonFormatter {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.options = {
|
|
9
|
+
abbreviate: options.abbreviate !== false,
|
|
10
|
+
maxTokens: options.maxTokens || 1000,
|
|
11
|
+
maxStringLength: options.maxStringLength || 50,
|
|
12
|
+
maxStackFrames: options.maxStackFrames || 2,
|
|
13
|
+
maxObjectKeys: options.maxObjectKeys || 3,
|
|
14
|
+
maxArrayItems: options.maxArrayItems || 2,
|
|
15
|
+
...options
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Abbreviation dictionary
|
|
19
|
+
this.abbrev = {
|
|
20
|
+
function: 'fn',
|
|
21
|
+
error: 'err',
|
|
22
|
+
expected: 'exp',
|
|
23
|
+
received: 'got',
|
|
24
|
+
actual: 'got',
|
|
25
|
+
undefined: 'undef',
|
|
26
|
+
null: 'nil',
|
|
27
|
+
test: 'tst',
|
|
28
|
+
testing: 'tst',
|
|
29
|
+
passed: 'pass',
|
|
30
|
+
failed: 'fail',
|
|
31
|
+
arguments: 'args',
|
|
32
|
+
return: 'ret',
|
|
33
|
+
result: 'ret',
|
|
34
|
+
message: 'msg',
|
|
35
|
+
location: 'loc',
|
|
36
|
+
line: 'ln',
|
|
37
|
+
column: 'col'
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Format test results in TOON format
|
|
43
|
+
* @param {Object} results - Test results object
|
|
44
|
+
* @returns {string} - Formatted TOON output
|
|
45
|
+
*/
|
|
46
|
+
format(results) {
|
|
47
|
+
const lines = [];
|
|
48
|
+
|
|
49
|
+
// Header
|
|
50
|
+
lines.push(this.formatHeader(results));
|
|
51
|
+
|
|
52
|
+
// Failures
|
|
53
|
+
if (results.failures && results.failures.length > 0) {
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push('FAILURES:');
|
|
56
|
+
results.failures.forEach(failure => {
|
|
57
|
+
lines.push(...this.formatFailure(failure));
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Trace
|
|
62
|
+
if (results.trace && results.trace.length > 0) {
|
|
63
|
+
lines.push('');
|
|
64
|
+
lines.push('TRACE:');
|
|
65
|
+
results.trace.forEach(entry => {
|
|
66
|
+
lines.push(this.formatTraceEntry(entry));
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Coverage
|
|
71
|
+
if (results.coverage) {
|
|
72
|
+
lines.push('');
|
|
73
|
+
lines.push(this.formatCoverage(results.coverage));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Format test result header
|
|
81
|
+
*/
|
|
82
|
+
formatHeader(results) {
|
|
83
|
+
const passed = results.stats?.passed || 0;
|
|
84
|
+
const total = results.stats?.total || 0;
|
|
85
|
+
return `===TESTS: ${passed}/${total}===`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Format a single test failure
|
|
90
|
+
*/
|
|
91
|
+
formatFailure(failure) {
|
|
92
|
+
const lines = [];
|
|
93
|
+
|
|
94
|
+
// Test name
|
|
95
|
+
lines.push(`✗ ${this.truncate(failure.test || failure.name)}`);
|
|
96
|
+
|
|
97
|
+
// Location
|
|
98
|
+
if (failure.location) {
|
|
99
|
+
lines.push(` @${this.formatLocation(failure.location)}`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Error message
|
|
103
|
+
if (failure.error) {
|
|
104
|
+
const msg = this.cleanErrorMessage(failure.error);
|
|
105
|
+
lines.push(` ${msg}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Expected vs Actual
|
|
109
|
+
if (failure.diff) {
|
|
110
|
+
if (failure.diff.expected !== undefined) {
|
|
111
|
+
lines.push(` exp: ${this.formatValue(failure.diff.expected)}`);
|
|
112
|
+
}
|
|
113
|
+
if (failure.diff.actual !== undefined) {
|
|
114
|
+
lines.push(` got: ${this.formatValue(failure.diff.actual)}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Execution path
|
|
119
|
+
if (failure.path) {
|
|
120
|
+
lines.push(` path: ${this.formatPath(failure.path)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Stack trace (abbreviated)
|
|
124
|
+
if (failure.stack && this.options.maxStackFrames > 0) {
|
|
125
|
+
const stack = this.formatStack(failure.stack);
|
|
126
|
+
if (stack) {
|
|
127
|
+
lines.push(` ${stack}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Format trace entry with depth-based indentation for execution tree
|
|
136
|
+
*/
|
|
137
|
+
formatTraceEntry(entry) {
|
|
138
|
+
const parts = [];
|
|
139
|
+
|
|
140
|
+
// Function name
|
|
141
|
+
parts.push(`fn:${this.abbreviateFunctionName(entry.name)}`);
|
|
142
|
+
|
|
143
|
+
// Duration
|
|
144
|
+
if (entry.duration !== undefined) {
|
|
145
|
+
parts.push(`ms:${Math.round(entry.duration)}`);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Arguments (if present)
|
|
149
|
+
if (entry.args && entry.args.length > 0) {
|
|
150
|
+
const args = entry.args
|
|
151
|
+
.slice(0, this.options.maxArrayItems)
|
|
152
|
+
.map(arg => this.formatValue(arg))
|
|
153
|
+
.join(',');
|
|
154
|
+
parts.push(`args:[${args}]`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Return value (if present and not undefined)
|
|
158
|
+
if (entry.result !== undefined) {
|
|
159
|
+
parts.push(`ret:${this.formatValue(entry.result)}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Error (if present)
|
|
163
|
+
if (entry.error) {
|
|
164
|
+
parts.push(`err:${this.cleanErrorMessage(entry.error)}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Calculate indentation based on depth (2 spaces base + 2 per depth level)
|
|
168
|
+
const depth = entry.depth || 0;
|
|
169
|
+
const indent = ' ' + ' '.repeat(depth);
|
|
170
|
+
|
|
171
|
+
return `${indent}${parts.join(' ')}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Abbreviate function name for compact output
|
|
176
|
+
*/
|
|
177
|
+
abbreviateFunctionName(name) {
|
|
178
|
+
if (!name) return 'anonymous';
|
|
179
|
+
|
|
180
|
+
// Keep the last part of dotted names, but preserve HTTP method info
|
|
181
|
+
if (name.startsWith('HTTP ') || name.startsWith('Route.')) {
|
|
182
|
+
return name;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// For service methods like "OrderService.createOrder", keep both parts but abbreviate
|
|
186
|
+
const parts = name.split('.');
|
|
187
|
+
if (parts.length === 2) {
|
|
188
|
+
return name; // Keep as-is for readability
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// For longer paths, just keep the last two parts
|
|
192
|
+
if (parts.length > 2) {
|
|
193
|
+
return parts.slice(-2).join('.');
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return name;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Format coverage information
|
|
201
|
+
*/
|
|
202
|
+
formatCoverage(coverage) {
|
|
203
|
+
const percent = Math.round(coverage.percent || 0);
|
|
204
|
+
const covered = coverage.covered || 0;
|
|
205
|
+
const total = coverage.total || 0;
|
|
206
|
+
return `COV: ${percent}% (${covered}/${total})`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Format a location reference
|
|
211
|
+
*/
|
|
212
|
+
formatLocation(location) {
|
|
213
|
+
if (typeof location === 'string') {
|
|
214
|
+
return this.abbreviatePath(location);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const file = this.abbreviatePath(location.file || '');
|
|
218
|
+
const line = location.line || '';
|
|
219
|
+
const col = location.column || '';
|
|
220
|
+
|
|
221
|
+
if (col) {
|
|
222
|
+
return `${file}:${line}:${col}`;
|
|
223
|
+
} else if (line) {
|
|
224
|
+
return `${file}:${line}`;
|
|
225
|
+
}
|
|
226
|
+
return file;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Format an execution path
|
|
231
|
+
*/
|
|
232
|
+
formatPath(path) {
|
|
233
|
+
if (Array.isArray(path)) {
|
|
234
|
+
return path
|
|
235
|
+
.map(step => {
|
|
236
|
+
if (typeof step === 'string') return step;
|
|
237
|
+
if (step.fn && step.result !== undefined) {
|
|
238
|
+
return `${step.fn}(...)→${this.formatValue(step.result)}`;
|
|
239
|
+
}
|
|
240
|
+
return step.fn || String(step);
|
|
241
|
+
})
|
|
242
|
+
.join('→');
|
|
243
|
+
}
|
|
244
|
+
return String(path);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Format a value for output
|
|
249
|
+
*/
|
|
250
|
+
formatValue(value) {
|
|
251
|
+
if (value === null) return 'nil';
|
|
252
|
+
if (value === undefined) return 'undef';
|
|
253
|
+
|
|
254
|
+
const type = typeof value;
|
|
255
|
+
|
|
256
|
+
if (type === 'string') {
|
|
257
|
+
return `"${this.truncate(value)}"`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (type === 'number' || type === 'boolean') {
|
|
261
|
+
return String(value);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (Array.isArray(value)) {
|
|
265
|
+
if (value.length === 0) return '[]';
|
|
266
|
+
const items = value
|
|
267
|
+
.slice(0, this.options.maxArrayItems)
|
|
268
|
+
.map(v => this.formatValue(v))
|
|
269
|
+
.join(',');
|
|
270
|
+
const more = value.length > this.options.maxArrayItems
|
|
271
|
+
? `...+${value.length - this.options.maxArrayItems}`
|
|
272
|
+
: '';
|
|
273
|
+
return `[${items}${more}]`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (type === 'object') {
|
|
277
|
+
const keys = Object.keys(value).slice(0, this.options.maxObjectKeys);
|
|
278
|
+
if (keys.length === 0) return '{}';
|
|
279
|
+
const more = Object.keys(value).length > this.options.maxObjectKeys
|
|
280
|
+
? '...'
|
|
281
|
+
: '';
|
|
282
|
+
return `{${keys.join(',')}${more}}`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return String(value).slice(0, 20);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Format stack trace
|
|
290
|
+
*/
|
|
291
|
+
formatStack(stack) {
|
|
292
|
+
if (typeof stack === 'string') {
|
|
293
|
+
const lines = stack.split('\n')
|
|
294
|
+
.filter(line => line.trim() && !line.includes('node_modules'))
|
|
295
|
+
.slice(0, this.options.maxStackFrames);
|
|
296
|
+
|
|
297
|
+
return lines
|
|
298
|
+
.map(line => {
|
|
299
|
+
// Extract file:line:col from stack frame
|
|
300
|
+
const match = line.match(/\((.+):(\d+):(\d+)\)/) ||
|
|
301
|
+
line.match(/at (.+):(\d+):(\d+)/);
|
|
302
|
+
if (match) {
|
|
303
|
+
const [, file, line, col] = match;
|
|
304
|
+
return `@${this.abbreviatePath(file)}:${line}`;
|
|
305
|
+
}
|
|
306
|
+
return line.trim().slice(0, 50);
|
|
307
|
+
})
|
|
308
|
+
.join(' ');
|
|
309
|
+
}
|
|
310
|
+
return '';
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Clean error message
|
|
315
|
+
*/
|
|
316
|
+
cleanErrorMessage(error) {
|
|
317
|
+
if (typeof error === 'object' && error.message) {
|
|
318
|
+
error = error.message;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let msg = String(error);
|
|
322
|
+
|
|
323
|
+
// Remove ANSI codes
|
|
324
|
+
msg = msg.replace(/\u001b\[\d+m/g, '');
|
|
325
|
+
|
|
326
|
+
// Remove timestamps
|
|
327
|
+
msg = msg.replace(/\[\d{2}:\d{2}:\d{2}\]/g, '');
|
|
328
|
+
|
|
329
|
+
// Remove absolute paths
|
|
330
|
+
msg = msg.replace(/\/[^\s]+\//g, match => {
|
|
331
|
+
const parts = match.split('/');
|
|
332
|
+
return parts[parts.length - 1] || match;
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
// Truncate
|
|
336
|
+
msg = this.truncate(msg);
|
|
337
|
+
|
|
338
|
+
return msg;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Abbreviate file path
|
|
343
|
+
*/
|
|
344
|
+
abbreviatePath(path) {
|
|
345
|
+
if (!path) return '';
|
|
346
|
+
|
|
347
|
+
// Remove common prefixes
|
|
348
|
+
path = path.replace(/^.*\/node_modules\//, 'npm/');
|
|
349
|
+
path = path.replace(/^.*\/src\//, 'src/');
|
|
350
|
+
path = path.replace(/^.*\/test\//, 'test/');
|
|
351
|
+
path = path.replace(/^.*\/lib\//, 'lib/');
|
|
352
|
+
|
|
353
|
+
// Get just filename if still too long
|
|
354
|
+
if (path.length > 30) {
|
|
355
|
+
const parts = path.split('/');
|
|
356
|
+
path = parts[parts.length - 1];
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return path;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Truncate string
|
|
364
|
+
*/
|
|
365
|
+
truncate(str, maxLength = this.options.maxStringLength) {
|
|
366
|
+
if (!str) return '';
|
|
367
|
+
str = String(str);
|
|
368
|
+
if (str.length <= maxLength) return str;
|
|
369
|
+
return str.slice(0, maxLength - 3) + '...';
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Apply abbreviations
|
|
374
|
+
*/
|
|
375
|
+
abbreviate(text) {
|
|
376
|
+
if (!this.options.abbreviate) return text;
|
|
377
|
+
|
|
378
|
+
let result = text;
|
|
379
|
+
for (const [full, abbr] of Object.entries(this.abbrev)) {
|
|
380
|
+
const regex = new RegExp(`\\b${full}\\b`, 'gi');
|
|
381
|
+
result = result.replace(regex, abbr);
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Group traces by traceId (each HTTP request becomes a group)
|
|
388
|
+
* @param {Array} traces - Array of trace objects
|
|
389
|
+
* @returns {Map<string, Array>} - Map of traceId to traces
|
|
390
|
+
*/
|
|
391
|
+
groupTracesByRequest(traces) {
|
|
392
|
+
const groups = new Map();
|
|
393
|
+
for (const trace of traces) {
|
|
394
|
+
const id = trace.traceId || 'unknown';
|
|
395
|
+
if (!groups.has(id)) groups.set(id, []);
|
|
396
|
+
groups.get(id).push(trace);
|
|
397
|
+
}
|
|
398
|
+
return groups;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Format a trace tree showing nested call hierarchy
|
|
403
|
+
* Groups traces by traceId and shows depth-based indentation
|
|
404
|
+
*
|
|
405
|
+
* @param {Array} traces - Array of trace objects with depth, traceId
|
|
406
|
+
* @param {Object} options - Formatting options
|
|
407
|
+
* @param {number} options.maxGroups - Max request groups to show (default: 10)
|
|
408
|
+
* @param {boolean} options.showHeader - Show header with stats (default: true)
|
|
409
|
+
* @returns {string} - Formatted trace tree output
|
|
410
|
+
*/
|
|
411
|
+
formatTraceTree(traces, options = {}) {
|
|
412
|
+
const maxGroups = options.maxGroups ?? 10;
|
|
413
|
+
const showHeader = options.showHeader !== false;
|
|
414
|
+
const lines = [];
|
|
415
|
+
|
|
416
|
+
if (!traces || traces.length === 0) {
|
|
417
|
+
return 'No traces collected';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Sort by timestamp
|
|
421
|
+
const sorted = [...traces].sort((a, b) => a.timestamp - b.timestamp);
|
|
422
|
+
|
|
423
|
+
// Group by traceId
|
|
424
|
+
const groups = this.groupTracesByRequest(sorted);
|
|
425
|
+
|
|
426
|
+
if (showHeader) {
|
|
427
|
+
lines.push('='.repeat(60));
|
|
428
|
+
lines.push('TRACE OUTPUT');
|
|
429
|
+
lines.push('='.repeat(60));
|
|
430
|
+
lines.push(`Traces: ${traces.length} | Requests: ${groups.size}`);
|
|
431
|
+
lines.push('');
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Show each request's trace tree
|
|
435
|
+
let shown = 0;
|
|
436
|
+
for (const [, groupTraces] of groups) {
|
|
437
|
+
if (shown >= maxGroups) {
|
|
438
|
+
lines.push(`... and ${groups.size - maxGroups} more requests`);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Sort within group
|
|
443
|
+
groupTraces.sort((a, b) => a.timestamp - b.timestamp);
|
|
444
|
+
|
|
445
|
+
// Find root trace (depth 0)
|
|
446
|
+
const root = groupTraces.find(t => t.depth === 0);
|
|
447
|
+
const rootName = root?.name || 'Request';
|
|
448
|
+
|
|
449
|
+
lines.push(`--- ${rootName} ---`);
|
|
450
|
+
|
|
451
|
+
for (const trace of groupTraces) {
|
|
452
|
+
const indent = ' '.repeat((trace.depth || 0) + 1);
|
|
453
|
+
const ms = trace.duration != null ? `${Math.round(trace.duration)}ms` : '';
|
|
454
|
+
const err = trace.error ? `ERR: ${this.truncate(trace.error.message || trace.error, 40)}` : '';
|
|
455
|
+
const ret = !err && trace.result != null
|
|
456
|
+
? this.truncate(JSON.stringify(trace.result), 40)
|
|
457
|
+
: '';
|
|
458
|
+
|
|
459
|
+
lines.push(`${indent}fn:${trace.name} depth:${trace.depth} ${ms} ${err || ret}`.trimEnd());
|
|
460
|
+
}
|
|
461
|
+
lines.push('');
|
|
462
|
+
shown++;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return lines.join('\n');
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Print trace tree to console with optional TOON summary
|
|
470
|
+
* Convenience method for test afterAll hooks
|
|
471
|
+
*
|
|
472
|
+
* @param {Array} traces - Array of trace objects
|
|
473
|
+
* @param {Object} options - Options
|
|
474
|
+
* @param {boolean} options.showToon - Also show TOON format (default: true)
|
|
475
|
+
* @param {number} options.toonLimit - Max traces for TOON output (default: 30)
|
|
476
|
+
*/
|
|
477
|
+
printTraceTree(traces, options = {}) {
|
|
478
|
+
const showToon = options.showToon !== false;
|
|
479
|
+
const toonLimit = options.toonLimit ?? 30;
|
|
480
|
+
|
|
481
|
+
// Print the tree format
|
|
482
|
+
console.log('\n' + this.formatTraceTree(traces, options));
|
|
483
|
+
|
|
484
|
+
// Optionally print TOON format
|
|
485
|
+
if (showToon && traces.length > 0) {
|
|
486
|
+
console.log('='.repeat(60));
|
|
487
|
+
console.log('TOON FORMAT');
|
|
488
|
+
console.log('='.repeat(60));
|
|
489
|
+
console.log(this.format({
|
|
490
|
+
stats: { passed: 0, total: 0 },
|
|
491
|
+
trace: traces.slice(0, toonLimit)
|
|
492
|
+
}));
|
|
493
|
+
console.log('='.repeat(60));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export default ToonFormatter;
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import { EventEmitter } from "node:events";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TraceCollector - Unix domain socket server for aggregating traces from multiple worker processes.
|
|
8
|
+
*
|
|
9
|
+
* Architecture:
|
|
10
|
+
* - Main process starts the collector before spawning test workers
|
|
11
|
+
* - Workers connect via Unix socket and send NDJSON trace messages
|
|
12
|
+
* - Collector aggregates, deduplicates, and filters traces
|
|
13
|
+
* - After tests complete, main process retrieves aggregated traces
|
|
14
|
+
*/
|
|
15
|
+
export class TraceCollector extends EventEmitter {
|
|
16
|
+
constructor(options = {}) {
|
|
17
|
+
super();
|
|
18
|
+
this.sessionId = options.sessionId || crypto.randomUUID();
|
|
19
|
+
this.socketPath = options.socketPath || this._getDefaultSocketPath();
|
|
20
|
+
this.filter = options.filter || (() => true);
|
|
21
|
+
this.maxTraces = options.maxTraces || 10000;
|
|
22
|
+
|
|
23
|
+
this.traces = [];
|
|
24
|
+
this.traceIds = new Set(); // For deduplication
|
|
25
|
+
this.server = null;
|
|
26
|
+
this.connections = new Set();
|
|
27
|
+
this.started = false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_getDefaultSocketPath() {
|
|
31
|
+
if (process.platform === "win32") {
|
|
32
|
+
return `\\\\?\\pipe\\taist-collector-${this.sessionId}`;
|
|
33
|
+
}
|
|
34
|
+
return `/tmp/taist-collector-${this.sessionId}.sock`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async start() {
|
|
38
|
+
if (this.started) {
|
|
39
|
+
throw new Error("TraceCollector already started");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Clean up any stale socket file
|
|
43
|
+
if (process.platform !== "win32") {
|
|
44
|
+
try {
|
|
45
|
+
fs.unlinkSync(this.socketPath);
|
|
46
|
+
} catch {
|
|
47
|
+
// Ignore if doesn't exist
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
this.server = net.createServer((socket) => {
|
|
53
|
+
this._handleConnection(socket);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
this.server.on("error", (err) => {
|
|
57
|
+
if (!this.started) {
|
|
58
|
+
reject(err);
|
|
59
|
+
} else {
|
|
60
|
+
this.emit("error", err);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
this.server.listen(this.socketPath, () => {
|
|
65
|
+
this.started = true;
|
|
66
|
+
this.emit("started", { socketPath: this.socketPath });
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_handleConnection(socket) {
|
|
73
|
+
this.connections.add(socket);
|
|
74
|
+
let buffer = "";
|
|
75
|
+
|
|
76
|
+
socket.on("data", (chunk) => {
|
|
77
|
+
buffer += chunk.toString();
|
|
78
|
+
|
|
79
|
+
// Process complete NDJSON lines
|
|
80
|
+
const lines = buffer.split("\n");
|
|
81
|
+
buffer = lines.pop(); // Keep incomplete line in buffer
|
|
82
|
+
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
if (line.trim()) {
|
|
85
|
+
this._processMessage(line);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
socket.on("close", () => {
|
|
91
|
+
// Process any remaining data in buffer
|
|
92
|
+
if (buffer.trim()) {
|
|
93
|
+
this._processMessage(buffer);
|
|
94
|
+
}
|
|
95
|
+
this.connections.delete(socket);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
socket.on("error", (err) => {
|
|
99
|
+
this.emit("connectionError", err);
|
|
100
|
+
this.connections.delete(socket);
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
_processMessage(line) {
|
|
105
|
+
try {
|
|
106
|
+
const message = JSON.parse(line);
|
|
107
|
+
|
|
108
|
+
if (message.type === "trace") {
|
|
109
|
+
this._addTrace(message.data);
|
|
110
|
+
} else if (message.type === "batch") {
|
|
111
|
+
for (const trace of message.data) {
|
|
112
|
+
this._addTrace(trace);
|
|
113
|
+
}
|
|
114
|
+
} else if (message.type === "flush") {
|
|
115
|
+
this.emit("flush", { workerId: message.workerId });
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
this.emit("parseError", { error: err, line });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
_addTrace(trace) {
|
|
123
|
+
// Generate trace ID for deduplication
|
|
124
|
+
const traceId =
|
|
125
|
+
trace.id || `${trace.name}-${trace.timestamp}-${trace.type}`;
|
|
126
|
+
|
|
127
|
+
if (this.traceIds.has(traceId)) {
|
|
128
|
+
return; // Duplicate
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Apply filter
|
|
132
|
+
if (!this.filter(trace)) {
|
|
133
|
+
return; // Filtered out
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Enforce max traces (circular buffer behavior)
|
|
137
|
+
if (this.traces.length >= this.maxTraces) {
|
|
138
|
+
const removed = this.traces.shift();
|
|
139
|
+
this.traceIds.delete(removed.id || `${removed.name}-${removed.timestamp}-${removed.type}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.traces.push(trace);
|
|
143
|
+
this.traceIds.add(traceId);
|
|
144
|
+
this.emit("trace", trace);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
getTraces() {
|
|
148
|
+
return [...this.traces];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
getTraceCount() {
|
|
152
|
+
return this.traces.length;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
clearTraces() {
|
|
156
|
+
this.traces = [];
|
|
157
|
+
this.traceIds.clear();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async stop() {
|
|
161
|
+
if (!this.started) {
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
// Close all active connections
|
|
167
|
+
for (const socket of this.connections) {
|
|
168
|
+
socket.destroy();
|
|
169
|
+
}
|
|
170
|
+
this.connections.clear();
|
|
171
|
+
|
|
172
|
+
// Close server
|
|
173
|
+
this.server.close(() => {
|
|
174
|
+
this.started = false;
|
|
175
|
+
|
|
176
|
+
// Clean up socket file
|
|
177
|
+
if (process.platform !== "win32") {
|
|
178
|
+
try {
|
|
179
|
+
fs.unlinkSync(this.socketPath);
|
|
180
|
+
} catch {
|
|
181
|
+
// Ignore
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
this.emit("stopped");
|
|
186
|
+
resolve();
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getSocketPath() {
|
|
192
|
+
return this.socketPath;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
isRunning() {
|
|
196
|
+
return this.started;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Create a default filter that excludes taist's own traces
|
|
202
|
+
*/
|
|
203
|
+
export function createDefaultFilter(options = {}) {
|
|
204
|
+
const excludePatterns = options.exclude || [
|
|
205
|
+
"/taist/",
|
|
206
|
+
"/node_modules/taist/",
|
|
207
|
+
"taist/lib/",
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
return (trace) => {
|
|
211
|
+
const name = trace.name || "";
|
|
212
|
+
for (const pattern of excludePatterns) {
|
|
213
|
+
if (name.includes(pattern)) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return true;
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default TraceCollector;
|