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.
- package/.taistrc.json +15 -0
- package/LICENSE +20 -0
- package/README.md +782 -0
- package/index.js +147 -0
- package/lib/ast-transformer.js +282 -0
- package/lib/execution-tracer.js +370 -0
- package/lib/loader-hooks.js +71 -0
- package/lib/loader.js +154 -0
- package/lib/output-formatter.js +151 -0
- package/lib/spawn-traced.js +204 -0
- package/lib/toon-formatter.js +351 -0
- package/lib/traced.js +223 -0
- package/lib/tracer-setup.js +115 -0
- package/lib/vitest-plugin.js +138 -0
- package/lib/vitest-runner.js +326 -0
- package/lib/watch-handler.js +300 -0
- package/package.json +71 -0
- package/taist.js +294 -0
package/lib/traced.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taist Traced - Seamless integration testing helpers for Vitest
|
|
3
|
+
*
|
|
4
|
+
* Spawns child processes with tracing enabled, automatically merging
|
|
5
|
+
* traces into the main Vitest trace output.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* import { traced } from 'taist/lib/traced.js';
|
|
9
|
+
*
|
|
10
|
+
* const result = await traced.run('./my-script.js', ['--arg']);
|
|
11
|
+
* expect(result.exitCode).toBe(0);
|
|
12
|
+
* // Traces automatically appear in TOON output - no manual inspection needed
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { writeFileSync, readFileSync, existsSync, appendFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join, resolve, dirname } from 'node:path';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
22
|
+
const __dirname = dirname(__filename);
|
|
23
|
+
const loaderPath = resolve(__dirname, 'loader.js');
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Get the shared trace file path from Vitest environment
|
|
27
|
+
* Falls back to creating a temp file if not in Vitest context
|
|
28
|
+
*/
|
|
29
|
+
function getTraceFilePath() {
|
|
30
|
+
// Use the same trace file as Vitest workers
|
|
31
|
+
if (process.env.TAIST_TRACE_FILE) {
|
|
32
|
+
return process.env.TAIST_TRACE_FILE;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fallback for standalone usage
|
|
36
|
+
const path = join(tmpdir(), `taist-integration-${Date.now()}.json`);
|
|
37
|
+
if (!existsSync(path)) {
|
|
38
|
+
writeFileSync(path, '[]');
|
|
39
|
+
}
|
|
40
|
+
return path;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Merge traces from a child process into the main trace file
|
|
45
|
+
*/
|
|
46
|
+
function mergeTraces(childTraceFile, mainTraceFile) {
|
|
47
|
+
try {
|
|
48
|
+
if (!existsSync(childTraceFile)) return;
|
|
49
|
+
|
|
50
|
+
const childTraces = JSON.parse(readFileSync(childTraceFile, 'utf-8'));
|
|
51
|
+
if (childTraces.length === 0) return;
|
|
52
|
+
|
|
53
|
+
let mainTraces = [];
|
|
54
|
+
if (existsSync(mainTraceFile)) {
|
|
55
|
+
try {
|
|
56
|
+
mainTraces = JSON.parse(readFileSync(mainTraceFile, 'utf-8'));
|
|
57
|
+
} catch {
|
|
58
|
+
mainTraces = [];
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
writeFileSync(mainTraceFile, JSON.stringify([...mainTraces, ...childTraces]));
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// Silently fail - traces are optional
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a Node.js script with tracing, merging traces into Vitest output
|
|
70
|
+
*
|
|
71
|
+
* @param {string} scriptPath - Path to the script to run
|
|
72
|
+
* @param {string[]} args - Arguments to pass to the script
|
|
73
|
+
* @param {Object} options - Options
|
|
74
|
+
* @returns {Promise<{ stdout: string, stderr: string, exitCode: number }>}
|
|
75
|
+
*/
|
|
76
|
+
async function run(scriptPath, args = [], options = {}) {
|
|
77
|
+
const mainTraceFile = getTraceFilePath();
|
|
78
|
+
const childTraceFile = join(tmpdir(), `taist-child-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
79
|
+
writeFileSync(childTraceFile, '[]');
|
|
80
|
+
|
|
81
|
+
const env = {
|
|
82
|
+
...process.env,
|
|
83
|
+
...options.env,
|
|
84
|
+
TAIST_TRACE_FILE: childTraceFile,
|
|
85
|
+
NODE_OPTIONS: `--import ${loaderPath} ${process.env.NODE_OPTIONS || ''}`.trim()
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
return new Promise((resolvePromise, reject) => {
|
|
89
|
+
const child = spawn('node', [scriptPath, ...args], {
|
|
90
|
+
cwd: options.cwd || process.cwd(),
|
|
91
|
+
env,
|
|
92
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let stdout = '';
|
|
96
|
+
let stderr = '';
|
|
97
|
+
|
|
98
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
99
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
100
|
+
|
|
101
|
+
child.on('error', reject);
|
|
102
|
+
|
|
103
|
+
child.on('close', (exitCode) => {
|
|
104
|
+
// Small delay for trace file to be written
|
|
105
|
+
setTimeout(() => {
|
|
106
|
+
mergeTraces(childTraceFile, mainTraceFile);
|
|
107
|
+
try { existsSync(childTraceFile) && require('fs').unlinkSync(childTraceFile); } catch {}
|
|
108
|
+
|
|
109
|
+
resolvePromise({
|
|
110
|
+
stdout,
|
|
111
|
+
stderr,
|
|
112
|
+
exitCode: exitCode ?? 0
|
|
113
|
+
});
|
|
114
|
+
}, 50);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Execute any command with tracing (for Node.js commands)
|
|
121
|
+
*/
|
|
122
|
+
async function exec(command, args = [], options = {}) {
|
|
123
|
+
const mainTraceFile = getTraceFilePath();
|
|
124
|
+
const childTraceFile = join(tmpdir(), `taist-child-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
|
125
|
+
writeFileSync(childTraceFile, '[]');
|
|
126
|
+
|
|
127
|
+
const env = {
|
|
128
|
+
...process.env,
|
|
129
|
+
...options.env,
|
|
130
|
+
TAIST_TRACE_FILE: childTraceFile,
|
|
131
|
+
NODE_OPTIONS: `--import ${loaderPath} ${process.env.NODE_OPTIONS || ''}`.trim()
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return new Promise((resolvePromise, reject) => {
|
|
135
|
+
const child = spawn(command, args, {
|
|
136
|
+
cwd: options.cwd || process.cwd(),
|
|
137
|
+
env,
|
|
138
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
let stdout = '';
|
|
142
|
+
let stderr = '';
|
|
143
|
+
|
|
144
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
145
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
146
|
+
|
|
147
|
+
child.on('error', reject);
|
|
148
|
+
|
|
149
|
+
child.on('close', (exitCode) => {
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
mergeTraces(childTraceFile, mainTraceFile);
|
|
152
|
+
try { existsSync(childTraceFile) && require('fs').unlinkSync(childTraceFile); } catch {}
|
|
153
|
+
|
|
154
|
+
resolvePromise({
|
|
155
|
+
stdout,
|
|
156
|
+
stderr,
|
|
157
|
+
exitCode: exitCode ?? 0
|
|
158
|
+
});
|
|
159
|
+
}, 50);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Start a long-running service with tracing
|
|
166
|
+
* Returns control functions to manage the service lifecycle
|
|
167
|
+
*/
|
|
168
|
+
function serve(scriptPath, args = [], options = {}) {
|
|
169
|
+
const mainTraceFile = getTraceFilePath();
|
|
170
|
+
const childTraceFile = join(tmpdir(), `taist-service-${Date.now()}.json`);
|
|
171
|
+
writeFileSync(childTraceFile, '[]');
|
|
172
|
+
|
|
173
|
+
const env = {
|
|
174
|
+
...process.env,
|
|
175
|
+
...options.env,
|
|
176
|
+
TAIST_TRACE_FILE: childTraceFile,
|
|
177
|
+
NODE_OPTIONS: `--import ${loaderPath} ${process.env.NODE_OPTIONS || ''}`.trim()
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const child = spawn('node', [scriptPath, ...args], {
|
|
181
|
+
cwd: options.cwd || process.cwd(),
|
|
182
|
+
env,
|
|
183
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
let stdout = '';
|
|
187
|
+
let stderr = '';
|
|
188
|
+
|
|
189
|
+
child.stdout.on('data', (data) => { stdout += data.toString(); });
|
|
190
|
+
child.stderr.on('data', (data) => { stderr += data.toString(); });
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
/** The underlying child process */
|
|
194
|
+
process: child,
|
|
195
|
+
|
|
196
|
+
/** Get current stdout */
|
|
197
|
+
get stdout() { return stdout; },
|
|
198
|
+
|
|
199
|
+
/** Get current stderr */
|
|
200
|
+
get stderr() { return stderr; },
|
|
201
|
+
|
|
202
|
+
/** Wait for output to contain a string (e.g., "Server listening") */
|
|
203
|
+
async waitFor(text, timeout = 5000) {
|
|
204
|
+
const start = Date.now();
|
|
205
|
+
while (Date.now() - start < timeout) {
|
|
206
|
+
if (stdout.includes(text) || stderr.includes(text)) return true;
|
|
207
|
+
await new Promise(r => setTimeout(r, 100));
|
|
208
|
+
}
|
|
209
|
+
throw new Error(`Timeout waiting for "${text}"`);
|
|
210
|
+
},
|
|
211
|
+
|
|
212
|
+
/** Stop the service and merge traces */
|
|
213
|
+
async stop() {
|
|
214
|
+
child.kill('SIGTERM');
|
|
215
|
+
await new Promise(r => setTimeout(r, 100));
|
|
216
|
+
mergeTraces(childTraceFile, mainTraceFile);
|
|
217
|
+
try { existsSync(childTraceFile) && require('fs').unlinkSync(childTraceFile); } catch {}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export const traced = { run, exec, serve };
|
|
223
|
+
export default traced;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vitest setup file for execution tracing
|
|
3
|
+
* This runs in the worker context before tests, setting up the global tracer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Simple tracer that collects execution traces
|
|
7
|
+
const traces = [];
|
|
8
|
+
const callStack = [];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Safely serialize a value for tracing
|
|
12
|
+
* Handles circular references and large objects
|
|
13
|
+
*/
|
|
14
|
+
function safeSerialize(value, depth = 0) {
|
|
15
|
+
if (depth > 2) return '[max depth]';
|
|
16
|
+
if (value === undefined) return undefined;
|
|
17
|
+
if (value === null) return null;
|
|
18
|
+
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`;
|
|
19
|
+
if (typeof value === 'symbol') return value.toString();
|
|
20
|
+
if (typeof value === 'bigint') return value.toString() + 'n';
|
|
21
|
+
if (typeof value !== 'object') return value;
|
|
22
|
+
|
|
23
|
+
// Handle arrays
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
if (value.length > 5) {
|
|
26
|
+
return [...value.slice(0, 5).map(v => safeSerialize(v, depth + 1)), `...+${value.length - 5}`];
|
|
27
|
+
}
|
|
28
|
+
return value.map(v => safeSerialize(v, depth + 1));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Handle objects
|
|
32
|
+
try {
|
|
33
|
+
const keys = Object.keys(value);
|
|
34
|
+
if (keys.length > 5) {
|
|
35
|
+
const result = {};
|
|
36
|
+
keys.slice(0, 5).forEach(k => {
|
|
37
|
+
result[k] = safeSerialize(value[k], depth + 1);
|
|
38
|
+
});
|
|
39
|
+
result['...'] = `+${keys.length - 5} more`;
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
const result = {};
|
|
43
|
+
keys.forEach(k => {
|
|
44
|
+
result[k] = safeSerialize(value[k], depth + 1);
|
|
45
|
+
});
|
|
46
|
+
return result;
|
|
47
|
+
} catch {
|
|
48
|
+
return '[unserializable]';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
globalThis.__taistTracer = {
|
|
53
|
+
enter(name, args) {
|
|
54
|
+
const startTime = performance.now();
|
|
55
|
+
callStack.push({ name, startTime, args: safeSerialize(args) });
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
exit(name, result) {
|
|
59
|
+
const entry = callStack.pop();
|
|
60
|
+
if (!entry) return;
|
|
61
|
+
|
|
62
|
+
const duration = performance.now() - entry.startTime;
|
|
63
|
+
traces.push({
|
|
64
|
+
name: entry.name,
|
|
65
|
+
duration: Math.round(duration * 100) / 100,
|
|
66
|
+
args: entry.args,
|
|
67
|
+
result: safeSerialize(result),
|
|
68
|
+
type: 'exit'
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
error(name, error) {
|
|
73
|
+
const entry = callStack.pop();
|
|
74
|
+
if (!entry) return;
|
|
75
|
+
|
|
76
|
+
const duration = performance.now() - entry.startTime;
|
|
77
|
+
traces.push({
|
|
78
|
+
name: entry.name,
|
|
79
|
+
duration: Math.round(duration * 100) / 100,
|
|
80
|
+
args: entry.args,
|
|
81
|
+
error: {
|
|
82
|
+
name: error?.name || 'Error',
|
|
83
|
+
message: error?.message || String(error)
|
|
84
|
+
},
|
|
85
|
+
type: 'error'
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
getTraces() {
|
|
90
|
+
return traces;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
// Export traces at the end of all tests via afterAll hook
|
|
95
|
+
if (typeof afterAll === 'function') {
|
|
96
|
+
afterAll(async () => {
|
|
97
|
+
if (traces.length > 0) {
|
|
98
|
+
// Write traces to the file specified by TAIST_TRACE_FILE env var
|
|
99
|
+
const tracePath = process.env.TAIST_TRACE_FILE;
|
|
100
|
+
if (tracePath) {
|
|
101
|
+
const fs = await import('fs');
|
|
102
|
+
try {
|
|
103
|
+
// Read existing traces and append
|
|
104
|
+
let existing = [];
|
|
105
|
+
if (fs.existsSync(tracePath)) {
|
|
106
|
+
existing = JSON.parse(fs.readFileSync(tracePath, 'utf-8'));
|
|
107
|
+
}
|
|
108
|
+
fs.writeFileSync(tracePath, JSON.stringify([...existing, ...traces]));
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error('[taist] Failed to write traces:', e.message);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taist Vitest Plugin - Code instrumentation for execution tracing
|
|
3
|
+
*
|
|
4
|
+
* This plugin integrates with Vite/Vitest to transform source code at build time,
|
|
5
|
+
* injecting tracer calls at function entry/exit points.
|
|
6
|
+
*
|
|
7
|
+
* Captures:
|
|
8
|
+
* - Function entry with arguments
|
|
9
|
+
* - Return values
|
|
10
|
+
* - Thrown errors
|
|
11
|
+
* - Execution timing
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { transformCode, defaultShouldInstrument } from './ast-transformer.js';
|
|
15
|
+
import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'fs';
|
|
16
|
+
import { tmpdir } from 'os';
|
|
17
|
+
import { join } from 'path';
|
|
18
|
+
|
|
19
|
+
// Trace file path for cross-process communication
|
|
20
|
+
let traceFilePath = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initialize trace collection file
|
|
24
|
+
* @returns {string} Path to the trace file
|
|
25
|
+
*/
|
|
26
|
+
export function initTraceFile() {
|
|
27
|
+
traceFilePath = join(tmpdir(), `taist-traces-${Date.now()}.json`);
|
|
28
|
+
writeFileSync(traceFilePath, '[]');
|
|
29
|
+
return traceFilePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get collected traces from the trace file
|
|
34
|
+
* @returns {Array} Array of trace entries
|
|
35
|
+
*/
|
|
36
|
+
export function getCollectedTraces() {
|
|
37
|
+
if (!traceFilePath || !existsSync(traceFilePath)) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
const content = readFileSync(traceFilePath, 'utf-8');
|
|
42
|
+
return JSON.parse(content);
|
|
43
|
+
} catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Clean up trace file
|
|
50
|
+
*/
|
|
51
|
+
export function cleanupTraceFile() {
|
|
52
|
+
if (traceFilePath && existsSync(traceFilePath)) {
|
|
53
|
+
try {
|
|
54
|
+
unlinkSync(traceFilePath);
|
|
55
|
+
} catch {
|
|
56
|
+
// Ignore cleanup errors
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
traceFilePath = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Get trace file path
|
|
64
|
+
* @returns {string|null}
|
|
65
|
+
*/
|
|
66
|
+
export function getTraceFilePath() {
|
|
67
|
+
return traceFilePath;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Legacy functions for compatibility
|
|
71
|
+
export function setGlobalTracer(tracer) {
|
|
72
|
+
globalThis.__taistTracer = tracer;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function getGlobalTracer() {
|
|
76
|
+
return globalThis.__taistTracer;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Check if a file should be instrumented (Vitest-specific version)
|
|
81
|
+
* @param {string} id - File path
|
|
82
|
+
* @param {Object} options - Plugin options
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
function shouldInstrument(id, options) {
|
|
86
|
+
if (!options.enabled) return false;
|
|
87
|
+
|
|
88
|
+
// Use default filter as base
|
|
89
|
+
if (!defaultShouldInstrument(id, options)) {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Additional Vitest-specific exclusions
|
|
94
|
+
if (id.includes('vitest-plugin') || id.includes('execution-tracer') || id.includes('tracer-setup')) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create the Taist Vitest plugin for code instrumentation
|
|
103
|
+
* @param {Object} options - Plugin options
|
|
104
|
+
* @param {boolean} options.enabled - Whether instrumentation is enabled
|
|
105
|
+
* @param {number} options.depth - Trace depth level (1-5)
|
|
106
|
+
* @returns {import('vite').Plugin}
|
|
107
|
+
*/
|
|
108
|
+
export function taistPlugin(options = {}) {
|
|
109
|
+
const pluginOptions = {
|
|
110
|
+
enabled: true,
|
|
111
|
+
depth: 2,
|
|
112
|
+
...options
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
name: 'taist-tracer',
|
|
117
|
+
enforce: 'pre',
|
|
118
|
+
|
|
119
|
+
transform(code, id) {
|
|
120
|
+
if (!shouldInstrument(id, pluginOptions)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const result = transformCode(code, id, { debug: pluginOptions.debug });
|
|
125
|
+
|
|
126
|
+
if (result) {
|
|
127
|
+
return {
|
|
128
|
+
code: result.code,
|
|
129
|
+
map: result.map
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export default taistPlugin;
|