testronaut 1.1.2 → 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/turnLoop.js +170 -107
- 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/tokenControl.js +107 -17
- package/vitest.config.js +8 -0
- package/openAI/modelResolver.js +0 -33
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|