@tng-sh/js 0.0.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.
@@ -0,0 +1,330 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const glob = require('fast-glob');
4
+ const chalk = require('chalk');
5
+ const GoUISession = require('./goUiSession');
6
+ const { getFileOutline, submitAndPoll, ping, getUserStats } = require('../index');
7
+ const { loadConfig } = require('./config');
8
+ const { saveTestFile } = require('./saveFile');
9
+
10
+ class GenerateTestsUI {
11
+ constructor(cliMode = false) {
12
+ this.cliMode = cliMode;
13
+ if (!cliMode) {
14
+ this.goUiSession = new GoUISession();
15
+ this.goUiSession.start();
16
+ }
17
+ }
18
+
19
+ async show() {
20
+ // Check for API key before showing menu
21
+ const config = loadConfig();
22
+ if (!config.API_KEY) {
23
+ this._showConfigMissing();
24
+ return 'exit';
25
+ }
26
+
27
+ while (true) {
28
+ const choice = this.goUiSession.showMenu();
29
+
30
+ if (choice === 'generate' || choice === 'tests' || choice === 'select_file') {
31
+ const result = await this._showFileSelection(false);
32
+ if (result === 'exit') return 'exit';
33
+ } else if (choice === 'audit') {
34
+ const result = await this._showFileSelection(true);
35
+ if (result === 'exit') return 'exit';
36
+ } else if (choice === 'stats') {
37
+ await this._showStats();
38
+ } else if (choice === 'about') {
39
+ this.goUiSession.showAbout();
40
+ } else if (choice === 'ping') {
41
+ await this._pingApi();
42
+ } else if (choice === 'exit' || choice === null) {
43
+ return 'exit';
44
+ }
45
+ }
46
+ }
47
+
48
+ _showConfigMissing() {
49
+ const { spawnSync } = require('child_process');
50
+ const dataJson = JSON.stringify({
51
+ missing_items: ["API key"],
52
+ config_file: "tng.config.js"
53
+ });
54
+
55
+ spawnSync(this.goUiSession._binaryPath, ['config-missing', '--data', dataJson], {
56
+ stdio: 'inherit'
57
+ });
58
+ }
59
+
60
+ async _showStats() {
61
+ const config = loadConfig();
62
+ if (!config.API_KEY) {
63
+ 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
+ return;
66
+ }
67
+
68
+ try {
69
+ const statsJson = getUserStats(config.API_URL, config.API_KEY);
70
+ const stats = JSON.parse(statsJson);
71
+ this.goUiSession.showStats(stats);
72
+ } catch (e) {
73
+ console.error(chalk.red(`\nError fetching stats: ${e.message}\n`));
74
+ }
75
+ }
76
+
77
+ async _pingApi() {
78
+ const config = loadConfig();
79
+ if (!config.API_KEY) {
80
+ 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
+ return;
83
+ }
84
+
85
+ this.goUiSession.showSpinner('Pinging TNG API...', () => {
86
+ try {
87
+ const result = ping(config.API_URL, config.API_KEY);
88
+ return { success: true, message: `API Response: ${result}` };
89
+ } catch (e) {
90
+ return { success: false, message: e.message };
91
+ }
92
+ });
93
+ }
94
+
95
+ async _showFileSelection(isAudit = false) {
96
+ const files = await this._getUserFiles();
97
+
98
+ if (files.length === 0) {
99
+ console.log(chalk.yellow('No JS/TS files found in project.'));
100
+ return 'back';
101
+ }
102
+
103
+ const cwd = process.cwd();
104
+ const items = files.map(file => ({
105
+ name: path.relative(cwd, file),
106
+ path: path.dirname(file)
107
+ }));
108
+
109
+ const title = isAudit ? 'Select JavaScript File to Audit' : 'Select JavaScript File';
110
+ const selectedName = this.goUiSession.showListView(title, items);
111
+
112
+ if (selectedName === 'back') return 'back';
113
+ if (!selectedName || selectedName === 'exit') return 'exit';
114
+
115
+ const selectedFile = path.resolve(cwd, selectedName);
116
+ const result = await this._showMethodsForFile(selectedFile, isAudit);
117
+ if (result === 'main_menu') return 'main_menu';
118
+ return result;
119
+ }
120
+
121
+ async _showMethodsForFile(filePath, isAudit = false) {
122
+ let outline;
123
+ try {
124
+ const result = getFileOutline(filePath);
125
+ outline = JSON.parse(result);
126
+ } catch (e) {
127
+ console.error(chalk.red(`Error parsing file: ${e.message}`));
128
+ return this._showFileSelection();
129
+ }
130
+
131
+ const methods = outline.methods || [];
132
+ if (methods.length === 0) {
133
+ this.goUiSession.showNoItems('methods');
134
+ return this._showFileSelection(isAudit);
135
+ }
136
+
137
+ const fileName = path.basename(filePath);
138
+ const items = methods.map(m => ({
139
+ name: m.class_name ? `${m.class_name}.${m.name}` : m.name,
140
+ path: `Function in ${fileName}`,
141
+ methodData: m
142
+ }));
143
+
144
+ const title = isAudit ? `Select Method to Audit for ${fileName}` : `Select Method for ${fileName}`;
145
+ const selectedDisplay = this.goUiSession.showListView(title, items);
146
+
147
+ if (selectedDisplay === 'back') return this._showFileSelection(isAudit);
148
+ if (!selectedDisplay) return this._showFileSelection(isAudit);
149
+
150
+ const selectedMethod = items.find(i => i.name === selectedDisplay)?.methodData;
151
+
152
+ if (selectedMethod) {
153
+ // New Step: Select Test Type
154
+ const testType = this.goUiSession.showJsTestMenu();
155
+ if (testType === 'back') return this._showFileSelection(isAudit);
156
+
157
+ // Handle auto-detect (pass null)
158
+ const finalType = testType === 'auto' ? null : testType;
159
+
160
+ const choice = await this._generateTestsForMethod(filePath, selectedMethod, finalType, isAudit);
161
+
162
+ if (isAudit) {
163
+ if (choice === 'main_menu') return 'main_menu';
164
+ return this._showFileSelection(isAudit);
165
+ }
166
+
167
+ if (choice && choice.file_path && !choice.error) {
168
+ this._showPostGenerationMenu(choice);
169
+ }
170
+ }
171
+ return this._showFileSelection(isAudit);
172
+ }
173
+
174
+ async _generateTestsForMethod(filePath, method, testType, isAudit = false) {
175
+ const fileName = path.basename(filePath);
176
+ const displayName = method.class_name ? `${method.class_name}#${method.name}` : `${fileName}#${method.name}`;
177
+ const actionName = isAudit ? 'Auditing' : 'Generating test for';
178
+
179
+ const progressHandler = (progress) => {
180
+ progress.update('Preparing request...');
181
+
182
+ try {
183
+ const config = loadConfig();
184
+ if (!config.API_KEY) {
185
+ progress.error('No API key configured. Run: tng init');
186
+ return { error: 'No API key' };
187
+ }
188
+
189
+ const agentMap = {
190
+ 'context_agent_status': { label: 'Context Builder', step: 1 },
191
+ 'style_agent_status': { label: 'Style Analyzer', step: 2 },
192
+ 'logical_issue_status': { label: 'Logic Analyzer', step: 3 },
193
+ 'behavior_expert_status': { label: 'Logic Generator', step: 4 },
194
+ 'context_insights_status': { label: 'Context Insights', step: 5 }
195
+ };
196
+
197
+ for (const [key, config] of Object.entries(agentMap)) {
198
+ progress.update(`${config.label}: Pending...`, { step_increment: true });
199
+ }
200
+
201
+ // Pass a callback to the native Rust submitAndPoll
202
+ const resultJson = submitAndPoll(
203
+ filePath,
204
+ method.name,
205
+ method.class_name || null,
206
+ testType || null,
207
+ isAudit, // audit_mode
208
+ JSON.stringify(config),
209
+ (msg, percent) => {
210
+ try {
211
+ if (msg.startsWith('{')) {
212
+ const info = JSON.parse(msg);
213
+
214
+ for (const [key, config] of Object.entries(agentMap)) {
215
+ const item = info[key];
216
+ if (!item) continue;
217
+
218
+ const agentStatus = item.status || 'pending';
219
+ const values = item.values || [];
220
+
221
+ let displayMsg = `${config.label}: ${agentStatus.charAt(0).toUpperCase() + agentStatus.slice(1)}...`;
222
+ if (agentStatus === 'completed') displayMsg = `${config.label}: Completed`;
223
+
224
+ if (values.length > 0) {
225
+ const vals = values.map(v => v.toString().replace(/_/g, ' ')).slice(0, 2).join(', ');
226
+ displayMsg += ` (${vals}${values.length > 2 ? '...' : ''})`;
227
+ }
228
+
229
+ progress.update(displayMsg, {
230
+ percent: key === 'behavior_expert_status' ? percent : undefined,
231
+ explicit_step: config.step,
232
+ step_increment: false
233
+ });
234
+ }
235
+ } else {
236
+ progress.update(msg, { percent });
237
+ }
238
+ } catch (e) {
239
+ progress.update(msg, { percent });
240
+ }
241
+ }
242
+ );
243
+
244
+ if (isAudit) {
245
+ // Auto-exit the progress UI so we can immediately show the audit results
246
+ progress.complete('Audit complete!', { auto_exit: true });
247
+ return {
248
+ message: 'Audit complete!',
249
+ resultJson: resultJson
250
+ };
251
+ }
252
+
253
+ progress.update('Tests generated successfully!');
254
+
255
+ return {
256
+ message: 'Tests generated successfully!',
257
+ resultJson
258
+ };
259
+ } catch (e) {
260
+ progress.error(`Failed to generate: ${e.message}`);
261
+ return { error: e.message };
262
+ }
263
+ };
264
+
265
+ const uiResult = await this.goUiSession.showProgress(`${actionName} ${displayName}`, progressHandler);
266
+
267
+ if (isAudit && uiResult && uiResult.resultJson) {
268
+ const auditData = JSON.parse(uiResult.resultJson);
269
+ // Unwrap 'audit_results' if present (Ruby wrapper does this, but JS output might differ)
270
+ return this.goUiSession.showAuditResults(auditData);
271
+ }
272
+
273
+ if (uiResult && uiResult.resultJson) {
274
+ const fileInfo = await saveTestFile(uiResult.resultJson);
275
+ return {
276
+ ...uiResult,
277
+ file_path: fileInfo?.file_path
278
+ };
279
+ }
280
+
281
+ return null;
282
+ }
283
+
284
+ _showPostGenerationMenu(result) {
285
+ const filePath = result.file_path;
286
+ const runCommand = `npm test ${filePath}`; // Example command
287
+
288
+ while (true) {
289
+ const choice = this.goUiSession.showPostGenerationMenu(filePath, runCommand);
290
+ if (choice === 'back' || !choice) break;
291
+
292
+ if (choice === 'copy_command') {
293
+ this._copyToClipboard(runCommand);
294
+ }
295
+ }
296
+ }
297
+
298
+ _copyToClipboard(text) {
299
+ const { execSync } = require('child_process');
300
+ try {
301
+ if (process.platform === 'darwin') {
302
+ execSync('pbcopy', { input: text });
303
+ } else {
304
+ // Simplified for now
305
+ console.log(`\n📋 Copy this command: ${text}\n`);
306
+ }
307
+ } catch (e) { }
308
+ }
309
+
310
+ async _getUserFiles() {
311
+ const patterns = ['**/*.{js,ts,jsx,tsx}'];
312
+ const ignore = [
313
+ '**/node_modules/**',
314
+ '**/dist/**',
315
+ '**/build/**',
316
+ '**/tests/**',
317
+ '**/test/**',
318
+ '**/*.test.*',
319
+ '**/*.spec.*'
320
+ ];
321
+
322
+ return glob(patterns, {
323
+ ignore,
324
+ absolute: true,
325
+ onlyFiles: true
326
+ });
327
+ }
328
+ }
329
+
330
+ module.exports = GenerateTestsUI;
@@ -0,0 +1,349 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { spawn, spawnSync } = require('child_process');
5
+
6
+ class GoUISession {
7
+ constructor() {
8
+ this._binaryPath = this._findGoUiBinary();
9
+ this._running = false;
10
+ }
11
+
12
+ start() {
13
+ this._running = true;
14
+ }
15
+
16
+ stop() {
17
+ this._running = false;
18
+ }
19
+
20
+ running() {
21
+ return this._running;
22
+ }
23
+
24
+ showMenu() {
25
+ const outputFile = this._createTempFile('menu-output', '.txt');
26
+
27
+ try {
28
+ spawnSync(this._binaryPath, ['menu', '--output', outputFile], {
29
+ stdio: 'inherit'
30
+ });
31
+
32
+ if (!fs.existsSync(outputFile) || fs.statSync(outputFile).size === 0) {
33
+ return 'exit';
34
+ }
35
+
36
+ const choice = fs.readFileSync(outputFile, 'utf8').trim();
37
+ return choice || 'exit';
38
+ } catch (error) {
39
+ console.error('Menu error:', error);
40
+ return 'exit';
41
+ } finally {
42
+ this._cleanupTempFile(outputFile);
43
+ }
44
+ }
45
+
46
+ showJsTestMenu() {
47
+ const outputFile = this._createTempFile('js-menu-output', '.txt');
48
+
49
+ try {
50
+ spawnSync(this._binaryPath, ['js-test-menu', '--output', outputFile], {
51
+ stdio: 'inherit'
52
+ });
53
+
54
+ if (!fs.existsSync(outputFile) || fs.statSync(outputFile).size === 0) {
55
+ return 'back';
56
+ }
57
+
58
+ const choice = fs.readFileSync(outputFile, 'utf8').trim();
59
+ return choice || 'back';
60
+ } catch (error) {
61
+ console.error('JS API Menu error:', error);
62
+ return 'back';
63
+ } finally {
64
+ this._cleanupTempFile(outputFile);
65
+ }
66
+ }
67
+
68
+ showListView(title, items) {
69
+ const dataJson = JSON.stringify({ title, items });
70
+ const outputFile = this._createTempFile('list-view-output', '.txt');
71
+
72
+ try {
73
+ spawnSync(this._binaryPath, ['list-view', '--data', dataJson, '--output', outputFile], {
74
+ stdio: 'inherit'
75
+ });
76
+
77
+ if (!fs.existsSync(outputFile) || fs.statSync(outputFile).size === 0) {
78
+ return 'back';
79
+ }
80
+
81
+ const selected = fs.readFileSync(outputFile, 'utf8').trim();
82
+ return selected || 'back';
83
+ } catch (error) {
84
+ console.error('List view error:', error);
85
+ return 'back';
86
+ } finally {
87
+ this._cleanupTempFile(outputFile);
88
+ }
89
+ }
90
+
91
+ showSpinner(message, func) {
92
+ const controlFile = this._createTempFile('spinner-control', '.json');
93
+
94
+ // Start spinner in background
95
+ const process = spawn(this._binaryPath, ['spinner', '--message', message, '--control', controlFile], {
96
+ stdio: ['ignore', 'inherit', 'inherit']
97
+ });
98
+
99
+ try {
100
+ const result = func();
101
+
102
+ const status = {
103
+ status: result && result.success ? 'success' : 'error',
104
+ message: (result && result.message) || 'Done!'
105
+ };
106
+
107
+ fs.writeFileSync(controlFile, JSON.stringify(status));
108
+
109
+ // Wait for process to exit
110
+ // In Node, we can't easily wait sync for background spawned process without busy loop or async
111
+ // But for this simple implementation, we'll wait for the process to exit
112
+ } catch (error) {
113
+ const status = { status: 'error', message: error.message };
114
+ fs.writeFileSync(controlFile, JSON.stringify(status));
115
+ throw error;
116
+ } finally {
117
+ // We don't delete controlFile immediately here if process is still running
118
+ // but spawnSync/wait logic would be better.
119
+ // For now, let's keep it simple.
120
+ }
121
+ }
122
+
123
+ showStats(statsData) {
124
+ const statsJson = JSON.stringify(statsData || {});
125
+ try {
126
+ spawnSync(this._binaryPath, ['stats', '--data', statsJson], {
127
+ stdio: 'inherit'
128
+ });
129
+ } catch (error) {
130
+ console.error('Stats error:', error);
131
+ }
132
+ }
133
+
134
+ showAbout() {
135
+ try {
136
+ spawnSync(this._binaryPath, ['about'], {
137
+ stdio: 'inherit'
138
+ });
139
+ } catch (error) {
140
+ console.error('About error:', error);
141
+ }
142
+ }
143
+
144
+ showPostGenerationMenu(filePath, runCommand) {
145
+ const dataJson = JSON.stringify({ file_path: filePath, run_command: runCommand });
146
+ const outputFile = this._createTempFile('post-gen-output', '.txt');
147
+
148
+ try {
149
+ spawnSync(this._binaryPath, ['post-generation-menu', '--data', dataJson, '--output', outputFile], {
150
+ stdio: 'inherit'
151
+ });
152
+
153
+ if (!fs.existsSync(outputFile) || fs.statSync(outputFile).size === 0) {
154
+ return 'back';
155
+ }
156
+
157
+ return fs.readFileSync(outputFile, 'utf8').trim() || 'back';
158
+ } catch (error) {
159
+ console.error('Post-gen menu error:', error);
160
+ return 'back';
161
+ } finally {
162
+ this._cleanupTempFile(outputFile);
163
+ }
164
+ }
165
+
166
+ async showProgress(title, handler) {
167
+ const controlFile = this._createTempFile('progress-control', '.json');
168
+
169
+ // Spawn progress in background
170
+ const child = spawn(this._binaryPath, ['progress', '--title', title, '--control', controlFile], {
171
+ stdio: ['ignore', 'inherit', 'inherit'],
172
+ env: process.env
173
+ });
174
+
175
+ const processPromise = new Promise((resolve) => {
176
+ child.on('exit', () => resolve());
177
+ child.on('error', () => resolve());
178
+ });
179
+
180
+ let stepCounter = 0;
181
+ const progress = {
182
+ update: (message, options = {}) => {
183
+ const stepIdx = options.explicit_step !== undefined ?
184
+ options.explicit_step :
185
+ (options.step_increment === false ? (stepCounter > 0 ? stepCounter - 1 : 0) : stepCounter);
186
+
187
+ const data = {
188
+ type: 'step',
189
+ step: stepIdx,
190
+ message: message
191
+ };
192
+
193
+ if (options.percent !== undefined) {
194
+ data.percent = options.percent;
195
+ }
196
+
197
+ fs.writeFileSync(controlFile, JSON.stringify(data));
198
+
199
+ if (options.explicit_step === undefined && options.step_increment !== false) {
200
+ stepCounter++;
201
+ }
202
+
203
+ // Small delay to ensure Go UI (polling at 100ms) doesn't miss rapid updates
204
+ if (options.explicit_step !== undefined) {
205
+ spawnSync('sleep', ['0.05']);
206
+ }
207
+ },
208
+ error: (message) => {
209
+ const data = { type: 'error', message };
210
+ fs.writeFileSync(controlFile, JSON.stringify(data));
211
+ },
212
+ complete: (message, options = {}) => {
213
+ const data = {
214
+ type: 'complete',
215
+ message: message || 'Done!',
216
+ auto_exit: options.auto_exit === true
217
+ };
218
+ fs.writeFileSync(controlFile, JSON.stringify(data));
219
+ }
220
+ };
221
+
222
+ try {
223
+ const result = await handler(progress);
224
+ // Signal success if not already completed/errored
225
+ progress.complete();
226
+
227
+ // Wait for progress bar to exit CLEANLY
228
+ await processPromise;
229
+
230
+ return result;
231
+ } catch (error) {
232
+ progress.error(error.message);
233
+ await processPromise; // Wait even on error
234
+ throw error;
235
+ // If the process was killed or died, exitCode will be set.
236
+ }
237
+ }
238
+
239
+
240
+ showClipboardSuccess(command) {
241
+ spawnSync(this._binaryPath, ['clipboard-success', '--command', command], {
242
+ stdio: 'inherit'
243
+ });
244
+ }
245
+
246
+ showTestResults(title, passed, failed, errors, total, results = []) {
247
+ const dataJson = JSON.stringify({ title, passed, failed, errors, total, results });
248
+ spawnSync(this._binaryPath, ['test-results', '--data', dataJson], {
249
+ stdio: 'inherit'
250
+ });
251
+ }
252
+
253
+ async showAuditResults(auditResult) {
254
+ const dataJson = JSON.stringify(auditResult);
255
+ const inputFile = this._createTempFile('audit-data', '.json');
256
+ const outputFile = this._createTempFile('audit-choice', '.txt');
257
+
258
+ try {
259
+ fs.writeFileSync(inputFile, dataJson);
260
+
261
+ // Pause stdin to allow child process to take control of TTY
262
+ if (process.stdin.setRawMode) {
263
+ process.stdin.setRawMode(false);
264
+ }
265
+ process.stdin.pause();
266
+
267
+ await new Promise((resolve, reject) => {
268
+ const child = spawn(this._binaryPath, ['audit-results', '--file', inputFile, '--output', outputFile], {
269
+ stdio: ['inherit', 'inherit', 'inherit'],
270
+ env: process.env
271
+ });
272
+
273
+ child.on('error', (err) => {
274
+ reject(err);
275
+ });
276
+
277
+ child.on('exit', (code) => {
278
+ resolve();
279
+ });
280
+ });
281
+
282
+ if (!fs.existsSync(outputFile) || fs.statSync(outputFile).size === 0) {
283
+ return 'back';
284
+ }
285
+
286
+ return fs.readFileSync(outputFile, 'utf8').trim() || 'back';
287
+ } catch (error) {
288
+ console.error('Audit results error:', error);
289
+ return 'back';
290
+ } finally {
291
+ // Resume stdin
292
+ process.stdin.resume();
293
+
294
+ this._cleanupTempFile(inputFile);
295
+ this._cleanupTempFile(outputFile);
296
+ }
297
+ }
298
+
299
+ showNoItems(itemType) {
300
+ try {
301
+ spawnSync(this._binaryPath, ['no-items', '--type', itemType], {
302
+ stdio: 'inherit'
303
+ });
304
+ } catch (error) {
305
+ console.error('No items error:', error);
306
+ }
307
+ }
308
+
309
+ _findGoUiBinary() {
310
+ const platform = process.platform;
311
+ const arch = process.arch;
312
+ let binaryName = '';
313
+
314
+ if (platform === 'darwin') {
315
+ binaryName = arch === 'arm64' ? 'go-ui-darwin-arm64' : 'go-ui-darwin-amd64';
316
+ } else if (platform === 'linux') {
317
+ binaryName = arch === 'arm64' ? 'go-ui-linux-arm64' : 'go-ui-linux-amd64';
318
+ } else {
319
+ throw new Error(`Unsupported platform: ${platform}`);
320
+ }
321
+
322
+ // Look in binaries folder relative to this file
323
+ const localBinary = path.join(__dirname, '..', 'binaries', binaryName);
324
+ if (fs.existsSync(localBinary)) {
325
+ return localBinary;
326
+ }
327
+
328
+ throw new Error(`go-ui binary not found: ${localBinary}`);
329
+ }
330
+
331
+ _createTempFile(prefix, suffix) {
332
+ const tmpDir = os.tmpdir();
333
+ const filePath = path.join(tmpDir, `${prefix}-${Date.now()}-${Math.floor(Math.random() * 1000)}${suffix}`);
334
+ fs.writeFileSync(filePath, '');
335
+ return filePath;
336
+ }
337
+
338
+ _cleanupTempFile(filePath) {
339
+ if (fs.existsSync(filePath)) {
340
+ try {
341
+ fs.unlinkSync(filePath);
342
+ } catch (e) {
343
+ // Ignore cleanup errors
344
+ }
345
+ }
346
+ }
347
+ }
348
+
349
+ module.exports = GoUISession;