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.
@@ -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;