@tng-sh/js 0.0.9 → 0.1.1

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.
@@ -3,9 +3,10 @@ const path = require('path');
3
3
  const glob = require('fast-glob');
4
4
  const chalk = require('chalk');
5
5
  const GoUISession = require('./goUiSession');
6
- const { getFileOutline, submitAndPoll, ping, getUserStats } = require('../index');
6
+ const { getFileOutline, generateTest, ping, getUserStats } = require('../index');
7
7
  const { loadConfig } = require('./config');
8
8
  const { saveTestFile } = require('./saveFile');
9
+ const { applyFix } = require('./fixApplier');
9
10
 
10
11
  class GenerateTestsUI {
11
12
  constructor(cliMode = false) {
@@ -52,16 +53,19 @@ class GenerateTestsUI {
52
53
  config_file: "tng.config.js"
53
54
  });
54
55
 
55
- spawnSync(this.goUiSession._binaryPath, ['config-missing', '--data', dataJson], {
56
- stdio: 'inherit'
57
- });
56
+ try {
57
+ spawnSync(this.goUiSession._binaryPath, ['config-missing', '--data', dataJson], {
58
+ stdio: 'inherit'
59
+ });
60
+ } catch (e) {
61
+ console.error(chalk.red('\nFailed to show configuration error UI.\n'));
62
+ }
58
63
  }
59
64
 
60
65
  async _showStats() {
61
66
  const config = loadConfig();
62
67
  if (!config.API_KEY) {
63
68
  console.log(chalk.red('\nNo API key configured. Run: tng init\n'));
64
- console.log(chalk.yellow('Or edit tng.config.js and set your API_KEY\n'));
65
69
  return;
66
70
  }
67
71
 
@@ -78,7 +82,6 @@ class GenerateTestsUI {
78
82
  const config = loadConfig();
79
83
  if (!config.API_KEY) {
80
84
  console.log(chalk.red('\nNo API key configured. Run: tng init\n'));
81
- console.log(chalk.yellow('Or edit tng.config.js and set your API_KEY\n'));
82
85
  return;
83
86
  }
84
87
 
@@ -96,7 +99,7 @@ class GenerateTestsUI {
96
99
  const files = await this._getUserFiles();
97
100
 
98
101
  if (files.length === 0) {
99
- console.log(chalk.yellow('No JS/TS files found in project.'));
102
+ console.log(chalk.yellow('\nNo JavaScript or TypeScript files found in your project.\n'));
100
103
  return 'back';
101
104
  }
102
105
 
@@ -124,8 +127,8 @@ class GenerateTestsUI {
124
127
  const result = getFileOutline(filePath);
125
128
  outline = JSON.parse(result);
126
129
  } catch (e) {
127
- console.error(chalk.red(`Error parsing file: ${e.message}`));
128
- return this._showFileSelection();
130
+ console.error(chalk.red(`\nError parsing file: ${e.message}\n`));
131
+ return this._showFileSelection(isAudit);
129
132
  }
130
133
 
131
134
  const methods = outline.methods || [];
@@ -144,8 +147,7 @@ class GenerateTestsUI {
144
147
  const title = isAudit ? `Select Method to Audit for ${fileName}` : `Select Method for ${fileName}`;
145
148
  const selectedDisplay = this.goUiSession.showListView(title, items);
146
149
 
147
- if (selectedDisplay === 'back') return this._showFileSelection(isAudit);
148
- if (!selectedDisplay) return this._showFileSelection(isAudit);
150
+ if (selectedDisplay === 'back' || !selectedDisplay) return this._showFileSelection(isAudit);
149
151
 
150
152
  const selectedMethod = items.find(i => i.name === selectedDisplay)?.methodData;
151
153
 
@@ -154,7 +156,6 @@ class GenerateTestsUI {
154
156
  if (testType === 'back') return this._showFileSelection(isAudit);
155
157
 
156
158
  const finalType = testType === 'auto' ? null : testType;
157
-
158
159
  const choice = await this._generateTestsForMethod(filePath, selectedMethod, finalType, isAudit);
159
160
 
160
161
  if (isAudit) {
@@ -170,88 +171,215 @@ class GenerateTestsUI {
170
171
  }
171
172
 
172
173
  async _generateTestsForMethod(filePath, method, testType, isAudit = false) {
174
+ if (!this._hasApiKey()) {
175
+ return { error: 'No API key' };
176
+ }
177
+
178
+ if (isAudit) {
179
+ return this._handleAuditFlow(filePath, method, testType);
180
+ }
181
+
173
182
  const fileName = path.basename(filePath);
174
183
  const displayName = method.class_name ? `${method.class_name}#${method.name}` : `${fileName}#${method.name}`;
175
- const actionName = isAudit ? 'Auditing' : 'Generating test for';
184
+ return this._handleTestGenerationFlow(filePath, method, testType, displayName);
185
+ }
176
186
 
177
- const progressHandler = async (progress) => {
178
- progress.update('Preparing request...');
187
+ _hasApiKey() {
188
+ const config = loadConfig();
189
+ if (!config.API_KEY) {
190
+ const msg = 'No API key configured. Run: tng init';
191
+ if (this.cliMode) console.log(chalk.red(msg));
192
+ return false;
193
+ }
194
+ return true;
195
+ }
179
196
 
180
- try {
181
- const config = loadConfig();
182
- if (!config.API_KEY) {
183
- progress.error('No API key configured. Run: tng init');
184
- return { error: 'No API key' };
185
- }
197
+ async _handleAuditFlow(filePath, method, testType) {
198
+ const { Worker } = require('worker_threads');
199
+ const streamingUi = await this.goUiSession.showStreamingAuditResults(
200
+ method.name,
201
+ method.class_name,
202
+ method.source_code || ''
203
+ );
204
+
205
+ if (!streamingUi) return null;
206
+
207
+ let results = {
208
+ issues: [],
209
+ behaviours: [],
210
+ method_name: method.name,
211
+ class_name: method.class_name,
212
+ method_source_with_lines: method.source_code
213
+ };
214
+ let auditFinished = false;
186
215
 
187
- const agentMap = {
188
- 'context_agent_status': { label: 'Context Builder', step: 1 },
189
- 'style_agent_status': { label: 'Style Analyzer', step: 2 },
190
- 'logical_issue_status': { label: 'Logic Analyzer', step: 3 },
191
- 'behavior_expert_status': { label: 'Logic Generator', step: 4 },
192
- 'context_insights_status': { label: 'Context Insights', step: 5 }
193
- };
216
+ // Start audit in background worker
217
+ const worker = new Worker(path.join(__dirname, 'auditWorker.js'), {
218
+ workerData: { filePath, methodName: method.name, className: method.class_name || null, testType: testType || null }
219
+ });
194
220
 
195
- for (const [key, config] of Object.entries(agentMap)) {
196
- progress.update(`${config.label}: Pending...`, { step_increment: true });
221
+ const itemIds = new Set();
222
+ worker.on('message', (msg) => {
223
+ if (msg.type === 'item') {
224
+ if (msg.data.startsWith('{')) {
225
+ streamingUi.write(msg.data);
226
+ try {
227
+ const data = JSON.parse(msg.data);
228
+
229
+ // Handle metadata updates from stream
230
+ if (data.method_name) results.method_name = data.method_name;
231
+ if (data.class_name) results.class_name = data.class_name;
232
+ if (data.method_source_with_lines) results.method_source_with_lines = data.method_source_with_lines;
233
+ if (data.source_code) results.method_source_with_lines = data.source_code;
234
+
235
+ // Only push if it looks like an actual audit item
236
+ if (data.test_name || data.summary || data.category) {
237
+ const id = `${data.category}-${data.test_name}-${data.line_number}`;
238
+ if (!itemIds.has(id)) {
239
+ if (data.category === 'behavior' || data.category === 'behaviour') results.behaviours.push(data);
240
+ else results.issues.push(data);
241
+ itemIds.add(id);
242
+ }
243
+ }
244
+ } catch (e) { }
197
245
  }
246
+ } else if (msg.type === 'result') {
247
+ try {
248
+ const finalResults = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
249
+
250
+ // Merge final results with our local ones, preserving 'fixed' state
251
+ const mergeList = (localList, incomingList) => {
252
+ if (!incomingList || incomingList.length === 0) return localList;
253
+
254
+ return incomingList.map(incomingItem => {
255
+ const localItem = localList.find(it =>
256
+ it.test_name === incomingItem.test_name &&
257
+ it.line_number === incomingItem.line_number
258
+ );
259
+ if (localItem && localItem.fixed) {
260
+ return { ...incomingItem, fixed: true };
261
+ }
262
+ return incomingItem;
263
+ });
264
+ };
198
265
 
199
- // Pass a callback to the native Rust submitAndPoll
200
- const resultJson = submitAndPoll(
201
- filePath,
202
- method.name,
203
- method.class_name || null,
204
- testType || null,
205
- isAudit, // audit_mode
206
- JSON.stringify(config),
207
- (msg, percent) => {
208
- try {
209
- if (msg.startsWith('{')) {
210
- const info = JSON.parse(msg);
266
+ results.issues = mergeList(results.issues, finalResults.issues || finalResults.findings);
267
+ results.behaviours = mergeList(results.behaviours, finalResults.behaviours);
211
268
 
212
- for (const [key, config] of Object.entries(agentMap)) {
213
- const item = info[key];
214
- if (!item) continue;
269
+ if (finalResults.method_source_with_lines) results.method_source_with_lines = finalResults.method_source_with_lines;
270
+ if (finalResults.method_name) results.method_name = finalResults.method_name;
271
+ } catch (e) { }
272
+ auditFinished = true;
273
+ } else if (msg.type === 'error') {
274
+ console.error(chalk.red(`Audit error: ${msg.data}`));
275
+ auditFinished = true;
276
+ }
277
+ });
215
278
 
216
- const agentStatus = item.status || 'pending';
217
- const values = item.values || [];
279
+ worker.on('error', (err) => {
280
+ console.error(chalk.red(`Worker error: ${err.message}`));
281
+ auditFinished = true;
282
+ });
218
283
 
219
- let displayMsg = `${config.label}: ${agentStatus.charAt(0).toUpperCase() + agentStatus.slice(1)}...`;
220
- if (agentStatus === 'completed') displayMsg = `${config.label}: Completed`;
284
+ try {
285
+ let currentChoice = null;
221
286
 
222
- if (values.length > 0) {
223
- const vals = values.map(v => v.toString().replace(/_/g, ' ')).slice(0, 2).join(', ');
224
- displayMsg += ` (${vals}${values.length > 2 ? '...' : ''})`;
225
- }
287
+ // Wait for INITIAL streaming session to complete (user picks an action or quits)
288
+ await streamingUi.wait;
226
289
 
227
- progress.update(displayMsg, {
228
- percent: key === 'behavior_expert_status' ? percent : undefined,
229
- explicit_step: config.step,
230
- step_increment: false
231
- });
232
- }
233
- } else {
234
- progress.update(msg, { percent });
235
- }
290
+ const getResponse = (outputFile) => {
291
+ if (fs.existsSync(outputFile)) {
292
+ const output = fs.readFileSync(outputFile, 'utf8').trim();
293
+ if (output) {
294
+ try {
295
+ const parsed = JSON.parse(output);
296
+ return typeof parsed === 'object' ? parsed : { action: output };
236
297
  } catch (e) {
237
- progress.update(msg, { percent });
298
+ return { action: output };
238
299
  }
239
300
  }
240
- );
301
+ }
302
+ return null;
303
+ };
304
+
305
+ currentChoice = getResponse(streamingUi.outputFile);
306
+
307
+ // Action loop: handles fix/open and returns to UI
308
+ while (currentChoice && (currentChoice.action === 'fix' || currentChoice.action === 'open')) {
309
+ const itemIndex = typeof currentChoice.index === 'number' ? currentChoice.index : 0;
310
+
311
+ if (currentChoice.action === 'fix') {
312
+ console.log(chalk.cyan(`Applying fix...`));
313
+ const fixResult = await applyFix(filePath, currentChoice.item);
314
+ if (fixResult.success) {
315
+ console.log(chalk.green(`✓ Fix applied successfully.`));
316
+ // Mark as fixed locally
317
+ [results.issues, results.behaviours].forEach(list => {
318
+ list.forEach(it => {
319
+ if (it.test_name === currentChoice.item.test_name && it.line_number === currentChoice.item.line_number) {
320
+ it.fixed = true;
321
+ }
322
+ });
323
+ });
324
+ } else {
325
+ console.log(chalk.red(`❌ Failed to apply fix: ${fixResult.error || 'Unknown error'}`));
326
+ }
327
+ } else if (currentChoice.action === 'open') {
328
+ this._openInEditor(filePath, currentChoice.item.line_number);
329
+ }
241
330
 
242
- if (isAudit) {
243
- progress.complete('Audit ready!', { auto_exit: false });
244
- return {
245
- message: 'Audit ready!',
246
- resultJson: resultJson
247
- };
331
+ // Ensure source has line numbers for static UI if it doesn't already
332
+ if (results.method_source_with_lines && !/^\s*\d+:/.test(results.method_source_with_lines)) {
333
+ results.method_source_with_lines = results.method_source_with_lines
334
+ .split('\n')
335
+ .map((line, i) => `${i + 1}: ${line}`)
336
+ .join('\n');
248
337
  }
249
338
 
250
- progress.update('Tests generated successfully!');
339
+ // Re-launch UI statically with updated results
340
+ const choiceStr = await this.goUiSession.showAuditResults(results, itemIndex);
341
+ if (choiceStr === 'back' || choiceStr === 'main_menu' || choiceStr === 'exit') {
342
+ currentChoice = null;
343
+ } else {
344
+ try {
345
+ const parsed = JSON.parse(choiceStr);
346
+ currentChoice = typeof parsed === 'object' ? parsed : { action: choiceStr };
347
+ } catch (e) {
348
+ currentChoice = { action: choiceStr };
349
+ }
350
+ }
351
+ }
251
352
 
252
- // Save the file inside the progress handler to match Python flow
253
- const fileInfo = await saveTestFile(resultJson);
353
+ return { message: 'Audit complete', results };
354
+ } catch (e) {
355
+ console.error(chalk.red(`\nAudit flow failed: ${e.message}\n`));
356
+ return null;
357
+ } finally {
358
+ await worker.terminate();
359
+ }
360
+ }
361
+
362
+ async _handleTestGenerationFlow(filePath, method, testType, displayName) {
363
+ const actionName = 'Generating test for';
364
+
365
+ const progressHandler = async (progress) => {
366
+ progress.update('Preparing request...');
367
+
368
+ try {
369
+ const config = loadConfig();
254
370
 
371
+ const resultJson = generateTest(
372
+ filePath,
373
+ method.name,
374
+ method.class_name || null,
375
+ testType || null,
376
+ JSON.stringify(config),
377
+ (msg, percent) => this._updateProgress(progress, msg, percent)
378
+ );
379
+
380
+ progress.update('Saving generated tests...');
381
+
382
+ const fileInfo = await saveTestFile(resultJson);
255
383
  return {
256
384
  message: 'Tests generated successfully!',
257
385
  resultJson,
@@ -264,23 +392,54 @@ class GenerateTestsUI {
264
392
  };
265
393
 
266
394
  const uiResult = await this.goUiSession.showProgress(`${actionName} ${displayName}`, progressHandler);
395
+ return uiResult || null;
396
+ }
267
397
 
268
- if (isAudit && uiResult && uiResult.resultJson) {
269
- const auditData = JSON.parse(uiResult.resultJson);
270
- // Unwrap 'audit_results' if present (Ruby wrapper does this, but JS output might differ)
271
- return this.goUiSession.showAuditResults(auditData);
272
- }
398
+ _updateProgress(progress, msg, percent) {
399
+ const agentMap = {
400
+ 'context_agent_status': { label: 'Context Builder', step: 1 },
401
+ 'style_agent_status': { label: 'Style Analyzer', step: 2 },
402
+ 'logical_issue_status': { label: 'Logic Analyzer', step: 3 },
403
+ 'behavior_expert_status': { label: 'Logic Generator', step: 4 },
404
+ 'context_insights_status': { label: 'Context Insights', step: 5 }
405
+ };
273
406
 
274
- if (uiResult && uiResult.resultJson) {
275
- return uiResult;
276
- }
407
+ try {
408
+ if (msg.trim().startsWith('{')) {
409
+ const info = JSON.parse(msg);
410
+
411
+ for (const [key, config] of Object.entries(agentMap)) {
412
+ const item = info[key];
413
+ if (!item) continue;
277
414
 
278
- return null;
415
+ const agentStatus = item.status || 'pending';
416
+ const values = item.values || [];
417
+
418
+ let displayMsg = `${config.label}: ${agentStatus.charAt(0).toUpperCase() + agentStatus.slice(1)}...`;
419
+ if (agentStatus === 'completed') displayMsg = `${config.label}: Completed`;
420
+
421
+ if (values.length > 0) {
422
+ const vals = values.map(v => v.toString().replace(/_/g, ' ')).slice(0, 2).join(', ');
423
+ displayMsg += ` (${vals}${values.length > 2 ? '...' : ''})`;
424
+ }
425
+
426
+ progress.update(displayMsg, {
427
+ percent: key === 'behavior_expert_status' ? percent : undefined,
428
+ explicit_step: config.step,
429
+ step_increment: false
430
+ });
431
+ }
432
+ } else {
433
+ progress.update(msg, { percent });
434
+ }
435
+ } catch (e) {
436
+ progress.update(msg, { percent });
437
+ }
279
438
  }
280
439
 
281
440
  _showPostGenerationMenu(result) {
282
441
  const filePath = result.file_path;
283
- const runCommand = `npm test ${filePath}`; // Example command
442
+ const runCommand = `npm test ${filePath}`;
284
443
 
285
444
  while (true) {
286
445
  const choice = this.goUiSession.showPostGenerationMenu(filePath, runCommand);
@@ -308,20 +467,17 @@ class GenerateTestsUI {
308
467
  };
309
468
  } catch (error) {
310
469
  return {
311
- success: true, // We still want to show the results even if tests failed
312
- message: "Tests completed",
313
- output: error.stdout + error.stderr,
314
- exit_code: error.status
470
+ success: true,
471
+ message: "Tests completed with failures",
472
+ output: (error.stdout || '') + (error.stderr || ''),
473
+ exit_code: error.status || 1
315
474
  };
316
475
  }
317
476
  };
318
477
 
319
478
  const testOutput = this.goUiSession.showSpinner("Running tests...", spinnerHandler);
320
479
 
321
- // Note: For simplicity in the first pass, we provide counts.
322
- // We can enhance _parseTestOutput if needed.
323
480
  const { passed, failed, errors, total } = this._parseTestOutput(testOutput.output || "", testOutput.exit_code || 0);
324
-
325
481
  this.goUiSession.showTestResults("Test Results", passed, failed, errors, total, []);
326
482
  }
327
483
 
@@ -356,11 +512,41 @@ class GenerateTestsUI {
356
512
  try {
357
513
  if (process.platform === 'darwin') {
358
514
  execSync('pbcopy', { input: text });
515
+ this.goUiSession.showClipboardSuccess(text);
516
+ } else if (process.platform === 'linux') {
517
+ // Try xclip then xsel
518
+ try {
519
+ execSync('xclip -selection clipboard', { input: text });
520
+ } catch (e) {
521
+ execSync('xsel --clipboard --input', { input: text });
522
+ }
523
+ this.goUiSession.showClipboardSuccess(text);
359
524
  } else {
360
- // Simplified for now
361
- console.log(`\n📋 Copy this command: ${text}\n`);
525
+ console.log(chalk.cyan(`\n📋 Please copy this command: ${text}\n`));
526
+ }
527
+ } catch (e) {
528
+ console.error(chalk.yellow(`\n⚠️ Failed to copy to clipboard. Command: ${text}\n`));
529
+ }
530
+ }
531
+
532
+ _openInEditor(filePath, lineNumber) {
533
+ const { execSync } = require('child_process');
534
+ try {
535
+ // Try to open in VS Code if available, fallback to default editor
536
+ const location = lineNumber ? `${filePath}:${lineNumber}` : filePath;
537
+ try {
538
+ execSync(`code --goto ${location}`, { stdio: 'ignore' });
539
+ console.log(chalk.blue(`\nOpening ${location} in VS Code...`));
540
+ } catch (e) {
541
+ if (process.platform === 'darwin') {
542
+ execSync(`open ${filePath}`);
543
+ } else if (process.platform === 'linux') {
544
+ execSync(`xdg-open ${filePath}`);
545
+ }
362
546
  }
363
- } catch (e) { }
547
+ } catch (e) {
548
+ console.log(chalk.yellow(`\n⚠️ Failed to open editor: ${e.message}`));
549
+ }
364
550
  }
365
551
 
366
552
  async _getUserFiles() {
@@ -375,11 +561,16 @@ class GenerateTestsUI {
375
561
  '**/*.spec.*'
376
562
  ];
377
563
 
378
- return glob(patterns, {
379
- ignore,
380
- absolute: true,
381
- onlyFiles: true
382
- });
564
+ try {
565
+ return await glob(patterns, {
566
+ ignore,
567
+ absolute: true,
568
+ onlyFiles: true
569
+ });
570
+ } catch (e) {
571
+ console.error(chalk.red(`\nError searching for files: ${e.message}\n`));
572
+ return [];
573
+ }
383
574
  }
384
575
  }
385
576