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.
- package/core/redaction.js +79 -0
- package/core/turnIntent.js +95 -0
- package/core/turnLoop.js +14 -2
- package/package.json +1 -1
- package/runner/testronaut.js +59 -26
- package/tools/generateHtmlReport.js +96 -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,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
|
-
|
|
191
|
-
|
|
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
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>
|
|
@@ -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
|
|