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/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;