testronaut 1.1.2 → 1.2.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.
- 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/redaction.js +161 -33
- 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/redation.test.js +118 -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.1
|
|
3
|
+
"version": "1.2.1",
|
|
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,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* redaction.test.js
|
|
3
|
+
* -----------------
|
|
4
|
+
* Purpose:
|
|
5
|
+
* Vitest unit tests for redaction utilities:
|
|
6
|
+
* - Centralized keyword usage
|
|
7
|
+
* - Tool arg masking (selector & key-name based)
|
|
8
|
+
* - Free-text masking for plans/instructions
|
|
9
|
+
*
|
|
10
|
+
* Test runner:
|
|
11
|
+
* Vitest (https://vitest.dev/)
|
|
12
|
+
*
|
|
13
|
+
* Location:
|
|
14
|
+
* tests/core/redaction.test.js
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect } from 'vitest';
|
|
18
|
+
import {
|
|
19
|
+
SENSITIVE_KEYWORDS,
|
|
20
|
+
maskPreview,
|
|
21
|
+
redactArgs,
|
|
22
|
+
redactPasswordInText,
|
|
23
|
+
} from '../../core/redaction.js';
|
|
24
|
+
|
|
25
|
+
describe('redaction utilities', () => {
|
|
26
|
+
describe('SENSITIVE_KEYWORDS (centralized list)', () => {
|
|
27
|
+
it('contains expected baseline keywords', () => {
|
|
28
|
+
const expected = ['password', 'token', 'access', 'api-key', 'auth'];
|
|
29
|
+
for (const k of expected) {
|
|
30
|
+
expect(SENSITIVE_KEYWORDS).toContain(k);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('maskPreview', () => {
|
|
36
|
+
it('masks and includes length by default', () => {
|
|
37
|
+
expect(maskPreview('hunter2')).toBe('•••••• (7)');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('masks empty-ish values without length', () => {
|
|
41
|
+
expect(maskPreview('')).toBe('••••••');
|
|
42
|
+
expect(maskPreview(null)).toBe('••••••');
|
|
43
|
+
expect(maskPreview(undefined)).toBe('••••••');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('respects showLength=false', () => {
|
|
47
|
+
expect(maskPreview('abcdef', false)).toBe('••••••');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('redactArgs', () => {
|
|
52
|
+
it('masks when selector contains #access', () => {
|
|
53
|
+
const args = { selector: '#access', text: 'Imp0st3r123!' };
|
|
54
|
+
const redacted = redactArgs('fill', args);
|
|
55
|
+
expect(redacted.text).toMatch(/^•••••• \(\d+\)$/);
|
|
56
|
+
expect(redacted.selector).toBe('#access'); // selector remains visible
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('masks when inputType is password', () => {
|
|
60
|
+
const args = { selector: '#login', inputType: 'password', value: 'superSecret' };
|
|
61
|
+
const redacted = redactArgs('type', args);
|
|
62
|
+
expect(redacted.value).toBe('•••••• (11)');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('masks values by sensitive arg key names (e.g., apiKey, token)', () => {
|
|
66
|
+
const args = { apiKey: 'sk-live-abc', token: 'abcd1234', other: 'notSecret' };
|
|
67
|
+
const redacted = redactArgs('noop', args); // fnName not type/fill still masks by key name
|
|
68
|
+
expect(redacted.apiKey).toBe('•••••• (11)');
|
|
69
|
+
expect(redacted.token).toBe('•••••• (8)');
|
|
70
|
+
expect(redacted.other).toBe('notSecret');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('does not mask non-sensitive calls', () => {
|
|
74
|
+
const args = { selector: '#search', text: 'hello world' };
|
|
75
|
+
const redacted = redactArgs('fill', args);
|
|
76
|
+
expect(redacted.text).toBe('hello world');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe('redactPasswordInText', () => {
|
|
81
|
+
it('Case 1: keyword ... "VALUE"', () => {
|
|
82
|
+
const input = `My password is "hunter2".`;
|
|
83
|
+
const out = redactPasswordInText(input);
|
|
84
|
+
expect(out).toContain(`password is "•••••• (7)"`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('Case 2: keyword ... (with|as|to|=) VALUE', () => {
|
|
88
|
+
const input = `token=abcd1234 and auth with xyz`;
|
|
89
|
+
const out = redactPasswordInText(input);
|
|
90
|
+
expect(out).toContain(`token=•••••• (8)`);
|
|
91
|
+
expect(out).toContain(`auth with •••••• (3)`);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('Case 3: "VALUE" into sensitive target (#access)', () => {
|
|
95
|
+
const input = `Type "Imp0st3r123!" into #access`;
|
|
96
|
+
const out = redactPasswordInText(input);
|
|
97
|
+
expect(out).toBe(`Type "•••••• (12)" into #access`);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('non-sensitive text is unchanged', () => {
|
|
101
|
+
const input = `Click the button and type "hello" into #search`;
|
|
102
|
+
const out = redactPasswordInText(input);
|
|
103
|
+
expect(out).toBe(input);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('handles api key forms with spaces/underscores/hyphens', () => {
|
|
107
|
+
const cases = [
|
|
108
|
+
`api-key "abcDEF123"`,
|
|
109
|
+
`api key "abcDEF123"`,
|
|
110
|
+
`api_key "abcDEF123"`,
|
|
111
|
+
];
|
|
112
|
+
for (const text of cases) {
|
|
113
|
+
const out = redactPasswordInText(text);
|
|
114
|
+
expect(out).toContain(`"•••••• (9)"`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
});
|