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.
@@ -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
- console.log(`[model] → ${fnName}`, args);
191
- currentStep.events.push(`[model] → ${fnName} ${args}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "testronaut",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "main": "./runner/testronaut.js",
5
5
  "bin": {
6
6
  "testronaut": "./bin/cli.js"
@@ -1,37 +1,68 @@
1
1
  import { runAgent } from '../core/agent.js';
2
- import { runSuite } from './suiteRunner.js';
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
- console.log(`\n🚀 Mission: ${title}`);
8
- fn();
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 = normalizeToArray(preMission);
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', // legacy label
33
- submissionType: 'premission', // normalized
34
- submissionName: fn?.name || `pre-${i + 1}`,
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?.name || `mission-${i + 1}`,
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?.name || `post-${i + 1}`,
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 => `${g.label}: ${g.goal?.name ?? g.goal}`)
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
- // console.log("success[0]: ", success[0]);
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, '&amp;').replace(/</g, '&lt;')
15
15
  .replace(/>/g, '&gt;');
16
+ const escAttr = (s) => esc(s).replace(/"/g, '&quot;'); // 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; }