testronaut 1.1.0 → 1.1.2

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,95 @@
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
+ // core/turnIntent.js
17
+ const ICONS = {
18
+ navigate: '🧭',
19
+ click_text: '🖱️',
20
+ click: '🖱️',
21
+ type: '⌨️',
22
+ fill: '⌨️',
23
+ press: '⌨️',
24
+ hover: '🪄',
25
+ wait_for_selector: '⏳',
26
+ wait: '⏳',
27
+ screenshot: '📸',
28
+ get_dom: '🧩',
29
+ check_text: '🔍',
30
+ scroll: '↕️',
31
+ upload_file: '📤',
32
+ download_file: '📥',
33
+ expand_menu: '📂',
34
+ set_viewport: '🖥️',
35
+ default: '⚙️',
36
+ };
37
+
38
+ const EMOJI_ON_DEFAULT =
39
+ process.env.TESTRONAUT_EMOJI === '1' || process.env.TESTRONAUT_EMOJI === 'true';
40
+
41
+ const withIcon = (name, s, { emoji = EMOJI_ON_DEFAULT } = {}) =>
42
+ emoji ? `${ICONS[name] || ICONS.default} ${s}` : s;
43
+
44
+
45
+ export function summarizeToolCall(fnName, args = {}, opts = {}) {
46
+ const safeArgs = redactArgs(fnName, args);
47
+
48
+ const selector = pickArg(safeArgs, ['selector','xpath','css','role','testId']);
49
+ const text = pickArg(safeArgs, ['text','value','keys','input','query','label']);
50
+ const url = pickArg(safeArgs, ['url','href','to']);
51
+ const file = pickArg(safeArgs, ['filePath','fileName','downloadPath']);
52
+ const x = pickArg(safeArgs, ['x']);
53
+ const y = pickArg(safeArgs, ['y']);
54
+ const timeout = pickArg(safeArgs, ['timeoutMs','timeout']);
55
+
56
+ const sel = selector ? ` (${truncateMiddle(selector, 60)})` : '';
57
+ const val = text ? ` "${truncateMiddle(text, 60)}"` : '';
58
+ const tmo = timeout ? ` [≤${timeout}ms]` : '';
59
+
60
+ switch (fnName) {
61
+ case 'navigate': return withIcon('navigate', `Navigate to ${truncateMiddle(url || '(no url)', 80)}`, opts);
62
+ case 'click_text': return withIcon('click_text', `Click text${val}`, opts);
63
+ case 'click': return withIcon('click', `Click element${sel}`, opts);
64
+ case 'type':
65
+ case 'fill': return withIcon('type', `Type${val}${selector ? ` into ${truncateMiddle(selector, 60)}` : ''}`, opts);
66
+ case 'press': return withIcon('press', `Press key${val || ''}${selector ? ` in ${truncateMiddle(selector, 60)}` : ''}`, opts);
67
+ case 'hover': return withIcon('hover', `Hover over${sel || val || ''}`, opts);
68
+ case 'wait_for_selector':
69
+ return withIcon('wait_for_selector', `Wait for${sel}${tmo}`, opts);
70
+ case 'wait': return withIcon('wait', `Wait${tmo || ''}`, opts);
71
+ case 'screenshot': return withIcon('screenshot', `Capture screenshot${selector ? ` of ${truncateMiddle(selector, 60)}` : ''}`, opts);
72
+ case 'get_dom': return withIcon('get_dom', `Inspect DOM (focused extract)`, opts);
73
+ case 'check_text': return withIcon('check_text', `Check page for${val || ' specific text'}`, opts);
74
+ case 'scroll': return withIcon('scroll', `Scroll to coords (${x ?? '?'}, ${y ?? '?'})`, opts);
75
+ case 'upload_file': return withIcon('upload_file', `Upload file ${truncateMiddle(file || '(unknown)', 60)}${selector ? ` via ${truncateMiddle(selector, 60)}` : ''}`, opts);
76
+ case 'download_file':
77
+ return withIcon('download_file', `Download file to ${truncateMiddle(file || '(unknown)', 60)}`, opts);
78
+ case 'expand_menu': return withIcon('expand_menu', `Expand menu${sel || ''}`, opts);
79
+ case 'set_viewport': return withIcon('set_viewport', `Set viewport ${safeArgs?.width}×${safeArgs?.height}`, opts);
80
+ default:
81
+ return withIcon('default', `Run ${fnName} with ${truncateMiddle(JSON.stringify(safeArgs ?? {}), 80)}`, opts);
82
+ }
83
+ }
84
+
85
+ export function summarizeTurnIntentFromMessage(msg, opts = {}) {
86
+ try {
87
+ if (msg?.tool_calls?.length) {
88
+ return msg.tool_calls
89
+ .map(c => summarizeToolCall(c.function?.name || 'unknown', JSON.parse(c.function?.arguments || '{}'), opts))
90
+ .join(' → ');
91
+ }
92
+ if (msg?.content) return `Reasoning: ${truncateMiddle(msg.content, 120)}`;
93
+ } catch {}
94
+ return 'No intent detected';
95
+ }
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,15 @@ export const turnLoop = async (
178
180
 
179
181
  const msg = response.choices[0].message;
180
182
 
183
+ // Plain (no emoji) for storage:
184
+ const planPlain = summarizeTurnIntentFromMessage(msg, { emoji: true });
185
+ // Emoji (toggleable) for console/event log:
186
+ const planDisplay = summarizeTurnIntentFromMessage(msg, { /* emoji default via env */ });
187
+
188
+ currentStep.summary = planPlain; // stored in JSON
189
+ currentStep.events.unshift(`📝 Plan: ${planDisplay}`);
190
+ console.log(`📝 Plan: ${planDisplay}`);
191
+
181
192
  if (msg.tool_calls?.length) {
182
193
  console.log('Processing tool calls...');
183
194
  const toolResponses = [];
@@ -187,8 +198,9 @@ export const turnLoop = async (
187
198
  const fnName = call.function.name;
188
199
  lastToolName = fnName;
189
200
  const args = JSON.parse(call.function.arguments || '{}');
190
- console.log(`[model] → ${fnName}`, args);
191
- currentStep.events.push(`[model] → ${fnName} ${args}`);
201
+ const safeArgs = redactArgs(fnName, args);
202
+ console.log(`[model] → ${fnName}`, safeArgs);
203
+ currentStep.events.push(`[model] → ${fnName} ${safeArgs}`);
192
204
  // console.log('Calling tool:', fnName);
193
205
  let result;
194
206
  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.2",
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>
@@ -55,6 +64,9 @@ export function generateHtmlReport(report, outputPath) {
55
64
  <span class="name">${prettyTitle}</span>
56
65
  <span class="status ${statusClass}">${badge(m.status)}</span>
57
66
  <span class="meta">steps: ${steps.length} • duration: ${mDurationSec}s</span>
67
+ <span class="toolbar">
68
+ <button class="btn-mini toggle" data-scope="submission" aria-label="Expand">▼</button>
69
+ </span>
58
70
  </summary>
59
71
  <div class="steps">
60
72
  ${stepItems || '<div class="empty">No steps recorded.</div>'}
@@ -92,12 +104,16 @@ export function generateHtmlReport(report, outputPath) {
92
104
  <span class="name">${esc(missionName)}</span>
93
105
  <span class="status ${status === 'failed' ? 'bad' : 'ok'}">${badge(status)}</span>
94
106
  <span class="meta">submissions: ${subs.length} • steps: ${totalSteps} • duration: ${groupDur}s</span>
107
+ <span class="toolbar">
108
+ <button class="btn-mini toggle" data-scope="mission" aria-label="Expand">▼</button>
109
+ </span>
95
110
  </summary>
96
111
  <div class="group-body">
97
112
  ${subs.map(submissionBlock).join('')}
98
113
  </div>
99
114
  </details>
100
115
  `;
116
+
101
117
  };
102
118
 
103
119
  const groupsHtml = Object.entries(grouped).map(([name, subs]) => missionGroupBlock(name, subs)).join('');
@@ -190,6 +206,22 @@ export function generateHtmlReport(report, outputPath) {
190
206
  .mission-group > summary:hover{ background: rgba(255,255,255,.08); }
191
207
 
192
208
  .group-body{ padding:12px; display:grid; gap:10px; }
209
+ // .toolbar{display:flex; gap:8px; margin-left:auto;}
210
+ // .btn-mini{
211
+ // font-size:11px; padding:4px 8px; border-radius:8px;
212
+ // background: rgba(255,255,255,.06); color: var(--text);
213
+ // border:1px solid var(--hairline); cursor:pointer;
214
+ // }
215
+ // .btn-mini:hover{ background: rgba(255,255,255,.10); }
216
+
217
+ .toolbar{display:flex; gap:8px; margin-left:auto;}
218
+ .btn-mini{
219
+ font-size:12px; padding:2px 8px; border-radius:8px;
220
+ background: rgba(255,255,255,.06); color: var(--text);
221
+ border:1px solid var(--hairline); cursor:pointer; line-height:1.2;
222
+ width:28px; text-align:center;
223
+ }
224
+ .btn-mini:hover{ background: rgba(255,255,255,.10); }
193
225
 
194
226
  .mission-submission > summary{
195
227
  font-weight:700; background: rgba(255,255,255,.06); border-top:1px solid var(--hairline);
@@ -210,6 +242,10 @@ export function generateHtmlReport(report, outputPath) {
210
242
  .step summary:hover{ background: rgba(255,255,255,.08); }
211
243
 
212
244
  .turn{ font-weight:700; }
245
+ .plan{
246
+ flex:1; color: var(--text-muted); font-size:12px;
247
+ white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
248
+ }
213
249
  .step-result.ok{ color: var(--ok); font-weight:700; }
214
250
  .step-result.bad{ color: var(--bad); font-weight:700; }
215
251
  .tokens{ color: var(--text-muted); font-size:12px; margin-left:auto; font-variant-numeric: tabular-nums; }
@@ -255,6 +291,66 @@ export function generateHtmlReport(report, outputPath) {
255
291
  <div class="container">
256
292
  ${groupsHtml || '<div class="glass empty">No missions recorded.</div>'}
257
293
  </div>
294
+ <script>
295
+ (function () {
296
+ function setOpenAll(root, selector, open) {
297
+ root.querySelectorAll(selector).forEach(function (el) {
298
+ if (el && 'open' in el) el.open = open;
299
+ });
300
+ }
301
+ function areAllOpen(root, selector) {
302
+ var list = root.querySelectorAll(selector);
303
+ if (!list.length) return false;
304
+ for (var i=0;i<list.length;i++) { if (!list[i].open) return false; }
305
+ return true;
306
+ }
307
+ function refreshBtn(btn) {
308
+ var scope = btn.getAttribute('data-scope');
309
+ var root = btn.closest(scope === 'mission' ? '.mission-group' : '.mission-submission');
310
+ if (!root) return;
311
+ var allOpen = scope === 'mission'
312
+ ? (areAllOpen(root, '.mission-submission') && areAllOpen(root, '.mission-submission .step'))
313
+ : areAllOpen(root, '.step');
314
+ btn.textContent = allOpen ? '▲' : '▼';
315
+ btn.setAttribute('aria-label', allOpen ? 'Collapse' : 'Expand');
316
+ btn.dataset.open = allOpen ? 'true' : 'false';
317
+ }
318
+
319
+ // Click handler (single toggle)
320
+ document.addEventListener('click', function (e) {
321
+ var btn = e.target && e.target.closest && e.target.closest('button.toggle');
322
+ if (!btn) return;
323
+ e.preventDefault(); e.stopPropagation();
324
+
325
+ var scope = btn.getAttribute('data-scope');
326
+ var root = btn.closest(scope === 'mission' ? '.mission-group' : '.mission-submission');
327
+ if (!root) return;
328
+
329
+ var currentlyOpen = btn.dataset.open === 'true';
330
+ var nextOpen = !currentlyOpen;
331
+
332
+ if (scope === 'mission') {
333
+ root.open = true; // ensure visible
334
+ setOpenAll(root, '.mission-submission', nextOpen);
335
+ setOpenAll(root, '.mission-submission .step', nextOpen);
336
+ } else {
337
+ root.open = true;
338
+ setOpenAll(root, '.step', nextOpen);
339
+ }
340
+ refreshBtn(btn);
341
+ }, true);
342
+
343
+ // Keep arrows in sync if user toggles details manually
344
+ document.addEventListener('toggle', function () {
345
+ document.querySelectorAll('button.toggle').forEach(refreshBtn);
346
+ }, true);
347
+
348
+ // Initial refresh
349
+ document.querySelectorAll('button.toggle').forEach(refreshBtn);
350
+ })();
351
+ </script>
352
+
353
+
258
354
  </body>
259
355
  </html>`;
260
356