testronaut 1.1.0 → 1.1.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/core/redaction.js +79 -0
- package/core/turnIntent.js +64 -0
- package/core/turnLoop.js +10 -2
- package/package.json +1 -1
- package/runner/testronaut.js +59 -26
- package/tools/generateHtmlReport.js +13 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// core/redaction.js
|
|
2
|
+
|
|
3
|
+
function clone(obj) {
|
|
4
|
+
try { return structuredClone(obj); } catch { return JSON.parse(JSON.stringify(obj ?? {})); }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function maskPreview(val, showLength = true) {
|
|
8
|
+
const s = String(val ?? '');
|
|
9
|
+
if (!s) return '••••••';
|
|
10
|
+
// Show length for debugging without leaking the value
|
|
11
|
+
return showLength ? `•••••• (${s.length})` : '••••••';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasPasswordHint(str = '') {
|
|
15
|
+
return /\b(pass(word)?|pwd|passwd)\b/i.test(String(str || ''));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isPasswordCall(fnName, args = {}) {
|
|
19
|
+
if (!(fnName === 'type' || fnName === 'fill')) return false;
|
|
20
|
+
const { selector, label, placeholder, name, inputType, role, testId } = args;
|
|
21
|
+
return (
|
|
22
|
+
hasPasswordHint(selector) ||
|
|
23
|
+
hasPasswordHint(label) ||
|
|
24
|
+
hasPasswordHint(placeholder) ||
|
|
25
|
+
hasPasswordHint(name) ||
|
|
26
|
+
String(inputType || '').toLowerCase() === 'password' ||
|
|
27
|
+
hasPasswordHint(role) ||
|
|
28
|
+
hasPasswordHint(testId)
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Redact ONLY password values in tool args:
|
|
34
|
+
* - Applies to type/fill on password-looking fields
|
|
35
|
+
* - Also masks any arg property explicitly named password/pwd/passwd (shallow)
|
|
36
|
+
*/
|
|
37
|
+
export function redactArgs(fnName, args = {}, { showLength = true } = {}) {
|
|
38
|
+
const out = clone(args);
|
|
39
|
+
|
|
40
|
+
// Mask direct props named password/pwd/passwd if present
|
|
41
|
+
for (const k of Object.keys(out || {})) {
|
|
42
|
+
if (hasPasswordHint(k)) {
|
|
43
|
+
out[k] = maskPreview(out[k], showLength);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// For typing into a password field, mask the value
|
|
48
|
+
if (isPasswordCall(fnName, args)) {
|
|
49
|
+
if ('text' in out) out.text = maskPreview(out.text, showLength);
|
|
50
|
+
if ('value' in out) out.value = maskPreview(out.value, showLength);
|
|
51
|
+
if ('input' in out) out.input = maskPreview(out.input, showLength);
|
|
52
|
+
if ('keys' in out) out.keys = maskPreview(out.keys, showLength);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Redact password literals in freeform mission text.
|
|
60
|
+
* Heuristic: look for the word "password" then the next quoted string OR
|
|
61
|
+
* a token after "with|as|to|=" within ~80 chars.
|
|
62
|
+
*/
|
|
63
|
+
export function redactPasswordInText(text = '', { showLength = true } = {}) {
|
|
64
|
+
let s = String(text ?? '');
|
|
65
|
+
|
|
66
|
+
// Case 1: password ... "VALUE"
|
|
67
|
+
s = s.replace(
|
|
68
|
+
/(\bpassword\b[^"'\\\n]{0,80}["'])([^"']+)(["'])/gi,
|
|
69
|
+
(_m, pre, pwd, post) => pre + maskPreview(pwd, showLength) + post
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Case 2: password ... (with|as|to|=) VALUE
|
|
73
|
+
s = s.replace(
|
|
74
|
+
/(\bpassword\b[^.\n]{0,80}?\b(with|as|to|=)\s*)([^\s"'`]+)/gi,
|
|
75
|
+
(_m, pre, _kw, pwd) => pre + maskPreview(pwd, showLength)
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return s;
|
|
79
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { redactArgs } from './redaction.js';
|
|
2
|
+
|
|
3
|
+
export function truncateMiddle(str = '', max = 80) {
|
|
4
|
+
if (!str || str.length <= max) return str;
|
|
5
|
+
const half = Math.floor((max - 1) / 2);
|
|
6
|
+
return `${str.slice(0, half)}…${str.slice(-half)}`;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function pickArg(args, keys) {
|
|
10
|
+
for (const k of keys) {
|
|
11
|
+
if (args?.[k] != null && String(args[k]).trim() !== '') return String(args[k]);
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function summarizeToolCall(fnName, args = {}) {
|
|
17
|
+
const safeArgs = redactArgs(fnName, args);
|
|
18
|
+
|
|
19
|
+
const selector = pickArg(safeArgs, ['selector','xpath','css','role','testId']);
|
|
20
|
+
const text = pickArg(safeArgs, ['text','value','keys','input','query','label']);
|
|
21
|
+
const url = pickArg(safeArgs, ['url','href','to']);
|
|
22
|
+
const file = pickArg(safeArgs, ['filePath','fileName','downloadPath']);
|
|
23
|
+
const x = pickArg(safeArgs, ['x']);
|
|
24
|
+
const y = pickArg(safeArgs, ['y']);
|
|
25
|
+
const timeout = pickArg(safeArgs, ['timeoutMs','timeout']);
|
|
26
|
+
|
|
27
|
+
const sel = selector ? ` (${truncateMiddle(selector, 60)})` : '';
|
|
28
|
+
const val = text ? ` "${truncateMiddle(text, 60)}"` : '';
|
|
29
|
+
const tmo = timeout ? ` [≤${timeout}ms]` : '';
|
|
30
|
+
|
|
31
|
+
switch (fnName) {
|
|
32
|
+
case 'navigate': return `Navigate to ${truncateMiddle(url || '(no url)', 80)}`;
|
|
33
|
+
case 'click_text': return `Click text${val}`;
|
|
34
|
+
case 'click': return `Click element${sel}`;
|
|
35
|
+
case 'type':
|
|
36
|
+
case 'fill': return `Type${val}${selector ? ` into ${truncateMiddle(selector, 60)}` : ''}`;
|
|
37
|
+
case 'press': return `Press key${val || ''}${selector ? ` in ${truncateMiddle(selector, 60)}` : ''}`;
|
|
38
|
+
case 'hover': return `Hover over${sel || val || ''}`;
|
|
39
|
+
case 'wait_for_selector': return `Wait for${sel}${tmo}`;
|
|
40
|
+
case 'wait': return `Wait${tmo || ''}`;
|
|
41
|
+
case 'screenshot': return `Capture screenshot${selector ? ` of ${truncateMiddle(selector, 60)}` : ''}`;
|
|
42
|
+
case 'get_dom': return `Inspect DOM (focused extract)`;
|
|
43
|
+
case 'check_text': return `Check page for${val || ' specific text'}`;
|
|
44
|
+
case 'scroll': return `Scroll to coords (${x ?? '?'}, ${y ?? '?'})`;
|
|
45
|
+
case 'upload_file': return `Upload file ${truncateMiddle(file || '(unknown)', 60)}${selector ? ` via ${truncateMiddle(selector, 60)}` : ''}`;
|
|
46
|
+
case 'download_file': return `Download file to ${truncateMiddle(file || '(unknown)', 60)}`;
|
|
47
|
+
case 'expand_menu': return `Expand menu${sel || ''}`;
|
|
48
|
+
case 'set_viewport': return `Set viewport ${safeArgs?.width}×${safeArgs?.height}`;
|
|
49
|
+
default:
|
|
50
|
+
return `Run ${fnName} with ${truncateMiddle(JSON.stringify(safeArgs ?? {}), 80)}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function summarizeTurnIntentFromMessage(msg) {
|
|
55
|
+
try {
|
|
56
|
+
if (msg?.tool_calls?.length) {
|
|
57
|
+
return msg.tool_calls
|
|
58
|
+
.map(c => summarizeToolCall(c.function?.name || 'unknown', JSON.parse(c.function?.arguments || '{}')))
|
|
59
|
+
.join(' → ');
|
|
60
|
+
}
|
|
61
|
+
if (msg?.content) return `Reasoning: ${truncateMiddle(msg.content, 120)}`;
|
|
62
|
+
} catch {}
|
|
63
|
+
return 'No intent detected';
|
|
64
|
+
}
|
package/core/turnLoop.js
CHANGED
|
@@ -16,6 +16,8 @@ import {
|
|
|
16
16
|
updateLimitsFromHeaders
|
|
17
17
|
} from '../tools/tokenControl.js';
|
|
18
18
|
import { resolveModel } from '../openAI/modelResolver.js';
|
|
19
|
+
import { summarizeTurnIntentFromMessage } from './turnIntent.js';
|
|
20
|
+
import { redactArgs } from './redaction.js';
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
const MODEL_ID = resolveModel();
|
|
@@ -178,6 +180,11 @@ export const turnLoop = async (
|
|
|
178
180
|
|
|
179
181
|
const msg = response.choices[0].message;
|
|
180
182
|
|
|
183
|
+
const planLine = summarizeTurnIntentFromMessage(msg);
|
|
184
|
+
currentStep.summary = planLine;
|
|
185
|
+
currentStep.events.unshift(`📝 Plan: ${planLine}`);
|
|
186
|
+
console.log(`📝 Plan: ${planLine}`);
|
|
187
|
+
|
|
181
188
|
if (msg.tool_calls?.length) {
|
|
182
189
|
console.log('Processing tool calls...');
|
|
183
190
|
const toolResponses = [];
|
|
@@ -187,8 +194,9 @@ export const turnLoop = async (
|
|
|
187
194
|
const fnName = call.function.name;
|
|
188
195
|
lastToolName = fnName;
|
|
189
196
|
const args = JSON.parse(call.function.arguments || '{}');
|
|
190
|
-
|
|
191
|
-
|
|
197
|
+
const safeArgs = redactArgs(fnName, args);
|
|
198
|
+
console.log(`[model] → ${fnName}`, safeArgs);
|
|
199
|
+
currentStep.events.push(`[model] → ${fnName} ${safeArgs}`);
|
|
192
200
|
// console.log('Calling tool:', fnName);
|
|
193
201
|
let result;
|
|
194
202
|
let errorMessage = null;
|
package/package.json
CHANGED
package/runner/testronaut.js
CHANGED
|
@@ -1,37 +1,68 @@
|
|
|
1
1
|
import { runAgent } from '../core/agent.js';
|
|
2
|
-
import {
|
|
2
|
+
import { redactPasswordInText } from '../core/redaction.js';
|
|
3
|
+
// import { runSuite } from './suiteRunner.js';
|
|
3
4
|
|
|
4
|
-
const objectives = [];
|
|
5
|
+
// const objectives = [];
|
|
5
6
|
|
|
6
|
-
export function mission(title, fn) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
7
|
+
// export function mission(title, fn) {
|
|
8
|
+
// console.log(`\n🚀 Mission: ${title}`);
|
|
9
|
+
// fn();
|
|
10
|
+
// }
|
|
11
|
+
|
|
12
|
+
// export function objective(desc, workflow) {
|
|
13
|
+
// objectives.push({ name: desc, workflow });
|
|
14
|
+
// }
|
|
15
|
+
|
|
16
|
+
// export async function launch(preMissionSetup) {
|
|
17
|
+
// await runSuite(objectives, preMissionSetup);
|
|
18
|
+
// }
|
|
10
19
|
|
|
11
|
-
export function objective(desc, workflow) {
|
|
12
|
-
objectives.push({ name: desc, workflow });
|
|
13
|
-
}
|
|
14
20
|
|
|
15
|
-
export async function launch(preMissionSetup) {
|
|
16
|
-
await runSuite(objectives, preMissionSetup);
|
|
17
|
-
}
|
|
18
21
|
|
|
19
22
|
export async function runMissions({ preMission, mission, postMission }, missionName) {
|
|
20
23
|
const normalizeToArray = (x) => (Array.isArray(x) ? x : x ? [x] : []);
|
|
21
|
-
|
|
22
|
-
const pre
|
|
24
|
+
|
|
25
|
+
const pre = normalizeToArray(preMission);
|
|
23
26
|
const main = normalizeToArray(mission);
|
|
24
27
|
const post = normalizeToArray(postMission);
|
|
25
28
|
|
|
26
29
|
const goals = [];
|
|
27
30
|
|
|
31
|
+
const pickName = (kind, fn, i) => {
|
|
32
|
+
const baseFallback =
|
|
33
|
+
kind === 'premission' ? `pre-${i + 1}` :
|
|
34
|
+
kind === 'postmission' ? `post-${i + 1}` :
|
|
35
|
+
`mission-${i + 1}`;
|
|
36
|
+
|
|
37
|
+
// MAIN mission: prefer the missionName argument
|
|
38
|
+
if (kind === 'mission') {
|
|
39
|
+
if (missionName) {
|
|
40
|
+
// if multiple main items, disambiguate nicely
|
|
41
|
+
const suffix = main.length > 1 ? ` (${i + 1}/${main.length})` : '';
|
|
42
|
+
return `${missionName}${suffix}`;
|
|
43
|
+
}
|
|
44
|
+
// no missionName: if mission is a string, derive from its first non-empty line
|
|
45
|
+
if (typeof fn === 'string') {
|
|
46
|
+
const firstLine = (fn.trim().split('\n').find(Boolean) || fn.trim()).slice(0, 60);
|
|
47
|
+
return firstLine.length === 60 ? `${firstLine}…` : firstLine || baseFallback;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// PRE/POST (and fallback for mission): prefer author-provided labels on the function
|
|
52
|
+
const explicit =
|
|
53
|
+
fn?.submissionName || fn?.displayName || fn?.title || fn?.name;
|
|
54
|
+
if (explicit && String(explicit).trim()) return String(explicit).trim();
|
|
55
|
+
|
|
56
|
+
return baseFallback;
|
|
57
|
+
};
|
|
58
|
+
|
|
28
59
|
if (pre.length) {
|
|
29
60
|
goals.push(
|
|
30
61
|
...pre.map((fn, i) => ({
|
|
31
62
|
goal: fn,
|
|
32
|
-
label: 'pre-mission',
|
|
33
|
-
submissionType: 'premission',
|
|
34
|
-
submissionName: fn
|
|
63
|
+
label: 'pre-mission', // legacy label (kept for logs)
|
|
64
|
+
submissionType: 'premission', // normalized
|
|
65
|
+
submissionName: pickName('premission', fn, i),
|
|
35
66
|
}))
|
|
36
67
|
);
|
|
37
68
|
}
|
|
@@ -42,7 +73,7 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
42
73
|
goal: fn,
|
|
43
74
|
label: 'mission',
|
|
44
75
|
submissionType: 'mission',
|
|
45
|
-
submissionName: fn
|
|
76
|
+
submissionName: pickName('mission', fn, i),
|
|
46
77
|
}))
|
|
47
78
|
);
|
|
48
79
|
}
|
|
@@ -53,28 +84,30 @@ export async function runMissions({ preMission, mission, postMission }, missionN
|
|
|
53
84
|
goal: fn,
|
|
54
85
|
label: 'post-mission',
|
|
55
86
|
submissionType: 'postmission',
|
|
56
|
-
submissionName: fn
|
|
87
|
+
submissionName: pickName('postmission', fn, i),
|
|
57
88
|
}))
|
|
58
89
|
);
|
|
59
90
|
}
|
|
60
91
|
|
|
61
92
|
console.log(
|
|
62
93
|
'\n🧭 Running mission flow:\n',
|
|
63
|
-
goals.map(g =>
|
|
94
|
+
goals.map(g => {
|
|
95
|
+
if (typeof g.goal === 'string') {
|
|
96
|
+
// redact only the password literal in the human-readable mission text
|
|
97
|
+
return `${g.label}: ${redactPasswordInText(g.goal)}`;
|
|
98
|
+
}
|
|
99
|
+
return `${g.label}: ${g.goal?.name || g.submissionName || g.goal}`;
|
|
100
|
+
})
|
|
64
101
|
);
|
|
65
|
-
|
|
102
|
+
|
|
66
103
|
const success = await runAgent(goals, missionName);
|
|
67
104
|
if (!success) {
|
|
68
105
|
console.log(`❌ Aborting after failed goal.`);
|
|
69
106
|
return;
|
|
70
107
|
}
|
|
71
|
-
|
|
72
|
-
// console.log("success[0].steps.length: ", success[0].steps.length);
|
|
73
|
-
// console.log("success[0].steps[success[0].steps.length - 1].result: ", success[0].steps[success[0].steps.length - 1].result)
|
|
108
|
+
|
|
74
109
|
const missionStatus = success[0].steps[success[0].steps.length - 1].result;
|
|
75
|
-
// console.log("missionStatus: ", missionStatus)
|
|
76
110
|
if (missionStatus.toLowerCase().includes('failure')) {
|
|
77
|
-
// console.log("we detected the failure!")
|
|
78
111
|
success[0].status = 'failed';
|
|
79
112
|
}
|
|
80
113
|
console.log('\n✅ Mission flow complete.');
|
|
@@ -13,6 +13,7 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
13
13
|
const esc = (s) => String(s ?? '')
|
|
14
14
|
.replace(/&/g, '&').replace(/</g, '<')
|
|
15
15
|
.replace(/>/g, '>');
|
|
16
|
+
const escAttr = (s) => esc(s).replace(/"/g, '"'); // for HTML attributes
|
|
16
17
|
const badge = (status) =>
|
|
17
18
|
status === 'passed' ? '✅ Passed' :
|
|
18
19
|
status === 'failed' ? '❌ Failed' : (status || '—');
|
|
@@ -31,10 +32,18 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
31
32
|
? `<img src="${esc(step.screenshotPath)}" alt="screenshot turn ${esc(step.turn ?? idx)}">`
|
|
32
33
|
: '';
|
|
33
34
|
|
|
35
|
+
const plan = (typeof step.summary === 'string' && step.summary.trim())
|
|
36
|
+
? step.summary.trim()
|
|
37
|
+
: '';
|
|
38
|
+
const planSpan = plan
|
|
39
|
+
? `<span class="plan" title="${escAttr(plan)}">${esc(plan)}</span>`
|
|
40
|
+
: '';
|
|
41
|
+
|
|
34
42
|
return `
|
|
35
43
|
<details class="step" ${ok ? '' : 'open'}>
|
|
36
44
|
<summary>
|
|
37
45
|
<span class="turn">Turn ${esc((step.turn ?? idx) + 1)}</span>
|
|
46
|
+
${planSpan}
|
|
38
47
|
<span class="step-result ${ok ? 'ok' : 'bad'}">${esc(step.result || '—')}</span>
|
|
39
48
|
<span class="tokens">tokens: ${esc(step.tokensUsed ?? '—')} / total: ${esc(step.totalTokensUsed ?? '—')}</span>
|
|
40
49
|
</summary>
|
|
@@ -210,6 +219,10 @@ export function generateHtmlReport(report, outputPath) {
|
|
|
210
219
|
.step summary:hover{ background: rgba(255,255,255,.08); }
|
|
211
220
|
|
|
212
221
|
.turn{ font-weight:700; }
|
|
222
|
+
.plan{
|
|
223
|
+
flex:1; color: var(--text-muted); font-size:12px;
|
|
224
|
+
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
|
|
225
|
+
}
|
|
213
226
|
.step-result.ok{ color: var(--ok); font-weight:700; }
|
|
214
227
|
.step-result.bad{ color: var(--bad); font-weight:700; }
|
|
215
228
|
.tokens{ color: var(--text-muted); font-size:12px; margin-left:auto; font-variant-numeric: tabular-nums; }
|