testronaut 1.1.1 → 1.2.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/CONTRIBUTING.md +194 -0
- package/bin/cli.js +1 -1
- package/bin/init.js +141 -57
- package/bin/initHelpers.js +140 -0
- package/core/agent.js +1 -1
- package/core/turnIntent.js +51 -20
- package/core/turnLoop.js +172 -105
- package/llm/gemini/geminiProvider.js +188 -0
- package/llm/llmFactory.js +50 -0
- package/llm/modelResolver.js +65 -0
- package/llm/openAI/openaiProvider.js +54 -0
- package/package.json +9 -3
- package/tests/binTests/init.test.js +189 -0
- package/tests/binTests/initHelpersTests/defaultConfig.test.js +12 -0
- package/tests/binTests/initHelpersTests/makeEnvTemplate.test.js +14 -0
- package/tests/binTests/initHelpersTests/migrateLegacyConfig.test.js +19 -0
- package/tests/binTests/initHelpersTests/models.test.js +21 -0
- package/tests/coreTests/turnLoop.test.js +177 -0
- package/tests/llmTests/geminiProvider.test.js +191 -0
- package/tests/llmTests/llmFactory.test.js +78 -0
- package/tests/llmTests/modelResolver.test.js +119 -0
- package/tests/llmTests/openAIProvider.test.js +117 -0
- package/tests/toolsTests/tokenControl.test.js +150 -0
- package/tools/generateHtmlReport.js +83 -0
- package/tools/tokenControl.js +107 -17
- package/vitest.config.js +8 -0
- package/openAI/modelResolver.js +0 -33
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* geminiProvider.js
|
|
3
|
+
* ------------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Adapter that normalizes Testronaut's OpenAI-like chat format to
|
|
6
|
+
* Google Gemini's SDK and back. Exposes a single `chat()` method.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Convert OpenAI-like messages → Gemini "contents" (roles/parts).
|
|
10
|
+
* - Map tool/function calling both ways:
|
|
11
|
+
* • assistant.tool_calls → Gemini functionCall parts
|
|
12
|
+
* • tool messages → user parts (so the model can read results)
|
|
13
|
+
* - Return an OpenAI-like assistant message and usage metadata.
|
|
14
|
+
*
|
|
15
|
+
* Message contract (OpenAI-like, internal):
|
|
16
|
+
* - messages: Array<{ role: 'system'|'user'|'assistant'|'tool', content?: string|Array, ... }>
|
|
17
|
+
* - Assistant tool calls:
|
|
18
|
+
* message.tool_calls = [{ id, type:'function', function:{ name, arguments:string }}]
|
|
19
|
+
* - Tool messages:
|
|
20
|
+
* { role:'tool', tool_call_id, name, type:'function', content:string }
|
|
21
|
+
*
|
|
22
|
+
* Related tests:
|
|
23
|
+
* Located in `tests/llmTests/geminiProvider.test.js`
|
|
24
|
+
*
|
|
25
|
+
* Used by:
|
|
26
|
+
* - llm/llmFactory.js
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { GoogleGenerativeAI } from '@google/generative-ai';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Convert OpenAI-like messages → Gemini "contents" array.
|
|
33
|
+
* - System messages are coalesced and injected as a prefix into the next user turn.
|
|
34
|
+
* - Assistant tool calls are represented as model turns with functionCall parts.
|
|
35
|
+
* - Tool results are encoded as a user turn with a JSON payload part.
|
|
36
|
+
* - Text/images are mapped to Gemini `parts` (text / inlineData).
|
|
37
|
+
*/
|
|
38
|
+
function toGeminiContents(messages) {
|
|
39
|
+
const contents = [];
|
|
40
|
+
let systemPrefix = '';
|
|
41
|
+
|
|
42
|
+
for (const m of messages) {
|
|
43
|
+
if (m.role === 'system') {
|
|
44
|
+
// Collect multi-system messages; inject once on the next user message
|
|
45
|
+
const sysText = Array.isArray(m.content)
|
|
46
|
+
? m.content.map(p => (typeof p === 'string' ? p : p.text || '')).join('\n')
|
|
47
|
+
: (m.content || '');
|
|
48
|
+
systemPrefix += (systemPrefix ? '\n' : '') + sysText;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Assistant tool calls → Gemini functionCall parts on a model turn
|
|
53
|
+
if (m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) {
|
|
54
|
+
contents.push({
|
|
55
|
+
role: 'model',
|
|
56
|
+
parts: m.tool_calls.map(tc => ({
|
|
57
|
+
functionCall: {
|
|
58
|
+
name: tc.function?.name,
|
|
59
|
+
args: safeJsonParse(tc.function?.arguments) ?? {},
|
|
60
|
+
},
|
|
61
|
+
})),
|
|
62
|
+
});
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Tool results → encode as a user turn with a JSON part the model can parse
|
|
67
|
+
if (m.role === 'tool') {
|
|
68
|
+
const parts = [{
|
|
69
|
+
text: JSON.stringify({
|
|
70
|
+
_tool_result: true,
|
|
71
|
+
tool_call_id: m.tool_call_id,
|
|
72
|
+
name: m.name,
|
|
73
|
+
content: m.content,
|
|
74
|
+
}),
|
|
75
|
+
}];
|
|
76
|
+
contents.push({ role: 'user', parts });
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Normal user/assistant content (text and/or images)
|
|
81
|
+
const baseParts = [];
|
|
82
|
+
const asArray = Array.isArray(m.content) ? m.content : [{ type: 'text', text: m.content }];
|
|
83
|
+
for (const p of asArray) {
|
|
84
|
+
if (p?.type === 'image') {
|
|
85
|
+
baseParts.push({
|
|
86
|
+
inlineData: {
|
|
87
|
+
mimeType: p.mimeType,
|
|
88
|
+
data: Buffer.from(p.data).toString('base64'),
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
} else {
|
|
92
|
+
baseParts.push({ text: typeof p === 'string' ? p : (p.text ?? '') });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Include system prefix exactly once on the first user turn after system
|
|
97
|
+
if (systemPrefix && m.role === 'user') {
|
|
98
|
+
baseParts.unshift({ text: `[system]\n${systemPrefix}` });
|
|
99
|
+
systemPrefix = '';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
contents.push({ role: m.role === 'assistant' ? 'model' : 'user', parts: baseParts });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return contents;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function safeJsonParse(s) {
|
|
109
|
+
try { return JSON.parse(s ?? '{}'); } catch { return null; }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Convert a Gemini candidate → OpenAI-like assistant message.
|
|
114
|
+
* - Collects text parts into `content`.
|
|
115
|
+
* - Translates functionCall parts into `tool_calls`.
|
|
116
|
+
*/
|
|
117
|
+
function fromGeminiCandidate(cand) {
|
|
118
|
+
const parts = cand?.content?.parts ?? [];
|
|
119
|
+
const tool_calls = [];
|
|
120
|
+
const texts = [];
|
|
121
|
+
|
|
122
|
+
for (const p of parts) {
|
|
123
|
+
if (p.functionCall) {
|
|
124
|
+
tool_calls.push({
|
|
125
|
+
id: cryptoRandomId(),
|
|
126
|
+
type: 'function',
|
|
127
|
+
function: {
|
|
128
|
+
name: p.functionCall.name,
|
|
129
|
+
arguments: JSON.stringify(p.functionCall.args ?? {}),
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
} else if (typeof p.text === 'string') {
|
|
133
|
+
texts.push(p.text);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const content = texts.join('');
|
|
138
|
+
return { role: 'assistant', content, tool_calls: tool_calls.length ? tool_calls : undefined };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function cryptoRandomId() {
|
|
142
|
+
return 'tool_' + Math.random().toString(36).slice(2);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export class GeminiProvider {
|
|
146
|
+
constructor({ apiKey } = {}) {
|
|
147
|
+
if (!apiKey) throw new Error('GEMINI_API_KEY is required for Gemini provider');
|
|
148
|
+
this.client = new GoogleGenerativeAI(apiKey);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Execute a chat turn via Gemini and normalize the response.
|
|
153
|
+
* @param {{model:string, messages:any[], tools?:any[]}} params
|
|
154
|
+
* @returns {Promise<{message:any, usage?:{total_tokens?:number, providerRaw?:any}}>}
|
|
155
|
+
*/
|
|
156
|
+
async chat({ model, messages, tools }) {
|
|
157
|
+
// Map OpenAI-like tool schema → Gemini functionDeclarations
|
|
158
|
+
const genTools = tools?.length
|
|
159
|
+
? [{
|
|
160
|
+
functionDeclarations: tools.map(t => ({
|
|
161
|
+
name: t.function?.name ?? t.name,
|
|
162
|
+
description: t.description ?? '',
|
|
163
|
+
parameters: t.function?.parameters ?? t.parameters ?? {}, // JSON schema
|
|
164
|
+
})),
|
|
165
|
+
}]
|
|
166
|
+
: undefined;
|
|
167
|
+
|
|
168
|
+
const gmodel = this.client.getGenerativeModel({
|
|
169
|
+
model,
|
|
170
|
+
tools: genTools,
|
|
171
|
+
generationConfig: {}, // temperature/topP can be added upstream if needed
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const contents = toGeminiContents(messages);
|
|
175
|
+
const res = await gmodel.generateContent({ contents });
|
|
176
|
+
|
|
177
|
+
const cand = res?.response?.candidates?.[0];
|
|
178
|
+
const message = fromGeminiCandidate(cand);
|
|
179
|
+
|
|
180
|
+
const usageMeta = res?.response?.usageMetadata;
|
|
181
|
+
const usage = {
|
|
182
|
+
total_tokens: usageMeta?.totalTokenCount,
|
|
183
|
+
providerRaw: usageMeta,
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
return { message, usage };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llmFactory.js
|
|
3
|
+
* --------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Provide a single entry point to obtain an LLM provider adapter.
|
|
6
|
+
* Each adapter exposes a unified `chat({ model, messages, tools })`
|
|
7
|
+
* and returns `{ message, usage }` where `message` is OpenAI-like.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities:
|
|
10
|
+
* - Map provider name → concrete adapter instance.
|
|
11
|
+
* - Normalize constructor options (env API keys + optional overrides).
|
|
12
|
+
*
|
|
13
|
+
* Message contract (internal, OpenAI-like):
|
|
14
|
+
* - messages: Array<{ role: 'system'|'user'|'assistant'|'tool', content?: string, ... }>
|
|
15
|
+
* - Assistant tool calls:
|
|
16
|
+
* message.tool_calls = [{ id, type:'function', function:{ name, arguments:string }}]
|
|
17
|
+
* - Tool messages:
|
|
18
|
+
* { role:'tool', tool_call_id, name, type:'function', content:string }
|
|
19
|
+
*
|
|
20
|
+
* Related tests:
|
|
21
|
+
* Located in `tests/llmTests/llmFactory.test.js`
|
|
22
|
+
*
|
|
23
|
+
* Used by:
|
|
24
|
+
* - core/turnLoop.js (to obtain the active provider implementation)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { OpenAIProvider } from './openAI/openaiProvider.js';
|
|
28
|
+
import { GeminiProvider } from './gemini/geminiProvider.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return an adapter implementing:
|
|
32
|
+
* chat({ model, messages, tools }) -> Promise<{ message, usage }>
|
|
33
|
+
*
|
|
34
|
+
* @param {string} providerName - 'openai' | 'gemini'
|
|
35
|
+
* @param {object} [opts] - Optional overrides (e.g., { apiKey, baseURL, ... })
|
|
36
|
+
* @returns {{ chat: Function }} provider adapter
|
|
37
|
+
*/
|
|
38
|
+
export function getLLM(providerName, opts = {}) {
|
|
39
|
+
switch ((providerName || '').toLowerCase()) {
|
|
40
|
+
case 'openai':
|
|
41
|
+
// NOTE: opts last → caller can override env key if needed (useful for tests)
|
|
42
|
+
return new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY, ...opts });
|
|
43
|
+
|
|
44
|
+
case 'gemini':
|
|
45
|
+
return new GeminiProvider({ apiKey: process.env.GEMINI_API_KEY, ...opts });
|
|
46
|
+
|
|
47
|
+
default:
|
|
48
|
+
throw new Error(`Unsupported LLM provider: ${providerName}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* modelResolver.js
|
|
3
|
+
* -----------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Determines which LLM provider and model Testronaut should use.
|
|
6
|
+
* Reads from env first, then `testronaut-config.json`, then defaults.
|
|
7
|
+
*
|
|
8
|
+
* Resolution priority:
|
|
9
|
+
* 1) Env: TESTRONAUT_PROVIDER / TESTRONAUT_MODEL
|
|
10
|
+
* 2) Config file (provider + model)
|
|
11
|
+
* 3) Legacy config (model === "openai", no provider)
|
|
12
|
+
* 4) Default: { provider: "openai", model: "gpt-4o" }
|
|
13
|
+
*
|
|
14
|
+
* Related tests: tests/llmTests/modelResolver.test.js
|
|
15
|
+
* Used by: core/turnLoop.js, llm/llmFactory.js
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve provider & model, honoring env overrides and safe defaults.
|
|
23
|
+
* @param {{cwd?: string}} [opts] - optional current working directory override (useful in tests)
|
|
24
|
+
* @returns {{provider: string, model: string}}
|
|
25
|
+
*/
|
|
26
|
+
export function resolveProviderModel(opts = {}) {
|
|
27
|
+
const envProvider = process.env.TESTRONAUT_PROVIDER?.trim();
|
|
28
|
+
const envModel = process.env.TESTRONAUT_MODEL?.trim();
|
|
29
|
+
|
|
30
|
+
// 1) Env override (both provided)
|
|
31
|
+
if (envProvider && envModel) {
|
|
32
|
+
return { provider: envProvider, model: envModel };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 2) Config lookup (compute path at call time)
|
|
36
|
+
const root = opts.cwd || process.cwd();
|
|
37
|
+
const configPath = path.join(root, 'testronaut-config.json');
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
if (fs.existsSync(configPath)) {
|
|
41
|
+
const cfg = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
42
|
+
|
|
43
|
+
// Modern config: explicit provider + model
|
|
44
|
+
if (cfg?.provider && cfg?.model) {
|
|
45
|
+
return {
|
|
46
|
+
provider: envProvider || cfg.provider,
|
|
47
|
+
model: envModel || cfg.model,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Legacy: model === "openai" and no provider
|
|
52
|
+
if (!cfg?.provider && cfg?.model === 'openai') {
|
|
53
|
+
return { provider: 'openai', model: envModel || 'gpt-4o' };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.warn('⚠️ Could not read testronaut-config.json; using defaults. Error:', e.message);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 3) Defaults
|
|
61
|
+
return {
|
|
62
|
+
provider: envProvider || 'openai',
|
|
63
|
+
model: envModel || 'gpt-4o',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* openaiProvider.js
|
|
3
|
+
* ------------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Adapter that wraps OpenAI's SDK and exposes a normalized `chat()` API
|
|
6
|
+
* consistent with Testronaut’s OpenAI-like message format.
|
|
7
|
+
*
|
|
8
|
+
* Responsibilities:
|
|
9
|
+
* - Call OpenAI Chat Completions with { model, messages, tools }.
|
|
10
|
+
* - Return { message, usage, headers } where:
|
|
11
|
+
* • message is OpenAI-like (already native)
|
|
12
|
+
* • usage.total_tokens is forwarded from OpenAI
|
|
13
|
+
* • headers (if present) can be used by the token limit learner
|
|
14
|
+
*
|
|
15
|
+
* Related tests:
|
|
16
|
+
* Located in `tests/llmTests/openAIProvider.test.js`
|
|
17
|
+
*
|
|
18
|
+
* Used by:
|
|
19
|
+
* - llm/llmFactory.js
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import OpenAI from 'openai';
|
|
23
|
+
|
|
24
|
+
export class OpenAIProvider {
|
|
25
|
+
constructor({ apiKey } = {}) {
|
|
26
|
+
if (!apiKey) throw new Error('OPENAI_API_KEY is required for OpenAI provider');
|
|
27
|
+
this.client = new OpenAI({ apiKey });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Execute a chat turn via OpenAI and normalize the response.
|
|
32
|
+
* @param {{model:string, messages:any[], tools?:any[]}} params
|
|
33
|
+
* @returns {Promise<{message:any, usage?:{total_tokens?:number, providerRaw?:any}, headers?:any}>}
|
|
34
|
+
*/
|
|
35
|
+
async chat({ model, messages, tools }) {
|
|
36
|
+
const res = await this.client.chat.completions.create({
|
|
37
|
+
model,
|
|
38
|
+
messages,
|
|
39
|
+
tools,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// OpenAI already returns an OpenAI-like message and usage structure.
|
|
43
|
+
const message = res.choices?.[0]?.message ?? { role: 'assistant', content: '' };
|
|
44
|
+
const usage = {
|
|
45
|
+
total_tokens: res.usage?.total_tokens,
|
|
46
|
+
providerRaw: res.usage,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Some SDK versions surface headers on `res.headers`, others on `res.response.headers`.
|
|
50
|
+
const headers = res.headers || res.response?.headers;
|
|
51
|
+
|
|
52
|
+
return { message, usage, headers };
|
|
53
|
+
}
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "testronaut",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"main": "./runner/testronaut.js",
|
|
5
5
|
"bin": {
|
|
6
6
|
"testronaut": "./bin/cli.js"
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
14
|
"scripts": {
|
|
15
|
-
"test": "
|
|
15
|
+
"test": "vitest run",
|
|
16
|
+
"test:watch": "vitest",
|
|
16
17
|
"start": "node ./bin/cli.js"
|
|
17
18
|
},
|
|
18
19
|
"author": "",
|
|
@@ -20,6 +21,7 @@
|
|
|
20
21
|
"description": "",
|
|
21
22
|
"dependencies": {
|
|
22
23
|
"@dqbd/tiktoken": "^1.0.21",
|
|
24
|
+
"@google/generative-ai": "^0.24.1",
|
|
23
25
|
"cheerio": "^1.1.0",
|
|
24
26
|
"dotenv": "^17.2.0",
|
|
25
27
|
"inquirer": "^12.9.1",
|
|
@@ -34,5 +36,9 @@
|
|
|
34
36
|
"automation",
|
|
35
37
|
"node",
|
|
36
38
|
"qa"
|
|
37
|
-
]
|
|
39
|
+
],
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^24.9.1",
|
|
42
|
+
"vitest": "^4.0.1"
|
|
43
|
+
}
|
|
38
44
|
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// tests/cliTests/init.test.js
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
// ─────────────────────────────────────────────
|
|
8
|
+
// Hoisted queue for the `prompts` mock
|
|
9
|
+
// ─────────────────────────────────────────────
|
|
10
|
+
const { shared } = vi.hoisted(() => ({
|
|
11
|
+
shared: {
|
|
12
|
+
answers: [], // each element should be the resolved object for one prompts() call
|
|
13
|
+
calls: 0,
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
// Mock `prompts` to return queued answers
|
|
18
|
+
vi.mock('prompts', () => ({
|
|
19
|
+
default: async () => {
|
|
20
|
+
shared.calls += 1;
|
|
21
|
+
if (!shared.answers.length) {
|
|
22
|
+
throw new Error('Test prompts queue empty. Push expected answers to shared.answers.');
|
|
23
|
+
}
|
|
24
|
+
return shared.answers.shift();
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Important: import SUT after mocks
|
|
29
|
+
import { initializeTestronautProject } from '../../bin/init';
|
|
30
|
+
|
|
31
|
+
function makeTempProject() {
|
|
32
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-init-'));
|
|
33
|
+
}
|
|
34
|
+
function readJson(p) {
|
|
35
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
36
|
+
}
|
|
37
|
+
function fileExists(p) {
|
|
38
|
+
try { return fs.statSync(p).isFile(); } catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
function dirExists(p) {
|
|
41
|
+
try { return fs.statSync(p).isDirectory(); } catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const originalCwd = process.cwd();
|
|
45
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
46
|
+
|
|
47
|
+
describe('initializeTestronautProject', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
process.env = { ...ORIGINAL_ENV };
|
|
50
|
+
shared.answers = [];
|
|
51
|
+
shared.calls = 0;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
process.env = { ...ORIGINAL_ENV };
|
|
56
|
+
process.chdir(originalCwd);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('scaffolds a new OpenAI project: config, .env, folders, welcome mission', async () => {
|
|
60
|
+
const temp = makeTempProject();
|
|
61
|
+
process.chdir(temp);
|
|
62
|
+
|
|
63
|
+
// Queue answers for two prompts:
|
|
64
|
+
// 1) provider
|
|
65
|
+
// 2) openai model
|
|
66
|
+
shared.answers.push({ llmProvider: 'openai' });
|
|
67
|
+
shared.answers.push({ openaiModel: 'gpt-4o' });
|
|
68
|
+
|
|
69
|
+
await initializeTestronautProject();
|
|
70
|
+
|
|
71
|
+
// Config
|
|
72
|
+
const configPath = path.join(temp, 'testronaut-config.json');
|
|
73
|
+
expect(fileExists(configPath)).toBe(true);
|
|
74
|
+
const cfg = readJson(configPath);
|
|
75
|
+
expect(cfg.provider).toBe('openai');
|
|
76
|
+
expect(cfg.model).toBe('gpt-4o');
|
|
77
|
+
expect(cfg.initialized).toBe(true);
|
|
78
|
+
expect(cfg.outputDir).toBe('missions/mission_reports');
|
|
79
|
+
expect(typeof cfg.maxTurns).toBe('number');
|
|
80
|
+
|
|
81
|
+
// .env
|
|
82
|
+
const envPath = path.join(temp, '.env');
|
|
83
|
+
expect(fileExists(envPath)).toBe(true);
|
|
84
|
+
const envTxt = fs.readFileSync(envPath, 'utf8');
|
|
85
|
+
expect(envTxt).toMatch(/OPENAI_API_KEY=sk-/);
|
|
86
|
+
|
|
87
|
+
// Folders
|
|
88
|
+
expect(dirExists(path.join(temp, 'missions'))).toBe(true);
|
|
89
|
+
expect(dirExists(path.join(temp, 'missions', 'mission_reports'))).toBe(true);
|
|
90
|
+
|
|
91
|
+
// Welcome mission (name depends on your implementation; we check presence in missions/)
|
|
92
|
+
const files = fs.readdirSync(path.join(temp, 'missions'));
|
|
93
|
+
expect(files.some(f => /welcome/i.test(f))).toBe(true);
|
|
94
|
+
|
|
95
|
+
// Exactly two prompts were asked
|
|
96
|
+
expect(shared.calls).toBe(2);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('scaffolds a new Gemini project: config, .env, folders, welcome mission', async () => {
|
|
100
|
+
const temp = makeTempProject();
|
|
101
|
+
process.chdir(temp);
|
|
102
|
+
|
|
103
|
+
shared.answers.push({ llmProvider: 'gemini' });
|
|
104
|
+
shared.answers.push({ geminiModel: 'gemini-2.5-flash' });
|
|
105
|
+
|
|
106
|
+
await initializeTestronautProject();
|
|
107
|
+
|
|
108
|
+
const cfg = readJson(path.join(temp, 'testronaut-config.json'));
|
|
109
|
+
expect(cfg.provider).toBe('gemini');
|
|
110
|
+
expect(cfg.model).toBe('gemini-2.5-flash');
|
|
111
|
+
|
|
112
|
+
const envTxt = fs.readFileSync(path.join(temp, '.env'), 'utf8');
|
|
113
|
+
expect(envTxt).toMatch(/GEMINI_API_KEY=AIza/);
|
|
114
|
+
|
|
115
|
+
expect(dirExists(path.join(temp, 'missions'))).toBe(true);
|
|
116
|
+
expect(dirExists(path.join(temp, 'missions', 'mission_reports'))).toBe(true);
|
|
117
|
+
|
|
118
|
+
const files = fs.readdirSync(path.join(temp, 'missions'));
|
|
119
|
+
expect(files.some(f => /welcome/i.test(f))).toBe(true);
|
|
120
|
+
|
|
121
|
+
expect(shared.calls).toBe(2);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('is idempotent: when initialized, it skips prompts and does not overwrite .env', async () => {
|
|
125
|
+
const temp = makeTempProject();
|
|
126
|
+
process.chdir(temp);
|
|
127
|
+
|
|
128
|
+
// Pre-create a config marked as initialized + a non-empty .env
|
|
129
|
+
const configPath = path.join(temp, 'testronaut-config.json');
|
|
130
|
+
fs.writeFileSync(
|
|
131
|
+
configPath,
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
initialized: true,
|
|
134
|
+
provider: 'openai',
|
|
135
|
+
model: 'gpt-4o',
|
|
136
|
+
outputDir: 'missions/mission_reports',
|
|
137
|
+
projectName: 'demo',
|
|
138
|
+
maxTurns: 20,
|
|
139
|
+
}, null, 2),
|
|
140
|
+
'utf8'
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const envPath = path.join(temp, '.env');
|
|
144
|
+
fs.writeFileSync(envPath, 'DO_NOT_TOUCH=1\n', 'utf8');
|
|
145
|
+
|
|
146
|
+
// Also ensure folders are missing to verify folder creation still happens
|
|
147
|
+
// (initialize should still ensure dirs even when skipping prompts)
|
|
148
|
+
await initializeTestronautProject();
|
|
149
|
+
|
|
150
|
+
// Prompts should not have been called
|
|
151
|
+
expect(shared.calls).toBe(0);
|
|
152
|
+
|
|
153
|
+
// .env untouched
|
|
154
|
+
const envTxt = fs.readFileSync(envPath, 'utf8');
|
|
155
|
+
expect(envTxt).toBe('DO_NOT_TOUCH=1\n');
|
|
156
|
+
|
|
157
|
+
// Folders ensured
|
|
158
|
+
expect(dirExists(path.join(temp, 'missions'))).toBe(true);
|
|
159
|
+
expect(dirExists(path.join(temp, 'missions', 'mission_reports'))).toBe(true);
|
|
160
|
+
|
|
161
|
+
// Config left as-is (no clobber)
|
|
162
|
+
const cfg = readJson(configPath);
|
|
163
|
+
expect(cfg.initialized).toBe(true);
|
|
164
|
+
expect(cfg.provider).toBe('openai');
|
|
165
|
+
expect(cfg.model).toBe('gpt-4o');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('preserves pre-existing fields and merges defaults without clobbering', async () => {
|
|
169
|
+
const temp = makeTempProject();
|
|
170
|
+
process.chdir(temp);
|
|
171
|
+
|
|
172
|
+
// seed a partial config (not initialized yet)
|
|
173
|
+
fs.writeFileSync(
|
|
174
|
+
path.join(temp, 'testronaut-config.json'),
|
|
175
|
+
JSON.stringify({ projectName: 'my-project' }, null, 2),
|
|
176
|
+
'utf8'
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
shared.answers.push({ llmProvider: 'openai' });
|
|
180
|
+
shared.answers.push({ openaiModel: 'gpt-4.1-mini' });
|
|
181
|
+
|
|
182
|
+
await initializeTestronautProject();
|
|
183
|
+
|
|
184
|
+
const cfg = readJson(path.join(temp, 'testronaut-config.json'));
|
|
185
|
+
expect(cfg.projectName).toBe('my-project'); // preserved
|
|
186
|
+
expect(cfg.outputDir).toBe('missions/mission_reports'); // default filled in
|
|
187
|
+
expect(cfg.maxTurns).toBe(20); // default filled in
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { defaultConfig } from '../../../bin/initHelpers';
|
|
3
|
+
|
|
4
|
+
describe('defaultConfig', () => {
|
|
5
|
+
it('creates sane defaults', () => {
|
|
6
|
+
const cfg = defaultConfig('my-app');
|
|
7
|
+
expect(cfg.initialized).toBe(true);
|
|
8
|
+
expect(cfg.outputDir).toBe('missions/mission_reports');
|
|
9
|
+
expect(cfg.projectName).toBe('my-app');
|
|
10
|
+
expect(cfg.maxTurns).toBe(20);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { makeEnvTemplate } from '../../../bin/initHelpers';
|
|
3
|
+
|
|
4
|
+
describe('makeEnvTemplate', () => {
|
|
5
|
+
it('returns OpenAI template', () => {
|
|
6
|
+
const s = makeEnvTemplate('openai');
|
|
7
|
+
expect(s).toMatch(/OPENAI_API_KEY=sk-\.\.\./);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('returns Gemini template', () => {
|
|
11
|
+
const s = makeEnvTemplate('gemini');
|
|
12
|
+
expect(s).toMatch(/GEMINI_API_KEY=AIza\.\.\./);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { migrateLegacyConfig } from '../../../bin/initHelpers';
|
|
3
|
+
|
|
4
|
+
describe('migrateLegacyConfig', () => {
|
|
5
|
+
it('sets provider=openai when model=openai and provider is missing', () => {
|
|
6
|
+
const out = migrateLegacyConfig({ model: 'openai' });
|
|
7
|
+
expect(out.provider).toBe('openai');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('does not override existing provider', () => {
|
|
11
|
+
const out = migrateLegacyConfig({ model: 'openai', provider: 'gemini' });
|
|
12
|
+
expect(out.provider).toBe('gemini');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('keeps unrelated configs intact', () => {
|
|
16
|
+
const out = migrateLegacyConfig({ foo: 1, bar: 'x' });
|
|
17
|
+
expect(out).toEqual({ foo: 1, bar: 'x' });
|
|
18
|
+
});
|
|
19
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { openAIModels, geminiModels, isKnownModel, pickInitialIndex } from '../../../bin/initHelpers';
|
|
3
|
+
|
|
4
|
+
describe('model helpers', () => {
|
|
5
|
+
it('lists known models', () => {
|
|
6
|
+
expect(openAIModels()).toContain('gpt-4o');
|
|
7
|
+
expect(geminiModels()).toContain('gemini-2.5-flash');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('recognizes known models by provider', () => {
|
|
11
|
+
expect(isKnownModel('openai', 'gpt-4.1-mini')).toBe(true);
|
|
12
|
+
expect(isKnownModel('gemini', 'gpt-4.1-mini')).toBe(false);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('picks initial index from list or fallback', () => {
|
|
16
|
+
const list = ['a','b','c'];
|
|
17
|
+
expect(pickInitialIndex(list, 'b', 'c')).toBe(1);
|
|
18
|
+
expect(pickInitialIndex(list, 'z', 'c')).toBe(2);
|
|
19
|
+
expect(pickInitialIndex(list, 'z', 'y')).toBe(0);
|
|
20
|
+
});
|
|
21
|
+
});
|