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,326 @@
1
+ /**
2
+ * Vitest Runner - Execute tests and collect results
3
+ * Custom runner and reporter for Vitest integration
4
+ */
5
+
6
+ import { Writable } from 'node:stream';
7
+ import { writeFileSync, readFileSync, existsSync, unlinkSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join, dirname } from 'node:path';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { startVitest } from 'vitest/node';
12
+ import { ExecutionTracer } from './execution-tracer.js';
13
+ import { taistPlugin } from './vitest-plugin.js';
14
+
15
+ const __dirname = dirname(fileURLToPath(import.meta.url));
16
+
17
+ /**
18
+ * Null writable stream that discards all output
19
+ * Used to suppress Vitest's stdout/stderr when not in verbose mode
20
+ */
21
+ class NullWritable extends Writable {
22
+ _write(chunk, encoding, callback) {
23
+ callback();
24
+ }
25
+ }
26
+
27
+ export class VitestRunner {
28
+ constructor(options = {}) {
29
+ this.options = options;
30
+ this.tracer = options.tracer || new ExecutionTracer(options.trace || {});
31
+ this.results = null;
32
+ this.verbose = options.verbose || false;
33
+ }
34
+
35
+ /**
36
+ * Run tests
37
+ * @param {Object} config - Test configuration
38
+ * @returns {Object} Test results
39
+ */
40
+ async run(config = {}) {
41
+ const vitestConfig = this.buildVitestConfig(config);
42
+
43
+ // Set up trace file for cross-process trace collection
44
+ let traceFilePath = null;
45
+ if (this.options.trace?.enabled) {
46
+ traceFilePath = join(tmpdir(), `taist-traces-${Date.now()}.json`);
47
+ writeFileSync(traceFilePath, '[]');
48
+ process.env.TAIST_TRACE_FILE = traceFilePath;
49
+ }
50
+
51
+ try {
52
+ if (this.options.trace?.enabled) {
53
+ this.tracer.start();
54
+ }
55
+
56
+ // Create VitestOptions with stream redirection when not in verbose mode
57
+ const vitestOptions = {};
58
+ if (!this.verbose) {
59
+ vitestOptions.stdout = new NullWritable();
60
+ vitestOptions.stderr = new NullWritable();
61
+ }
62
+
63
+ // Add the taist plugin and setup files when tracing is enabled
64
+ const viteOverrides = {};
65
+ if (this.options.trace?.enabled) {
66
+ viteOverrides.plugins = [
67
+ taistPlugin({
68
+ enabled: true,
69
+ depth: this.options.trace?.depth || 2
70
+ })
71
+ ];
72
+ // Add setup file to inject tracer into test workers
73
+ vitestConfig.setupFiles = [
74
+ ...(vitestConfig.setupFiles || []),
75
+ join(__dirname, 'tracer-setup.js')
76
+ ];
77
+ }
78
+
79
+ const vitest = await startVitest(
80
+ 'test',
81
+ [],
82
+ vitestConfig,
83
+ viteOverrides, // Vite config with our instrumentation plugin
84
+ vitestOptions // stdout/stderr stream redirection
85
+ );
86
+
87
+ if (!vitest) {
88
+ throw new Error('Failed to start Vitest');
89
+ }
90
+
91
+ // Wait for tests to complete
92
+ await vitest.close();
93
+
94
+ // Collect results
95
+ this.results = this.collectResults(vitest);
96
+
97
+ // Add trace data if enabled - read from trace file written by workers
98
+ if (this.options.trace?.enabled && traceFilePath) {
99
+ try {
100
+ if (existsSync(traceFilePath)) {
101
+ const traceContent = readFileSync(traceFilePath, 'utf-8');
102
+ this.results.trace = JSON.parse(traceContent);
103
+ // Clean up trace file
104
+ unlinkSync(traceFilePath);
105
+ }
106
+ } catch (e) {
107
+ // If reading traces fails, continue without them
108
+ this.results.trace = [];
109
+ }
110
+ this.tracer.stop();
111
+ }
112
+
113
+ return this.results;
114
+ } catch (error) {
115
+ return {
116
+ stats: {
117
+ total: 0,
118
+ passed: 0,
119
+ failed: 1,
120
+ skipped: 0
121
+ },
122
+ failures: [{
123
+ test: 'Test execution',
124
+ error: error.message,
125
+ stack: error.stack
126
+ }],
127
+ duration: 0
128
+ };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Build Vitest configuration
134
+ */
135
+ buildVitestConfig(config) {
136
+ const include = config.tests || config.test || ['**/*.test.js', '**/*.spec.js'];
137
+ const includeArray = Array.isArray(include) ? include : [include];
138
+
139
+ const vitestConfig = {
140
+ include: includeArray,
141
+ watch: false,
142
+ reporters: [], // No reporters to suppress output
143
+ ui: false,
144
+ outputFile: false,
145
+ logHeapUsage: false,
146
+ maxConcurrency: 1,
147
+ silent: true,
148
+ // When tracing is enabled, run tests in main thread to share globalThis
149
+ ...(this.options.trace?.enabled ? {
150
+ pool: 'forks',
151
+ poolOptions: {
152
+ forks: {
153
+ singleFork: true
154
+ }
155
+ },
156
+ isolate: false
157
+ } : {}),
158
+ ...config
159
+ };
160
+
161
+ // Only add coverage if explicitly enabled
162
+ if (this.options.coverage === true) {
163
+ vitestConfig.coverage = {
164
+ enabled: true,
165
+ reporter: ['json-summary'],
166
+ all: true,
167
+ ...this.options.coverage
168
+ };
169
+ }
170
+
171
+ return vitestConfig;
172
+ }
173
+
174
+ /**
175
+ * Collect results from Vitest
176
+ */
177
+ collectResults(vitest) {
178
+ const state = vitest.state;
179
+ const files = state.getFiles();
180
+
181
+ const stats = {
182
+ total: 0,
183
+ passed: 0,
184
+ failed: 0,
185
+ skipped: 0
186
+ };
187
+
188
+ const failures = [];
189
+ let totalDuration = 0;
190
+
191
+ // Process all test files
192
+ for (const file of files) {
193
+ const tasks = this.getAllTasks(file);
194
+
195
+ for (const task of tasks) {
196
+ if (task.type !== 'test') continue;
197
+
198
+ stats.total++;
199
+
200
+ if (task.result?.state === 'pass') {
201
+ stats.passed++;
202
+ } else if (task.result?.state === 'fail') {
203
+ stats.failed++;
204
+ failures.push(this.formatFailure(task, file));
205
+ } else if (task.result?.state === 'skip') {
206
+ stats.skipped++;
207
+ }
208
+
209
+ if (task.result?.duration) {
210
+ totalDuration += task.result.duration;
211
+ }
212
+ }
213
+ }
214
+
215
+ const results = {
216
+ stats,
217
+ failures,
218
+ duration: totalDuration
219
+ };
220
+
221
+ // Add coverage if available
222
+ if (vitest.coverageProvider) {
223
+ results.coverage = this.extractCoverage(vitest);
224
+ }
225
+
226
+ return results;
227
+ }
228
+
229
+ /**
230
+ * Get all tasks from a file recursively
231
+ */
232
+ getAllTasks(file) {
233
+ const tasks = [];
234
+
235
+ const collect = (task) => {
236
+ tasks.push(task);
237
+ if (task.tasks) {
238
+ task.tasks.forEach(collect);
239
+ }
240
+ };
241
+
242
+ collect(file);
243
+ return tasks;
244
+ }
245
+
246
+ /**
247
+ * Format a test failure
248
+ */
249
+ formatFailure(task, file) {
250
+ const error = task.result?.errors?.[0] || task.result?.error;
251
+
252
+ const failure = {
253
+ test: this.getTestName(task),
254
+ location: this.getLocation(task, file)
255
+ };
256
+
257
+ if (error) {
258
+ failure.error = error.message || String(error);
259
+ failure.stack = error.stack;
260
+
261
+ // Extract diff if available
262
+ if (error.actual !== undefined || error.expected !== undefined) {
263
+ failure.diff = {
264
+ expected: error.expected,
265
+ actual: error.actual
266
+ };
267
+ }
268
+ }
269
+
270
+ return failure;
271
+ }
272
+
273
+ /**
274
+ * Get full test name
275
+ */
276
+ getTestName(task) {
277
+ const names = [];
278
+ let current = task;
279
+
280
+ while (current) {
281
+ if (current.name && current.type !== 'file') {
282
+ names.unshift(current.name);
283
+ }
284
+ current = current.suite;
285
+ }
286
+
287
+ return names.join(' > ') || task.name;
288
+ }
289
+
290
+ /**
291
+ * Get test location
292
+ */
293
+ getLocation(task, file) {
294
+ if (task.location) {
295
+ return {
296
+ file: file.filepath || file.name,
297
+ line: task.location.line,
298
+ column: task.location.column
299
+ };
300
+ }
301
+
302
+ return file.filepath || file.name;
303
+ }
304
+
305
+ /**
306
+ * Extract coverage information
307
+ */
308
+ extractCoverage(vitest) {
309
+ // This would extract coverage from vitest.coverageProvider
310
+ // For now, return placeholder
311
+ return {
312
+ percent: 0,
313
+ covered: 0,
314
+ total: 0
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Get results
320
+ */
321
+ getResults() {
322
+ return this.results;
323
+ }
324
+ }
325
+
326
+ export default VitestRunner;
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Watch Handler - File watching and incremental test runs
3
+ * Enables iterative development with AI tools
4
+ */
5
+
6
+ import chokidar from 'chokidar';
7
+ import { EventEmitter } from 'events';
8
+
9
+ export class WatchHandler extends EventEmitter {
10
+ constructor(options = {}) {
11
+ super();
12
+
13
+ this.options = {
14
+ ignore: options.ignore || ['node_modules/**', '.git/**', 'dist/**', 'build/**'],
15
+ delay: options.delay || 500,
16
+ maxHistory: options.maxHistory || 10,
17
+ ...options
18
+ };
19
+
20
+ this.watcher = null;
21
+ this.history = [];
22
+ this.iteration = 0;
23
+ this.isRunning = false;
24
+ this.debounceTimer = null;
25
+ this.changedFiles = new Set();
26
+ this.lastResults = null;
27
+ }
28
+
29
+ /**
30
+ * Start watching files
31
+ * @param {Array} paths - Paths to watch
32
+ * @param {Function} onRun - Callback to run tests
33
+ */
34
+ async start(paths, onRun) {
35
+ if (this.watcher) {
36
+ throw new Error('Watch handler already started');
37
+ }
38
+
39
+ this.onRun = onRun;
40
+
41
+ const watchPaths = Array.isArray(paths) ? paths : [paths];
42
+
43
+ this.watcher = chokidar.watch(watchPaths, {
44
+ ignored: this.options.ignore,
45
+ persistent: true,
46
+ ignoreInitial: true,
47
+ awaitWriteFinish: {
48
+ stabilityThreshold: 200,
49
+ pollInterval: 100
50
+ }
51
+ });
52
+
53
+ this.watcher
54
+ .on('change', (path) => this.handleChange(path))
55
+ .on('add', (path) => this.handleChange(path))
56
+ .on('unlink', (path) => this.handleChange(path))
57
+ .on('error', (error) => this.emit('error', error));
58
+
59
+ // Run initial tests
60
+ await this.runTests([]);
61
+
62
+ this.emit('ready');
63
+ }
64
+
65
+ /**
66
+ * Stop watching
67
+ */
68
+ async stop() {
69
+ if (this.watcher) {
70
+ await this.watcher.close();
71
+ this.watcher = null;
72
+ }
73
+
74
+ if (this.debounceTimer) {
75
+ clearTimeout(this.debounceTimer);
76
+ this.debounceTimer = null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Handle file change
82
+ */
83
+ handleChange(path) {
84
+ this.changedFiles.add(path);
85
+
86
+ // Debounce test runs
87
+ if (this.debounceTimer) {
88
+ clearTimeout(this.debounceTimer);
89
+ }
90
+
91
+ this.debounceTimer = setTimeout(() => {
92
+ const changes = Array.from(this.changedFiles);
93
+ this.changedFiles.clear();
94
+ this.runTests(changes);
95
+ }, this.options.delay);
96
+ }
97
+
98
+ /**
99
+ * Run tests
100
+ */
101
+ async runTests(changes) {
102
+ if (this.isRunning) {
103
+ return;
104
+ }
105
+
106
+ this.isRunning = true;
107
+ this.iteration++;
108
+
109
+ const startTime = Date.now();
110
+
111
+ try {
112
+ this.emit('run-start', { iteration: this.iteration, changes });
113
+
114
+ const results = await this.onRun();
115
+
116
+ const duration = Date.now() - startTime;
117
+
118
+ // Create history entry
119
+ const entry = this.createHistoryEntry(results, changes, duration);
120
+ this.addToHistory(entry);
121
+
122
+ // Store results for comparison
123
+ this.lastResults = results;
124
+
125
+ this.emit('run-complete', {
126
+ iteration: this.iteration,
127
+ results,
128
+ changes,
129
+ duration,
130
+ history: entry
131
+ });
132
+ } catch (error) {
133
+ this.emit('run-error', {
134
+ iteration: this.iteration,
135
+ error,
136
+ changes
137
+ });
138
+ } finally {
139
+ this.isRunning = false;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Create history entry
145
+ */
146
+ createHistoryEntry(results, changes, duration) {
147
+ const summary = {
148
+ pass: results.stats?.passed || 0,
149
+ fail: results.stats?.failed || 0,
150
+ total: results.stats?.total || 0
151
+ };
152
+
153
+ // Compare with previous results
154
+ if (this.lastResults) {
155
+ summary.new_failures = this.findNewFailures(results, this.lastResults);
156
+ summary.fixed = this.findFixedTests(results, this.lastResults);
157
+ } else {
158
+ summary.new_failures = [];
159
+ summary.fixed = [];
160
+ }
161
+
162
+ // Extract key errors (top 3)
163
+ summary.key_errors = (results.failures || [])
164
+ .slice(0, 3)
165
+ .map(f => this.extractErrorMessage(f));
166
+
167
+ return {
168
+ iteration: this.iteration,
169
+ timestamp: new Date().toISOString(),
170
+ changes,
171
+ summary,
172
+ duration
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Find new failures
178
+ */
179
+ findNewFailures(current, previous) {
180
+ const currentFailures = new Set(
181
+ (current.failures || []).map(f => f.test)
182
+ );
183
+ const previousFailures = new Set(
184
+ (previous.failures || []).map(f => f.test)
185
+ );
186
+
187
+ return Array.from(currentFailures).filter(test => !previousFailures.has(test));
188
+ }
189
+
190
+ /**
191
+ * Find fixed tests
192
+ */
193
+ findFixedTests(current, previous) {
194
+ const currentFailures = new Set(
195
+ (current.failures || []).map(f => f.test)
196
+ );
197
+ const previousFailures = new Set(
198
+ (previous.failures || []).map(f => f.test)
199
+ );
200
+
201
+ return Array.from(previousFailures).filter(test => !currentFailures.has(test));
202
+ }
203
+
204
+ /**
205
+ * Extract error message
206
+ */
207
+ extractErrorMessage(failure) {
208
+ if (failure.error) {
209
+ if (typeof failure.error === 'string') return failure.error;
210
+ if (failure.error.message) return failure.error.message;
211
+ return String(failure.error);
212
+ }
213
+ return 'Unknown error';
214
+ }
215
+
216
+ /**
217
+ * Add entry to history
218
+ */
219
+ addToHistory(entry) {
220
+ this.history.push(entry);
221
+
222
+ // Keep only recent history
223
+ if (this.history.length > this.options.maxHistory) {
224
+ this.history.shift();
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Get history
230
+ */
231
+ getHistory() {
232
+ return this.history;
233
+ }
234
+
235
+ /**
236
+ * Get summary of recent iterations
237
+ */
238
+ getSummary(count = 5) {
239
+ const recent = this.history.slice(-count);
240
+
241
+ return {
242
+ iterations: recent.length,
243
+ current: recent[recent.length - 1],
244
+ trend: this.analyzeTrend(recent)
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Analyze trend in test results
250
+ */
251
+ analyzeTrend(entries) {
252
+ if (entries.length < 2) {
253
+ return 'stable';
254
+ }
255
+
256
+ const first = entries[0].summary.fail;
257
+ const last = entries[entries.length - 1].summary.fail;
258
+
259
+ if (last < first) return 'improving';
260
+ if (last > first) return 'degrading';
261
+ return 'stable';
262
+ }
263
+
264
+ /**
265
+ * Get formatted history for output
266
+ */
267
+ formatHistory(count = 3) {
268
+ const recent = this.history.slice(-count);
269
+
270
+ return recent.map(entry => {
271
+ const lines = [];
272
+ lines.push(`[${entry.iteration}] ${entry.summary.pass}/${entry.summary.total}`);
273
+
274
+ if (entry.summary.new_failures.length > 0) {
275
+ lines.push(` New: ${entry.summary.new_failures.join(', ')}`);
276
+ }
277
+
278
+ if (entry.summary.fixed.length > 0) {
279
+ lines.push(` Fixed: ${entry.summary.fixed.join(', ')}`);
280
+ }
281
+
282
+ if (entry.summary.key_errors.length > 0) {
283
+ lines.push(` Errors: ${entry.summary.key_errors[0]}`);
284
+ }
285
+
286
+ return lines.join('\n');
287
+ }).join('\n\n');
288
+ }
289
+
290
+ /**
291
+ * Clear history
292
+ */
293
+ clearHistory() {
294
+ this.history = [];
295
+ this.iteration = 0;
296
+ this.lastResults = null;
297
+ }
298
+ }
299
+
300
+ export default WatchHandler;
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "taist",
3
+ "version": "1.1.0",
4
+ "description": "Token-Optimized Testing Framework for AI-Assisted Development",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "taist": "taist.js"
9
+ },
10
+ "files": [
11
+ "lib/",
12
+ "index.js",
13
+ "taist.js",
14
+ "LICENSE",
15
+ "README.md",
16
+ ".taistrc.json"
17
+ ],
18
+ "scripts": {
19
+ "test": "vitest run",
20
+ "test:watch": "vitest",
21
+ "test:unit": "vitest run test/unit",
22
+ "test:integration": "vitest run test/integration",
23
+ "test:coverage": "vitest run --coverage",
24
+ "test:ai": "node taist.js test --format toon",
25
+ "test:ai:watch": "node taist.js watch",
26
+ "test:ai:trace": "node taist.js test --trace --depth 3",
27
+ "demo": "node taist.js test -t ./examples/calculator.test.js",
28
+ "demo:json": "node taist.js test -t ./examples/calculator.test.js --format json",
29
+ "demo:compact": "node taist.js test -t ./examples/calculator.test.js --format compact",
30
+ "demo:failing": "node taist.js test -t ./examples/failing.test.js || true",
31
+ "prepublishOnly": "npm run test"
32
+ },
33
+ "keywords": [
34
+ "testing",
35
+ "ai",
36
+ "tdd",
37
+ "vitest",
38
+ "token-optimized",
39
+ "llm",
40
+ "claude",
41
+ "copilot",
42
+ "ai-testing",
43
+ "test-runner",
44
+ "toon"
45
+ ],
46
+ "author": "David Purkiss",
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/davidpurkiss/taist.git"
51
+ },
52
+ "bugs": {
53
+ "url": "https://github.com/davidpurkiss/taist/issues"
54
+ },
55
+ "homepage": "https://github.com/davidpurkiss/taist#readme",
56
+ "dependencies": {
57
+ "commander": "^11.1.0",
58
+ "vitest": "^1.0.4",
59
+ "chokidar": "^3.5.3",
60
+ "picocolors": "^1.0.0",
61
+ "acorn": "^8.11.0",
62
+ "estree-walker": "^3.0.3",
63
+ "magic-string": "^0.30.5"
64
+ },
65
+ "devDependencies": {
66
+ "@vitest/ui": "^1.0.4"
67
+ },
68
+ "engines": {
69
+ "node": ">=18.0.0"
70
+ }
71
+ }