taist 1.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,151 @@
1
+ /**
2
+ * Output Formatter - Multi-format test result formatter
3
+ * Supports TOON, JSON, and Compact output formats
4
+ */
5
+
6
+ import { ToonFormatter } from './toon-formatter.js';
7
+
8
+ export class OutputFormatter {
9
+ constructor(options = {}) {
10
+ this.formatType = options.format || 'toon';
11
+ this.options = options;
12
+ this.toonFormatter = new ToonFormatter(options);
13
+ }
14
+
15
+ /**
16
+ * Format test results based on configured format
17
+ * @param {Object} results - Test results object
18
+ * @returns {string} - Formatted output
19
+ */
20
+ format(results) {
21
+ switch (this.formatType.toLowerCase()) {
22
+ case 'toon':
23
+ return this.formatToon(results);
24
+ case 'json':
25
+ return this.formatJson(results);
26
+ case 'compact':
27
+ return this.formatCompact(results);
28
+ default:
29
+ throw new Error(`Unknown format: ${this.formatType}. Use: toon, json, or compact`);
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Format as TOON
35
+ */
36
+ formatToon(results) {
37
+ return this.toonFormatter.format(results);
38
+ }
39
+
40
+ /**
41
+ * Format as JSON
42
+ */
43
+ formatJson(results) {
44
+ const output = {
45
+ status: this.determineStatus(results),
46
+ stats: results.stats || {
47
+ total: 0,
48
+ passed: 0,
49
+ failed: 0,
50
+ skipped: 0
51
+ },
52
+ failures: (results.failures || []).map(f => this.formatFailureForJson(f)),
53
+ trace: results.trace || [],
54
+ coverage: results.coverage || null,
55
+ duration: results.duration || 0,
56
+ timestamp: new Date().toISOString()
57
+ };
58
+
59
+ return JSON.stringify(output, null, this.options.pretty ? 2 : 0);
60
+ }
61
+
62
+ /**
63
+ * Format as compact one-liner
64
+ */
65
+ formatCompact(results) {
66
+ const stats = results.stats || {};
67
+ const passed = stats.passed || 0;
68
+ const total = stats.total || 0;
69
+ const failed = stats.failed || 0;
70
+
71
+ const status = failed === 0 ? '✓' : '✗';
72
+ const parts = [`${status} ${passed}/${total}`];
73
+
74
+ if (failed > 0) {
75
+ parts.push(`${failed} fail`);
76
+ if (results.failures && results.failures.length > 0) {
77
+ const firstError = results.failures[0];
78
+ const errorMsg = this.extractErrorMessage(firstError);
79
+ parts.push(`(${errorMsg})`);
80
+ }
81
+ }
82
+
83
+ if (results.coverage) {
84
+ parts.push(`cov:${Math.round(results.coverage.percent)}%`);
85
+ }
86
+
87
+ if (results.duration) {
88
+ parts.push(`${Math.round(results.duration)}ms`);
89
+ }
90
+
91
+ return parts.join(' ');
92
+ }
93
+
94
+ /**
95
+ * Determine overall status
96
+ */
97
+ determineStatus(results) {
98
+ const stats = results.stats || {};
99
+ if (stats.failed > 0) return 'fail';
100
+ if (stats.passed === 0 && stats.total === 0) return 'empty';
101
+ return 'pass';
102
+ }
103
+
104
+ /**
105
+ * Format failure for JSON output
106
+ */
107
+ formatFailureForJson(failure) {
108
+ return {
109
+ test: failure.test || failure.name,
110
+ error: this.extractErrorMessage(failure),
111
+ location: failure.location || null,
112
+ diff: failure.diff || null,
113
+ stack: this.formatStackForJson(failure.stack),
114
+ path: failure.path || null
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Extract error message
120
+ */
121
+ extractErrorMessage(failure) {
122
+ if (failure.error) {
123
+ if (typeof failure.error === 'string') return failure.error;
124
+ if (failure.error.message) return failure.error.message;
125
+ return String(failure.error);
126
+ }
127
+ return 'Unknown error';
128
+ }
129
+
130
+ /**
131
+ * Format stack trace for JSON
132
+ */
133
+ formatStackForJson(stack) {
134
+ if (!stack) return null;
135
+ if (typeof stack === 'string') {
136
+ return stack.split('\n')
137
+ .filter(line => line.trim())
138
+ .slice(0, 5);
139
+ }
140
+ return stack;
141
+ }
142
+
143
+ /**
144
+ * Set format type
145
+ */
146
+ setFormat(format) {
147
+ this.formatType = format;
148
+ }
149
+ }
150
+
151
+ export default OutputFormatter;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Taist Spawn Helper - Run child processes with execution tracing
3
+ *
4
+ * Provides utilities for spawning Node.js processes with instrumentation
5
+ * enabled, collecting traces, and integrating with test frameworks.
6
+ *
7
+ * Usage:
8
+ * import { spawnTraced, execTraced } from 'taist/spawn-traced';
9
+ *
10
+ * const { traces, stdout, stderr, exitCode } = await execTraced('node', ['my-script.js']);
11
+ * console.log(traces); // Array of execution traces
12
+ */
13
+
14
+ import { spawn, execFile } from 'node:child_process';
15
+ import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join, resolve, dirname } from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ // Get the directory of this file to find the loader
21
+ const __filename = fileURLToPath(import.meta.url);
22
+ const __dirname = dirname(__filename);
23
+ const loaderPath = resolve(__dirname, 'loader.js');
24
+
25
+ /**
26
+ * Create a temporary file for trace collection
27
+ * @returns {{ path: string, cleanup: () => void }}
28
+ */
29
+ function createTraceFile() {
30
+ const path = join(tmpdir(), `taist-trace-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
31
+ writeFileSync(path, '[]');
32
+ return {
33
+ path,
34
+ cleanup: () => {
35
+ try {
36
+ if (existsSync(path)) unlinkSync(path);
37
+ } catch {
38
+ // Ignore cleanup errors
39
+ }
40
+ }
41
+ };
42
+ }
43
+
44
+ /**
45
+ * Read traces from a trace file
46
+ * @param {string} path - Path to trace file
47
+ * @returns {Array} Array of trace entries
48
+ */
49
+ function readTraces(path) {
50
+ try {
51
+ if (!existsSync(path)) return [];
52
+ const content = readFileSync(path, 'utf-8');
53
+ return JSON.parse(content);
54
+ } catch {
55
+ return [];
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Execute a Node.js script with tracing enabled
61
+ * Returns a promise that resolves when the process exits
62
+ *
63
+ * @param {string} command - Command to run (usually 'node')
64
+ * @param {string[]} args - Arguments to pass
65
+ * @param {Object} options - Options
66
+ * @param {string} options.cwd - Working directory
67
+ * @param {Object} options.env - Additional environment variables
68
+ * @param {boolean} options.debug - Enable debug output
69
+ * @param {number} options.timeout - Timeout in milliseconds
70
+ * @returns {Promise<{ traces: Array, stdout: string, stderr: string, exitCode: number }>}
71
+ */
72
+ export async function execTraced(command, args = [], options = {}) {
73
+ const traceFile = createTraceFile();
74
+
75
+ const env = {
76
+ ...process.env,
77
+ ...options.env,
78
+ TAIST_TRACE_FILE: traceFile.path,
79
+ NODE_OPTIONS: `--import ${loaderPath} ${process.env.NODE_OPTIONS || ''}`.trim()
80
+ };
81
+
82
+ if (options.debug) {
83
+ env.TAIST_DEBUG = '1';
84
+ }
85
+
86
+ return new Promise((resolvePromise, reject) => {
87
+ const child = spawn(command, args, {
88
+ cwd: options.cwd || process.cwd(),
89
+ env,
90
+ stdio: ['pipe', 'pipe', 'pipe']
91
+ });
92
+
93
+ let stdout = '';
94
+ let stderr = '';
95
+ let timeoutId;
96
+
97
+ if (options.timeout) {
98
+ timeoutId = setTimeout(() => {
99
+ child.kill('SIGTERM');
100
+ reject(new Error(`Process timed out after ${options.timeout}ms`));
101
+ }, options.timeout);
102
+ }
103
+
104
+ child.stdout.on('data', (data) => {
105
+ stdout += data.toString();
106
+ });
107
+
108
+ child.stderr.on('data', (data) => {
109
+ stderr += data.toString();
110
+ });
111
+
112
+ child.on('error', (error) => {
113
+ if (timeoutId) clearTimeout(timeoutId);
114
+ traceFile.cleanup();
115
+ reject(error);
116
+ });
117
+
118
+ child.on('close', (exitCode) => {
119
+ if (timeoutId) clearTimeout(timeoutId);
120
+
121
+ // Give a small delay for trace file to be written
122
+ setTimeout(() => {
123
+ const traces = readTraces(traceFile.path);
124
+ traceFile.cleanup();
125
+
126
+ resolvePromise({
127
+ traces,
128
+ stdout,
129
+ stderr,
130
+ exitCode: exitCode ?? 0
131
+ });
132
+ }, 50);
133
+ });
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Spawn a Node.js process with tracing enabled
139
+ * Returns the child process immediately (for streaming output)
140
+ *
141
+ * @param {string} command - Command to run
142
+ * @param {string[]} args - Arguments
143
+ * @param {Object} options - Spawn options
144
+ * @returns {{ child: ChildProcess, getTraces: () => Array, cleanup: () => void }}
145
+ */
146
+ export function spawnTraced(command, args = [], options = {}) {
147
+ const traceFile = createTraceFile();
148
+
149
+ const env = {
150
+ ...process.env,
151
+ ...options.env,
152
+ TAIST_TRACE_FILE: traceFile.path,
153
+ NODE_OPTIONS: `--import ${loaderPath} ${process.env.NODE_OPTIONS || ''}`.trim()
154
+ };
155
+
156
+ if (options.debug) {
157
+ env.TAIST_DEBUG = '1';
158
+ }
159
+
160
+ const child = spawn(command, args, {
161
+ ...options,
162
+ env,
163
+ stdio: options.stdio || 'pipe'
164
+ });
165
+
166
+ return {
167
+ child,
168
+ getTraces: () => readTraces(traceFile.path),
169
+ cleanup: () => traceFile.cleanup()
170
+ };
171
+ }
172
+
173
+ /**
174
+ * Run a Node.js script file with tracing
175
+ * Convenience wrapper for execTraced('node', [scriptPath, ...args])
176
+ *
177
+ * @param {string} scriptPath - Path to the script to run
178
+ * @param {string[]} args - Arguments to pass to the script
179
+ * @param {Object} options - Options (same as execTraced)
180
+ * @returns {Promise<{ traces: Array, stdout: string, stderr: string, exitCode: number }>}
181
+ */
182
+ export async function runTraced(scriptPath, args = [], options = {}) {
183
+ return execTraced('node', [scriptPath, ...args], options);
184
+ }
185
+
186
+ /**
187
+ * Create a test helper for integration testing
188
+ * Returns functions pre-configured for a specific working directory
189
+ *
190
+ * @param {string} cwd - Working directory for the tests
191
+ * @returns {{ run: Function, exec: Function, spawn: Function }}
192
+ */
193
+ export function createTestHelper(cwd) {
194
+ return {
195
+ run: (scriptPath, args, options) =>
196
+ runTraced(scriptPath, args, { ...options, cwd }),
197
+ exec: (command, args, options) =>
198
+ execTraced(command, args, { ...options, cwd }),
199
+ spawn: (command, args, options) =>
200
+ spawnTraced(command, args, { ...options, cwd })
201
+ };
202
+ }
203
+
204
+ export default { execTraced, spawnTraced, runTraced, createTestHelper };
@@ -0,0 +1,351 @@
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
136
+ */
137
+ formatTraceEntry(entry) {
138
+ const parts = [`fn:${entry.name}`];
139
+
140
+ if (entry.duration !== undefined) {
141
+ parts.push(`ms:${Math.round(entry.duration)}`);
142
+ }
143
+
144
+ if (entry.args && entry.args.length > 0) {
145
+ const args = entry.args
146
+ .slice(0, this.options.maxArrayItems)
147
+ .map(arg => this.formatValue(arg))
148
+ .join(',');
149
+ parts.push(`args:[${args}]`);
150
+ }
151
+
152
+ if (entry.result !== undefined) {
153
+ parts.push(`ret:${this.formatValue(entry.result)}`);
154
+ }
155
+
156
+ if (entry.error) {
157
+ parts.push(`err:${this.cleanErrorMessage(entry.error)}`);
158
+ }
159
+
160
+ return ` ${parts.join(' ')}`;
161
+ }
162
+
163
+ /**
164
+ * Format coverage information
165
+ */
166
+ formatCoverage(coverage) {
167
+ const percent = Math.round(coverage.percent || 0);
168
+ const covered = coverage.covered || 0;
169
+ const total = coverage.total || 0;
170
+ return `COV: ${percent}% (${covered}/${total})`;
171
+ }
172
+
173
+ /**
174
+ * Format a location reference
175
+ */
176
+ formatLocation(location) {
177
+ if (typeof location === 'string') {
178
+ return this.abbreviatePath(location);
179
+ }
180
+
181
+ const file = this.abbreviatePath(location.file || '');
182
+ const line = location.line || '';
183
+ const col = location.column || '';
184
+
185
+ if (col) {
186
+ return `${file}:${line}:${col}`;
187
+ } else if (line) {
188
+ return `${file}:${line}`;
189
+ }
190
+ return file;
191
+ }
192
+
193
+ /**
194
+ * Format an execution path
195
+ */
196
+ formatPath(path) {
197
+ if (Array.isArray(path)) {
198
+ return path
199
+ .map(step => {
200
+ if (typeof step === 'string') return step;
201
+ if (step.fn && step.result !== undefined) {
202
+ return `${step.fn}(...)→${this.formatValue(step.result)}`;
203
+ }
204
+ return step.fn || String(step);
205
+ })
206
+ .join('→');
207
+ }
208
+ return String(path);
209
+ }
210
+
211
+ /**
212
+ * Format a value for output
213
+ */
214
+ formatValue(value) {
215
+ if (value === null) return 'nil';
216
+ if (value === undefined) return 'undef';
217
+
218
+ const type = typeof value;
219
+
220
+ if (type === 'string') {
221
+ return `"${this.truncate(value)}"`;
222
+ }
223
+
224
+ if (type === 'number' || type === 'boolean') {
225
+ return String(value);
226
+ }
227
+
228
+ if (Array.isArray(value)) {
229
+ if (value.length === 0) return '[]';
230
+ const items = value
231
+ .slice(0, this.options.maxArrayItems)
232
+ .map(v => this.formatValue(v))
233
+ .join(',');
234
+ const more = value.length > this.options.maxArrayItems
235
+ ? `...+${value.length - this.options.maxArrayItems}`
236
+ : '';
237
+ return `[${items}${more}]`;
238
+ }
239
+
240
+ if (type === 'object') {
241
+ const keys = Object.keys(value).slice(0, this.options.maxObjectKeys);
242
+ if (keys.length === 0) return '{}';
243
+ const more = Object.keys(value).length > this.options.maxObjectKeys
244
+ ? '...'
245
+ : '';
246
+ return `{${keys.join(',')}${more}}`;
247
+ }
248
+
249
+ return String(value).slice(0, 20);
250
+ }
251
+
252
+ /**
253
+ * Format stack trace
254
+ */
255
+ formatStack(stack) {
256
+ if (typeof stack === 'string') {
257
+ const lines = stack.split('\n')
258
+ .filter(line => line.trim() && !line.includes('node_modules'))
259
+ .slice(0, this.options.maxStackFrames);
260
+
261
+ return lines
262
+ .map(line => {
263
+ // Extract file:line:col from stack frame
264
+ const match = line.match(/\((.+):(\d+):(\d+)\)/) ||
265
+ line.match(/at (.+):(\d+):(\d+)/);
266
+ if (match) {
267
+ const [, file, line, col] = match;
268
+ return `@${this.abbreviatePath(file)}:${line}`;
269
+ }
270
+ return line.trim().slice(0, 50);
271
+ })
272
+ .join(' ');
273
+ }
274
+ return '';
275
+ }
276
+
277
+ /**
278
+ * Clean error message
279
+ */
280
+ cleanErrorMessage(error) {
281
+ if (typeof error === 'object' && error.message) {
282
+ error = error.message;
283
+ }
284
+
285
+ let msg = String(error);
286
+
287
+ // Remove ANSI codes
288
+ msg = msg.replace(/\u001b\[\d+m/g, '');
289
+
290
+ // Remove timestamps
291
+ msg = msg.replace(/\[\d{2}:\d{2}:\d{2}\]/g, '');
292
+
293
+ // Remove absolute paths
294
+ msg = msg.replace(/\/[^\s]+\//g, match => {
295
+ const parts = match.split('/');
296
+ return parts[parts.length - 1] || match;
297
+ });
298
+
299
+ // Truncate
300
+ msg = this.truncate(msg);
301
+
302
+ return msg;
303
+ }
304
+
305
+ /**
306
+ * Abbreviate file path
307
+ */
308
+ abbreviatePath(path) {
309
+ if (!path) return '';
310
+
311
+ // Remove common prefixes
312
+ path = path.replace(/^.*\/node_modules\//, 'npm/');
313
+ path = path.replace(/^.*\/src\//, 'src/');
314
+ path = path.replace(/^.*\/test\//, 'test/');
315
+ path = path.replace(/^.*\/lib\//, 'lib/');
316
+
317
+ // Get just filename if still too long
318
+ if (path.length > 30) {
319
+ const parts = path.split('/');
320
+ path = parts[parts.length - 1];
321
+ }
322
+
323
+ return path;
324
+ }
325
+
326
+ /**
327
+ * Truncate string
328
+ */
329
+ truncate(str, maxLength = this.options.maxStringLength) {
330
+ if (!str) return '';
331
+ str = String(str);
332
+ if (str.length <= maxLength) return str;
333
+ return str.slice(0, maxLength - 3) + '...';
334
+ }
335
+
336
+ /**
337
+ * Apply abbreviations
338
+ */
339
+ abbreviate(text) {
340
+ if (!this.options.abbreviate) return text;
341
+
342
+ let result = text;
343
+ for (const [full, abbr] of Object.entries(this.abbrev)) {
344
+ const regex = new RegExp(`\\b${full}\\b`, 'gi');
345
+ result = result.replace(regex, abbr);
346
+ }
347
+ return result;
348
+ }
349
+ }
350
+
351
+ export default ToonFormatter;