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,177 @@
|
|
|
1
|
+
// tests/turnLoopTests/turnLoop.test.js
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// ─────────────────────────────────────────────
|
|
5
|
+
// Hoisted shared state (visible to mock factories)
|
|
6
|
+
// ─────────────────────────────────────────────
|
|
7
|
+
const { shared } = vi.hoisted(() => {
|
|
8
|
+
return {
|
|
9
|
+
shared: {
|
|
10
|
+
// LLM chat mock will be attached here by the llmFactory mock
|
|
11
|
+
chatMock: null,
|
|
12
|
+
|
|
13
|
+
// Browser tool spies available to the chromeBrowser mock
|
|
14
|
+
chromeToolSpies: {
|
|
15
|
+
click_text: vi.fn(async () => JSON.stringify({ ok: true, clicked: 'Hello' })),
|
|
16
|
+
get_dom: vi.fn(async () => '<html>dom</html>'),
|
|
17
|
+
screenshot: vi.fn(async () =>
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
_testronaut_file_event: 'download',
|
|
20
|
+
fileName: 'shot.png',
|
|
21
|
+
bytes: 1234,
|
|
22
|
+
mode: 'save',
|
|
23
|
+
})
|
|
24
|
+
),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ─────────────────────────────────────────────
|
|
31
|
+
// Mocks
|
|
32
|
+
// ─────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
vi.mock('../../llm/modelResolver.js', () => ({
|
|
35
|
+
resolveProviderModel: () => ({ provider: 'stub', model: 'stub-model' }),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
// Use hoisted shared.chatMock
|
|
39
|
+
vi.mock('../../llm/llmFactory.js', () => ({
|
|
40
|
+
getLLM: () => {
|
|
41
|
+
shared.chatMock = vi.fn();
|
|
42
|
+
return { chat: shared.chatMock };
|
|
43
|
+
},
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
vi.mock('../../tools/tokenControl.js', () => ({
|
|
47
|
+
tokenEstimate: vi.fn(async () => 0),
|
|
48
|
+
tokenUseCoolOff: vi.fn(async (totalTokensUsed, turnTimestamps) => ({
|
|
49
|
+
shouldBackoff: false,
|
|
50
|
+
totalTokensUsed,
|
|
51
|
+
turnTimestamps,
|
|
52
|
+
})),
|
|
53
|
+
recordTokenUsage: vi.fn(() => {}),
|
|
54
|
+
pruneOldTokenUsage: vi.fn((turnTimestamps) => ({
|
|
55
|
+
turnTimestamps,
|
|
56
|
+
totalTokensUsed: turnTimestamps.reduce((a, [, t]) => a + t, 0),
|
|
57
|
+
})),
|
|
58
|
+
updateLimitsFromHeaders: vi.fn(() => {}),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.mock('../../tools/toolSchema.js', () => ({
|
|
62
|
+
default: [
|
|
63
|
+
{ type: 'function', function: { name: 'click_text', description: 'click by text', parameters: {} } },
|
|
64
|
+
{ type: 'function', function: { name: 'get_dom', description: 'get DOM html', parameters: {} } },
|
|
65
|
+
{ type: 'function', function: { name: 'screenshot', description: 'take a screenshot', parameters: {} } },
|
|
66
|
+
],
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
// Use hoisted shared.chromeToolSpies
|
|
70
|
+
vi.mock('../../tools/chromeBrowser.js', () => ({
|
|
71
|
+
CHROME_TOOL_MAP: shared.chromeToolSpies,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
vi.mock('../../tools/turnLoopUtils.js', async () => {
|
|
75
|
+
const real = await vi.importActual('../../tools/turnLoopUtils.js');
|
|
76
|
+
return {
|
|
77
|
+
...real,
|
|
78
|
+
finalResponseHandler: (msg) => {
|
|
79
|
+
const content = (msg?.content || '').toString();
|
|
80
|
+
if (content.includes('FINAL')) {
|
|
81
|
+
return { finalMessage: 'Mission complete', success: true };
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
},
|
|
85
|
+
validateAndInsertMissingToolResponses: () => true,
|
|
86
|
+
wait: vi.fn(async () => {}),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
vi.mock('../../core/turnIntent.js', () => ({
|
|
91
|
+
summarizeTurnIntentFromMessage: (msg) => `plan:${(msg?.content || '').slice(0, 20)}`,
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
vi.mock('../../core/redaction.js', () => ({
|
|
95
|
+
redactArgs: (name, args) => args,
|
|
96
|
+
}));
|
|
97
|
+
|
|
98
|
+
// Import SUT after mocks
|
|
99
|
+
import { turnLoop } from '../../core/turnLoop.js';
|
|
100
|
+
|
|
101
|
+
// Helper
|
|
102
|
+
function baseMessages() {
|
|
103
|
+
return [
|
|
104
|
+
{ role: 'system', content: 'You are Testronaut.' },
|
|
105
|
+
{ role: 'user', content: 'Start mission' },
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe('turnLoop', () => {
|
|
110
|
+
let browser;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
browser = {};
|
|
114
|
+
// reset spies and chat mock
|
|
115
|
+
shared.chromeToolSpies.click_text.mockClear();
|
|
116
|
+
shared.chromeToolSpies.get_dom.mockClear();
|
|
117
|
+
shared.chromeToolSpies.screenshot.mockClear();
|
|
118
|
+
if (shared.chatMock) shared.chatMock.mockReset();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('handles a final response in a single turn (no tools)', async () => {
|
|
122
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
123
|
+
message: { role: 'assistant', content: 'FINAL: success' },
|
|
124
|
+
usage: { total_tokens: 42 },
|
|
125
|
+
headers: {},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const messages = baseMessages();
|
|
129
|
+
const result = await turnLoop(browser, messages, 1, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
130
|
+
|
|
131
|
+
expect(result).toBeTruthy();
|
|
132
|
+
expect(result.steps?.length).toBe(1);
|
|
133
|
+
const step = result.steps[0];
|
|
134
|
+
expect(step.tokensUsed).toBe(42);
|
|
135
|
+
expect(step.summary).toContain('plan:FINAL: success');
|
|
136
|
+
expect(step.result).toMatch(/✅|Success/);
|
|
137
|
+
|
|
138
|
+
expect(shared.chromeToolSpies.click_text).not.toHaveBeenCalled();
|
|
139
|
+
expect(shared.chromeToolSpies.get_dom).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('executes a tool call, injects DOM, then finishes on next turn', async () => {
|
|
143
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
144
|
+
message: {
|
|
145
|
+
role: 'assistant',
|
|
146
|
+
content: '',
|
|
147
|
+
tool_calls: [
|
|
148
|
+
{
|
|
149
|
+
id: 'tool_1',
|
|
150
|
+
type: 'function',
|
|
151
|
+
function: { name: 'click_text', arguments: JSON.stringify({ text: 'Hello' }) },
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
usage: { total_tokens: 10 },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
shared.chatMock.mockResolvedValueOnce({
|
|
159
|
+
message: { role: 'assistant', content: 'FINAL: done' },
|
|
160
|
+
usage: { total_tokens: 5 },
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const messages = baseMessages();
|
|
164
|
+
const res = await turnLoop(browser, messages, 2, 0, 0, {}, { steps: [], missionName: 'demo' });
|
|
165
|
+
|
|
166
|
+
expect(res).toBeTruthy();
|
|
167
|
+
expect(res.steps.length).toBe(2);
|
|
168
|
+
|
|
169
|
+
const step1 = res.steps[0];
|
|
170
|
+
expect(step1.result).toBe('✅ Passed');
|
|
171
|
+
expect(shared.chromeToolSpies.click_text).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(shared.chromeToolSpies.get_dom).toHaveBeenCalled();
|
|
173
|
+
|
|
174
|
+
const step2 = res.steps[1];
|
|
175
|
+
expect(step2.result).toMatch(/Success/);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// tests/llmTests/geminiProvider.test.js
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Hoisted shared so the mock factory can access/record state
|
|
5
|
+
const { shared } = vi.hoisted(() => ({
|
|
6
|
+
shared: {
|
|
7
|
+
apiKey: null,
|
|
8
|
+
getModelOpts: null,
|
|
9
|
+
lastContents: null,
|
|
10
|
+
// default response generator; tests can override
|
|
11
|
+
generateResponse: () => ({
|
|
12
|
+
response: {
|
|
13
|
+
candidates: [
|
|
14
|
+
{ content: { parts: [{ text: 'hello' }] } }
|
|
15
|
+
],
|
|
16
|
+
usageMetadata: { totalTokenCount: 123 },
|
|
17
|
+
},
|
|
18
|
+
}),
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
// Mock the Google SDK
|
|
23
|
+
vi.mock('@google/generative-ai', () => {
|
|
24
|
+
class FakeModel {
|
|
25
|
+
constructor(opts) { shared.getModelOpts = opts; }
|
|
26
|
+
async generateContent({ contents }) {
|
|
27
|
+
shared.lastContents = contents;
|
|
28
|
+
return shared.generateResponse();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
class GoogleGenerativeAI {
|
|
32
|
+
constructor(key) { shared.apiKey = key; }
|
|
33
|
+
getGenerativeModel(opts) { shared.getModelOpts = opts; return new FakeModel(opts); }
|
|
34
|
+
}
|
|
35
|
+
return { GoogleGenerativeAI };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Import SUT after mocks
|
|
39
|
+
import { GeminiProvider } from '../../llm/gemini/geminiProvider.js';
|
|
40
|
+
|
|
41
|
+
describe('GeminiProvider', () => {
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
shared.apiKey = null;
|
|
44
|
+
shared.getModelOpts = null;
|
|
45
|
+
shared.lastContents = null;
|
|
46
|
+
// reset default response
|
|
47
|
+
shared.generateResponse = () => ({
|
|
48
|
+
response: {
|
|
49
|
+
candidates: [{ content: { parts: [{ text: 'hello' }] } }],
|
|
50
|
+
usageMetadata: { totalTokenCount: 123 },
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws if constructed without apiKey', () => {
|
|
56
|
+
expect(() => new GeminiProvider({})).toThrow(/GEMINI_API_KEY/i);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('passes apiKey and model/tools into the SDK correctly', async () => {
|
|
60
|
+
const prov = new GeminiProvider({ apiKey: 'gk-123' });
|
|
61
|
+
|
|
62
|
+
const tools = [
|
|
63
|
+
{ type: 'function', function: { name: 'foo', description: 'Foo', parameters: { type: 'object' } } },
|
|
64
|
+
{ type: 'function', function: { name: 'bar', parameters: { type: 'object', properties: { x: { type: 'string' } } } } },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
await prov.chat({
|
|
68
|
+
model: 'gemini-2.5-flash',
|
|
69
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
70
|
+
tools,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(shared.apiKey).toBe('gk-123');
|
|
74
|
+
expect(shared.getModelOpts?.model).toBe('gemini-2.5-flash');
|
|
75
|
+
// Tools converted to Gemini functionDeclarations wrapper
|
|
76
|
+
expect(Array.isArray(shared.getModelOpts?.tools)).toBe(true);
|
|
77
|
+
expect(shared.getModelOpts.tools[0].functionDeclarations.map(d => d.name)).toEqual(['foo', 'bar']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('converts OpenAI-like messages → Gemini contents (system prefix + tool calls + tool results)', async () => {
|
|
81
|
+
const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
|
|
82
|
+
|
|
83
|
+
// Build a conversation:
|
|
84
|
+
// - system
|
|
85
|
+
// - user (should get system prefix injected)
|
|
86
|
+
// - assistant with tool_calls
|
|
87
|
+
// - tool result
|
|
88
|
+
// - assistant normal text
|
|
89
|
+
const messages = [
|
|
90
|
+
{ role: 'system', content: 'You are helpful.' },
|
|
91
|
+
{ role: 'user', content: 'search for x' },
|
|
92
|
+
{
|
|
93
|
+
role: 'assistant',
|
|
94
|
+
tool_calls: [
|
|
95
|
+
{ id: '1', type: 'function', function: { name: 'search', arguments: JSON.stringify({ q: 'x' }) } },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
role: 'tool',
|
|
100
|
+
tool_call_id: '1',
|
|
101
|
+
name: 'search',
|
|
102
|
+
type: 'function',
|
|
103
|
+
content: JSON.stringify({ results: [1, 2, 3] }),
|
|
104
|
+
},
|
|
105
|
+
{ role: 'assistant', content: 'Here are results.' },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
await prov.chat({ model: 'gemini-2.5-pro', messages });
|
|
109
|
+
|
|
110
|
+
const c = shared.lastContents;
|
|
111
|
+
expect(Array.isArray(c)).toBe(true);
|
|
112
|
+
|
|
113
|
+
// 1) First item: user with injected system prefix
|
|
114
|
+
expect(c[0].role).toBe('user');
|
|
115
|
+
expect(c[0].parts[0].text).toMatch(/^\[system]\nYou are helpful\./);
|
|
116
|
+
expect(c[0].parts[1].text).toBe('search for x');
|
|
117
|
+
|
|
118
|
+
// 2) Assistant tool call → model role with functionCall
|
|
119
|
+
expect(c[1].role).toBe('model');
|
|
120
|
+
expect(c[1].parts[0].functionCall).toEqual({ name: 'search', args: { q: 'x' } });
|
|
121
|
+
|
|
122
|
+
// 3) Tool result → user role with JSON marker
|
|
123
|
+
expect(c[2].role).toBe('user');
|
|
124
|
+
const toolJson = JSON.parse(c[2].parts[0].text);
|
|
125
|
+
expect(toolJson._tool_result).toBe(true);
|
|
126
|
+
expect(toolJson.tool_call_id).toBe('1');
|
|
127
|
+
expect(toolJson.name).toBe('search');
|
|
128
|
+
|
|
129
|
+
// 4) Final assistant text → model role with text
|
|
130
|
+
expect(c[3].role).toBe('model');
|
|
131
|
+
expect(c[3].parts[0].text).toBe('Here are results.');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('maps Gemini candidate parts → OpenAI-like assistant message and usage', async () => {
|
|
135
|
+
// Make the fake SDK return a functionCall + text
|
|
136
|
+
shared.generateResponse = () => ({
|
|
137
|
+
response: {
|
|
138
|
+
candidates: [
|
|
139
|
+
{
|
|
140
|
+
content: {
|
|
141
|
+
parts: [
|
|
142
|
+
{ functionCall: { name: 'open', args: { url: 'https://example.com' } } },
|
|
143
|
+
{ text: 'Ok!' },
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
usageMetadata: { totalTokenCount: 999, other: 'meta' },
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const prov = new GeminiProvider({ apiKey: 'gk-abc' });
|
|
153
|
+
const { message, usage } = await prov.chat({
|
|
154
|
+
model: 'gemini-2.5-flash',
|
|
155
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(message.role).toBe('assistant');
|
|
159
|
+
expect(message.content).toBe('Ok!');
|
|
160
|
+
expect(Array.isArray(message.tool_calls)).toBe(true);
|
|
161
|
+
expect(message.tool_calls[0].function.name).toBe('open');
|
|
162
|
+
expect(JSON.parse(message.tool_calls[0].function.arguments)).toEqual({ url: 'https://example.com' });
|
|
163
|
+
|
|
164
|
+
expect(usage.total_tokens).toBe(999);
|
|
165
|
+
expect(usage.providerRaw).toEqual({ totalTokenCount: 999, other: 'meta' });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('supports image parts (encoded to inlineData)', async () => {
|
|
169
|
+
const prov = new GeminiProvider({ apiKey: 'gk-xyz' });
|
|
170
|
+
|
|
171
|
+
const imageBytes = Buffer.from([1, 2, 3, 4]);
|
|
172
|
+
await prov.chat({
|
|
173
|
+
model: 'gemini-2.5-flash',
|
|
174
|
+
messages: [
|
|
175
|
+
{
|
|
176
|
+
role: 'user',
|
|
177
|
+
content: [
|
|
178
|
+
{ type: 'text', text: 'see image' },
|
|
179
|
+
{ type: 'image', mimeType: 'image/png', data: imageBytes },
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const c = shared.lastContents;
|
|
186
|
+
expect(c[0].role).toBe('user');
|
|
187
|
+
expect(c[0].parts[0].text).toBe('see image');
|
|
188
|
+
expect(c[0].parts[1].inlineData.mimeType).toBe('image/png');
|
|
189
|
+
expect(typeof c[0].parts[1].inlineData.data).toBe('string'); // base64
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// tests/llmTests/llmFactory.test.js
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Hoisted shared spies so mock factories can access them
|
|
5
|
+
const { shared } = vi.hoisted(() => ({
|
|
6
|
+
shared: {
|
|
7
|
+
openAICtor: vi.fn(),
|
|
8
|
+
geminiCtor: vi.fn(),
|
|
9
|
+
},
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
// Mock provider classes; constructors record received options
|
|
13
|
+
vi.mock('../../llm/openAI/openaiProvider.js', () => ({
|
|
14
|
+
OpenAIProvider: function (opts) {
|
|
15
|
+
shared.openAICtor(opts);
|
|
16
|
+
// minimal adapter surface used by callers
|
|
17
|
+
this.chat = vi.fn(async () => ({ message: { role: 'assistant', content: 'ok' }, usage: {} }));
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock('../../llm/gemini/geminiProvider.js', () => ({
|
|
22
|
+
GeminiProvider: function (opts) {
|
|
23
|
+
shared.geminiCtor(opts);
|
|
24
|
+
this.chat = vi.fn(async () => ({ message: { role: 'assistant', content: 'ok' }, usage: {} }));
|
|
25
|
+
},
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Import SUT after mocks
|
|
29
|
+
import { getLLM } from '../../llm/llmFactory.js';
|
|
30
|
+
|
|
31
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
32
|
+
|
|
33
|
+
describe('getLLM', () => {
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
process.env = { ...ORIGINAL_ENV };
|
|
36
|
+
shared.openAICtor.mockClear();
|
|
37
|
+
shared.geminiCtor.mockClear();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
process.env = { ...ORIGINAL_ENV };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('creates OpenAI provider (case-insensitive) and passes env key', () => {
|
|
45
|
+
process.env.OPENAI_API_KEY = 'sk-openai-123';
|
|
46
|
+
const llm = getLLM('OpEnAi');
|
|
47
|
+
expect(typeof llm.chat).toBe('function');
|
|
48
|
+
expect(shared.openAICtor).toHaveBeenCalledTimes(1);
|
|
49
|
+
expect(shared.openAICtor).toHaveBeenCalledWith(
|
|
50
|
+
expect.objectContaining({ apiKey: 'sk-openai-123' })
|
|
51
|
+
);
|
|
52
|
+
expect(shared.geminiCtor).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('creates Gemini provider and passes env key', () => {
|
|
56
|
+
process.env.GEMINI_API_KEY = 'gk-gemini-abc';
|
|
57
|
+
const llm = getLLM('gemini');
|
|
58
|
+
expect(typeof llm.chat).toBe('function');
|
|
59
|
+
expect(shared.geminiCtor).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(shared.geminiCtor).toHaveBeenCalledWith(
|
|
61
|
+
expect.objectContaining({ apiKey: 'gk-gemini-abc' })
|
|
62
|
+
);
|
|
63
|
+
expect(shared.openAICtor).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('merges opts after env so explicit opts.apiKey overrides env', () => {
|
|
67
|
+
process.env.OPENAI_API_KEY = 'sk-env-will-be-overridden';
|
|
68
|
+
const llm = getLLM('openai', { apiKey: 'sk-passed-in' });
|
|
69
|
+
expect(shared.openAICtor).toHaveBeenCalledWith(
|
|
70
|
+
expect.objectContaining({ apiKey: 'sk-passed-in' })
|
|
71
|
+
);
|
|
72
|
+
expect(typeof llm.chat).toBe('function');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('throws on unsupported provider', () => {
|
|
76
|
+
expect(() => getLLM('anthropic')).toThrow(/Unsupported LLM provider/i);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// tests/llmTests/modelResolver.test.js
|
|
2
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
|
|
7
|
+
import { resolveProviderModel } from '../../llm/modelResolver.js';
|
|
8
|
+
|
|
9
|
+
const ORIGINAL_ENV = { ...process.env };
|
|
10
|
+
const originalCwd = process.cwd();
|
|
11
|
+
|
|
12
|
+
function makeTempProject() {
|
|
13
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-'));
|
|
14
|
+
}
|
|
15
|
+
function writeConfig(dir, obj) {
|
|
16
|
+
fs.writeFileSync(path.join(dir, 'testronaut-config.json'), JSON.stringify(obj, null, 2), 'utf8');
|
|
17
|
+
}
|
|
18
|
+
function clearResolverEnv() {
|
|
19
|
+
delete process.env.TESTRONAUT_PROVIDER;
|
|
20
|
+
delete process.env.TESTRONAUT_MODEL;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
process.env = { ...ORIGINAL_ENV };
|
|
25
|
+
clearResolverEnv();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
process.env = { ...ORIGINAL_ENV };
|
|
30
|
+
process.chdir(originalCwd);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('resolveProviderModel', () => {
|
|
34
|
+
it('returns env provider/model when both set', () => {
|
|
35
|
+
process.env.TESTRONAUT_PROVIDER = 'gemini';
|
|
36
|
+
process.env.TESTRONAUT_MODEL = 'gemini-2.5-flash';
|
|
37
|
+
const res = resolveProviderModel();
|
|
38
|
+
expect(res).toEqual({ provider: 'gemini', model: 'gemini-2.5-flash' });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('reads provider/model from config file when present', () => {
|
|
42
|
+
const temp = makeTempProject();
|
|
43
|
+
writeConfig(temp, { provider: 'openai', model: 'gpt-5' });
|
|
44
|
+
|
|
45
|
+
const res = resolveProviderModel({ cwd: temp }); // 👈 pass cwd
|
|
46
|
+
expect(res.provider).toBe('openai');
|
|
47
|
+
expect(res.model).toBe('gpt-5');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('applies env override for only one value (env model overrides config model)', () => {
|
|
51
|
+
const temp = makeTempProject();
|
|
52
|
+
writeConfig(temp, { provider: 'openai', model: 'gpt-4o' });
|
|
53
|
+
process.env.TESTRONAUT_MODEL = 'gpt-5-mini';
|
|
54
|
+
|
|
55
|
+
const res = resolveProviderModel({ cwd: temp });
|
|
56
|
+
expect(res.provider).toBe('openai');
|
|
57
|
+
expect(res.model).toBe('gpt-5-mini');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('handles legacy config with model="openai"', () => {
|
|
61
|
+
const temp = makeTempProject();
|
|
62
|
+
writeConfig(temp, { model: 'openai' });
|
|
63
|
+
|
|
64
|
+
const res = resolveProviderModel({ cwd: temp });
|
|
65
|
+
expect(res).toEqual({ provider: 'openai', model: 'gpt-4o' });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns defaults when config missing', () => {
|
|
69
|
+
const temp = makeTempProject();
|
|
70
|
+
const res = resolveProviderModel({ cwd: temp });
|
|
71
|
+
expect(res).toEqual({ provider: 'openai', model: 'gpt-4o' });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns defaults when JSON parsing fails', () => {
|
|
75
|
+
const temp = makeTempProject();
|
|
76
|
+
fs.writeFileSync(path.join(temp, 'testronaut-config.json'), '{bad json', 'utf8');
|
|
77
|
+
|
|
78
|
+
const res = resolveProviderModel({ cwd: temp });
|
|
79
|
+
expect(res.provider).toBe('openai');
|
|
80
|
+
expect(res.model).toBe('gpt-4o');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('trims env vars and treats empty strings as absent', () => {
|
|
84
|
+
process.env.TESTRONAUT_PROVIDER = ' openai ';
|
|
85
|
+
process.env.TESTRONAUT_MODEL = ' gpt-5 ';
|
|
86
|
+
let res = resolveProviderModel();
|
|
87
|
+
expect(res).toEqual({ provider: 'openai', model: 'gpt-5' });
|
|
88
|
+
|
|
89
|
+
process.env.TESTRONAUT_PROVIDER = ' ';
|
|
90
|
+
process.env.TESTRONAUT_MODEL = ' ';
|
|
91
|
+
res = resolveProviderModel();
|
|
92
|
+
// falls back to defaults when env vars are just whitespace
|
|
93
|
+
expect(res).toEqual({ provider: 'openai', model: 'gpt-4o' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('prefers config when only provider is in env and model only in config', () => {
|
|
97
|
+
process.env.TESTRONAUT_PROVIDER = 'gemini';
|
|
98
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-'));
|
|
99
|
+
fs.writeFileSync(
|
|
100
|
+
path.join(temp, 'testronaut-config.json'),
|
|
101
|
+
JSON.stringify({ provider: 'openai', model: 'gemini-2.5-flash' }),
|
|
102
|
+
'utf8'
|
|
103
|
+
);
|
|
104
|
+
const res = resolveProviderModel({ cwd: temp });
|
|
105
|
+
expect(res).toEqual({ provider: 'gemini', model: 'gemini-2.5-flash' });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('ignores unrelated keys in config and still resolves', () => {
|
|
109
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'testronaut-'));
|
|
110
|
+
fs.writeFileSync(
|
|
111
|
+
path.join(temp, 'testronaut-config.json'),
|
|
112
|
+
JSON.stringify({ provider: 'openai', model: 'gpt-4o', extra: { foo: 1 } }),
|
|
113
|
+
'utf8'
|
|
114
|
+
);
|
|
115
|
+
const res = resolveProviderModel({ cwd: temp });
|
|
116
|
+
expect(res).toEqual({ provider: 'openai', model: 'gpt-4o' });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// tests/llmTests/openAIProvider.test.js
|
|
2
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
// Hoisted shared so the mock factory can access/record state
|
|
5
|
+
const { shared } = vi.hoisted(() => ({
|
|
6
|
+
shared: {
|
|
7
|
+
ctorOpts: null,
|
|
8
|
+
lastCreateArgs: null,
|
|
9
|
+
// default response; tests can override
|
|
10
|
+
responseFactory: () => ({
|
|
11
|
+
choices: [{ message: { role: 'assistant', content: 'hello' } }],
|
|
12
|
+
usage: { total_tokens: 321, prompt_tokens: 100, completion_tokens: 221 },
|
|
13
|
+
headers: { 'x-ratelimit-limit-tokens': '90000' },
|
|
14
|
+
}),
|
|
15
|
+
},
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
// Mock the OpenAI SDK surface we use
|
|
19
|
+
vi.mock('openai', () => {
|
|
20
|
+
class FakeChatCompletions {
|
|
21
|
+
async create(args) {
|
|
22
|
+
shared.lastCreateArgs = args;
|
|
23
|
+
return shared.responseFactory();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
class FakeChat {
|
|
27
|
+
constructor() {
|
|
28
|
+
this.completions = new FakeChatCompletions();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
class OpenAI {
|
|
32
|
+
constructor(opts) {
|
|
33
|
+
shared.ctorOpts = opts;
|
|
34
|
+
this.chat = new FakeChat();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return { default: OpenAI };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Import SUT after mocks
|
|
41
|
+
import { OpenAIProvider } from '../../llm/openAI/openaiProvider.js';
|
|
42
|
+
|
|
43
|
+
describe('OpenAIProvider', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
shared.ctorOpts = null;
|
|
46
|
+
shared.lastCreateArgs = null;
|
|
47
|
+
shared.responseFactory = () => ({
|
|
48
|
+
choices: [{ message: { role: 'assistant', content: 'hello' } }],
|
|
49
|
+
usage: { total_tokens: 321, prompt_tokens: 100, completion_tokens: 221 },
|
|
50
|
+
headers: { 'x-ratelimit-limit-tokens': '90000' },
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('throws without apiKey', () => {
|
|
55
|
+
expect(() => new OpenAIProvider({})).toThrow(/OPENAI_API_KEY/i);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('passes apiKey to OpenAI constructor', () => {
|
|
59
|
+
const p = new OpenAIProvider({ apiKey: 'sk-test-123' });
|
|
60
|
+
expect(shared.ctorOpts).toEqual({ apiKey: 'sk-test-123' });
|
|
61
|
+
expect(typeof p.chat).toBe('function');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('calls chat.completions.create with model/messages/tools', async () => {
|
|
65
|
+
const prov = new OpenAIProvider({ apiKey: 'sk-abc' });
|
|
66
|
+
const tools = [{ type: 'function', function: { name: 'doThing', parameters: { type: 'object' } } }];
|
|
67
|
+
|
|
68
|
+
await prov.chat({
|
|
69
|
+
model: 'gpt-4o',
|
|
70
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
71
|
+
tools,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(shared.lastCreateArgs).toEqual({
|
|
75
|
+
model: 'gpt-4o',
|
|
76
|
+
messages: [{ role: 'user', content: 'hi' }],
|
|
77
|
+
tools,
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns normalized message, usage, and headers', async () => {
|
|
82
|
+
const prov = new OpenAIProvider({ apiKey: 'sk-abc' });
|
|
83
|
+
const { message, usage, headers } = await prov.chat({
|
|
84
|
+
model: 'gpt-4o',
|
|
85
|
+
messages: [{ role: 'user', content: 'go' }],
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(message).toEqual({ role: 'assistant', content: 'hello' });
|
|
89
|
+
expect(usage.total_tokens).toBe(321);
|
|
90
|
+
expect(usage.providerRaw).toEqual({ total_tokens: 321, prompt_tokens: 100, completion_tokens: 221 });
|
|
91
|
+
expect(headers).toEqual({ 'x-ratelimit-limit-tokens': '90000' });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('handles SDK variants with headers on response.response.headers', async () => {
|
|
95
|
+
shared.responseFactory = () => ({
|
|
96
|
+
choices: [{ message: { role: 'assistant', content: 'ok' } }],
|
|
97
|
+
usage: { total_tokens: 11 },
|
|
98
|
+
response: { headers: { 'x-ratelimit-limit-tokens': '12345' } },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const prov = new OpenAIProvider({ apiKey: 'sk-abc' });
|
|
102
|
+
const { headers } = await prov.chat({ model: 'gpt-4o', messages: [] });
|
|
103
|
+
expect(headers).toEqual({ 'x-ratelimit-limit-tokens': '12345' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('provides a default assistant message when choices are empty', async () => {
|
|
107
|
+
shared.responseFactory = () => ({
|
|
108
|
+
choices: [],
|
|
109
|
+
usage: {},
|
|
110
|
+
headers: {},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const prov = new OpenAIProvider({ apiKey: 'sk-abc' });
|
|
114
|
+
const { message } = await prov.chat({ model: 'gpt-4o', messages: [] });
|
|
115
|
+
expect(message).toEqual({ role: 'assistant', content: '' });
|
|
116
|
+
});
|
|
117
|
+
});
|