scenescout 1.2.0 → 1.4.0
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/CHANGELOG.md +26 -0
- package/README.md +41 -9
- package/dist/browsers.js +11 -0
- package/dist/cli.js +127 -37
- package/dist/engine/browser.js +207 -10
- package/dist/engine/injection.js +162 -0
- package/dist/engine/live-page.js +650 -0
- package/dist/engine/live.js +579 -0
- package/dist/engine/memory.js +10 -2
- package/dist/engine/oracles.js +8 -0
- package/dist/engine/report.js +8 -2
- package/dist/installer.js +82 -4
- package/dist/mcp-server.js +216 -26
- package/package.json +4 -2
- package/skills/scenescout/SKILL.md +3 -2
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live view's single page. It is served as one string with no external
|
|
3
|
+
* assets, so it works offline and the Content-Security-Policy can forbid
|
|
4
|
+
* everything but itself.
|
|
5
|
+
*
|
|
6
|
+
* Everything shown here is untrusted: URLs, element names and the report's
|
|
7
|
+
* text come from the app under test, session names, tasks and objectives from
|
|
8
|
+
* the agent. The script sets them all with textContent and never builds markup
|
|
9
|
+
* from them. The client script avoids template literals, but because the whole
|
|
10
|
+
* page is one, a backtick inside it is written \` and a backslash \\ (the
|
|
11
|
+
* regexes in renderMarkdown), and live-test checks that the script still parses.
|
|
12
|
+
*/
|
|
13
|
+
export const LIVE_PAGE = `<!doctype html>
|
|
14
|
+
<html lang="en">
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
18
|
+
<title>SceneScout live</title>
|
|
19
|
+
<style>
|
|
20
|
+
:root {
|
|
21
|
+
color-scheme: light dark;
|
|
22
|
+
--bg: #f6f7f9; --panel: #ffffff; --line: #d9dde3; --text: #15181d; --muted: #5d6673;
|
|
23
|
+
--run: #b45309; --run-bg: #fef3c7; --idle: #475569; --idle-bg: #e8ebf0; --stuck: #b91c1c; --stuck-bg: #fee2e2;
|
|
24
|
+
--accent: #2563eb; --shade: #0b0d10;
|
|
25
|
+
}
|
|
26
|
+
@media (prefers-color-scheme: dark) {
|
|
27
|
+
:root {
|
|
28
|
+
--bg: #0e1116; --panel: #161a21; --line: #2a303a; --text: #e6e9ee; --muted: #98a2b3;
|
|
29
|
+
--run: #fbbf24; --run-bg: #3a2a08; --idle: #a3adba; --idle-bg: #222831; --stuck: #fca5a5; --stuck-bg: #3f1518;
|
|
30
|
+
--accent: #7aa2ff; --shade: #000000;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
* { box-sizing: border-box; }
|
|
34
|
+
::selection { background: color-mix(in srgb, var(--accent) 28%, transparent); }
|
|
35
|
+
.feed, .brief, #report { scrollbar-width: thin; scrollbar-color: var(--line) transparent; }
|
|
36
|
+
.badge, .meta, .feed .t, .since, #focus .bar .line { font-variant-numeric: tabular-nums; }
|
|
37
|
+
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
|
|
38
|
+
header { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center;
|
|
39
|
+
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
|
|
40
|
+
h1 { margin: 0; font-size: 16px; font-weight: 650; }
|
|
41
|
+
.meta { color: var(--muted); font-size: 13px; }
|
|
42
|
+
.spacer { flex: 1 1 auto; }
|
|
43
|
+
.actions { display: flex; gap: 8px; flex: 0 0 auto; }
|
|
44
|
+
button { font: inherit; color: var(--text); background: var(--panel); border: 1px solid var(--line); border-radius: 6px;
|
|
45
|
+
padding: 5px 10px; cursor: pointer; }
|
|
46
|
+
button:hover { border-color: var(--accent); }
|
|
47
|
+
button:focus-visible, .shot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
48
|
+
button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
49
|
+
#banner { display: none; margin: 12px 16px 0; padding: 8px 12px; border-radius: 6px; background: var(--stuck-bg); color: var(--stuck); }
|
|
50
|
+
#empty { display: none; padding: 48px 16px; text-align: center; color: var(--muted); }
|
|
51
|
+
main { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; padding: 16px; }
|
|
52
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
|
|
53
|
+
.card.stuck { border-color: var(--stuck); }
|
|
54
|
+
.top { display: flex; align-items: center; gap: 8px; padding: 10px 12px 6px; }
|
|
55
|
+
.name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
56
|
+
.role { color: var(--muted); font-size: 12px; white-space: nowrap; }
|
|
57
|
+
.badge { margin-left: auto; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
|
|
58
|
+
.badge.running { color: var(--run); background: var(--run-bg); }
|
|
59
|
+
.badge.idle { color: var(--idle); background: var(--idle-bg); }
|
|
60
|
+
.badge.stuck { color: var(--stuck); background: var(--stuck-bg); }
|
|
61
|
+
.line { padding: 0 12px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--muted);
|
|
62
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
63
|
+
.line b { color: var(--text); font-weight: 600; }
|
|
64
|
+
.shot { position: relative; margin: 8px 12px 0; aspect-ratio: 64 / 45; background: var(--shade); border-radius: 6px; overflow: hidden;
|
|
65
|
+
cursor: zoom-in; border: 0; padding: 0; display: block; width: calc(100% - 24px); }
|
|
66
|
+
.shot img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
|
67
|
+
.shot .tag { position: absolute; top: 6px; left: 6px; font-size: 11px; font-weight: 700; letter-spacing: .04em; padding: 1px 6px;
|
|
68
|
+
border-radius: 4px; background: var(--stuck); color: #fff; display: none; }
|
|
69
|
+
.shot.live .tag { display: block; }
|
|
70
|
+
.shot .none { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; padding: 12px;
|
|
71
|
+
color: #98a2b3; font-size: 12px; text-align: center; }
|
|
72
|
+
.shot.empty .none { display: flex; }
|
|
73
|
+
.shot.empty img { visibility: hidden; }
|
|
74
|
+
.feed { margin: 8px 12px 0; padding: 6px 8px; background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
|
|
75
|
+
font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; max-height: 108px; overflow-y: auto; overscroll-behavior: contain; }
|
|
76
|
+
.feed .row { display: flex; gap: 6px; white-space: nowrap; }
|
|
77
|
+
/* Consecutive actions of one journey share a tint, so where one goal ends and the next begins is visible in the log. */
|
|
78
|
+
.feed .group { border-left: 1px solid transparent; padding-left: 5px; margin-left: -6px; border-radius: 3px; }
|
|
79
|
+
.feed .g0 { background: rgba(96, 165, 250, .13); border-color: rgba(96, 165, 250, .7); }
|
|
80
|
+
.feed .g1 { background: rgba(52, 211, 153, .13); border-color: rgba(52, 211, 153, .7); }
|
|
81
|
+
.feed .g2 { background: rgba(251, 191, 36, .13); border-color: rgba(251, 191, 36, .7); }
|
|
82
|
+
.feed .g3 { background: rgba(244, 114, 182, .13); border-color: rgba(244, 114, 182, .7); }
|
|
83
|
+
#focus .feed .group:hover { outline: 1px solid rgba(230, 233, 238, .35); }
|
|
84
|
+
.feed .t { color: var(--muted); flex: 0 0 auto; }
|
|
85
|
+
.feed .a { color: var(--text); font-weight: 600; flex: 0 0 auto; }
|
|
86
|
+
.feed .d { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
|
|
87
|
+
.feed .bad { color: var(--stuck); }
|
|
88
|
+
.feed .none { color: var(--muted); }
|
|
89
|
+
#focus .lower { display: flex; gap: 10px; flex: 0 0 auto; height: 30vh; min-height: 140px; }
|
|
90
|
+
#focus .feed { margin: 0; max-height: none; flex: 2 1 0; min-width: 0; background: #11151b; border-color: #2a303a; scrollbar-color: #2a303a transparent; }
|
|
91
|
+
#focus .brief { flex: 1 1 0; min-width: 0; overflow-y: auto; padding: 10px 14px; background: #11151b; border: 1px solid #2a303a;
|
|
92
|
+
border-radius: 6px; color: #e6e9ee; scrollbar-color: #2a303a transparent; }
|
|
93
|
+
#focus .brief h3 { margin: 0 0 4px; font-size: 11px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: #98a2b3; }
|
|
94
|
+
#focus .brief p { margin: 0 0 14px; font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }
|
|
95
|
+
#focus .brief p.unset { color: #98a2b3; font-style: italic; }
|
|
96
|
+
#focus .brief .since { margin-top: -10px; font-size: 12px; color: #98a2b3; }
|
|
97
|
+
@media (max-width: 700px) { #focus .lower { flex-direction: column; height: 45vh; } }
|
|
98
|
+
.task { padding: 0 12px 2px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
99
|
+
#focus .feed .a { color: #e6e9ee; }
|
|
100
|
+
#focus .feed .t, #focus .feed .d, #focus .feed .none { color: #98a2b3; }
|
|
101
|
+
#focus .feed .bad { color: #fca5a5; }
|
|
102
|
+
.foot { display: flex; align-items: center; gap: 8px; padding: 8px 12px 10px; }
|
|
103
|
+
.foot .spec { color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
104
|
+
#focus { display: none; position: fixed; inset: 0; z-index: 5; background: #0b0d10; padding: 16px; flex-direction: column; gap: 10px; }
|
|
105
|
+
#focus.open { display: flex; }
|
|
106
|
+
#focus .bar { display: flex; align-items: center; gap: 12px; color: #fff; }
|
|
107
|
+
#focus .bar .line { color: #cbd5e1; padding: 0; flex: 1 1 auto; }
|
|
108
|
+
#focus .bar .line b { color: #fff; }
|
|
109
|
+
#focus img { flex: 1 1 auto; min-height: 0; width: 100%; object-fit: contain; background: #000; border-radius: 6px; }
|
|
110
|
+
#report { display: none; position: fixed; inset: 0; z-index: 6; background: var(--bg); overflow-y: auto; padding: 0 16px 32px; }
|
|
111
|
+
#report.open { display: block; }
|
|
112
|
+
#report .bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
|
|
113
|
+
padding: 12px 0; background: var(--bg); border-bottom: 1px solid var(--line); }
|
|
114
|
+
#report .bar strong { font-size: 16px; }
|
|
115
|
+
#report .doc { max-width: 900px; margin: 0 auto; padding-top: 16px; line-height: 1.55; overflow-wrap: anywhere; }
|
|
116
|
+
#report .doc h2 { font-size: 22px; margin: 8px 0 12px; }
|
|
117
|
+
#report .doc h3 { font-size: 17px; margin: 28px 0 8px; padding-top: 12px; border-top: 1px solid var(--line); }
|
|
118
|
+
#report .doc h4 { font-size: 15px; margin: 22px 0 6px; }
|
|
119
|
+
#report .doc table { border-collapse: collapse; margin: 8px 0 12px; font-size: 13px; }
|
|
120
|
+
#report .doc th, #report .doc td { border: 1px solid var(--line); padding: 4px 8px; text-align: left; vertical-align: top; }
|
|
121
|
+
#report .doc th { background: var(--panel); }
|
|
122
|
+
#report .doc code { font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: var(--panel); border: 1px solid var(--line);
|
|
123
|
+
border-radius: 4px; padding: 0 4px; }
|
|
124
|
+
#report .doc pre { padding: 10px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; overflow-x: auto; }
|
|
125
|
+
#report .doc pre code { border: 0; padding: 0; background: none; white-space: pre; }
|
|
126
|
+
#report .doc details { margin: 8px 0; }
|
|
127
|
+
#report .doc summary { cursor: pointer; color: var(--muted); }
|
|
128
|
+
#report .doc .unset { color: var(--muted); font-style: italic; }
|
|
129
|
+
@media (prefers-reduced-motion: no-preference) { .badge.running { animation: pulse 1.6s ease-in-out infinite; } }
|
|
130
|
+
@keyframes pulse { 50% { opacity: .55; } }
|
|
131
|
+
</style>
|
|
132
|
+
</head>
|
|
133
|
+
<body>
|
|
134
|
+
<header>
|
|
135
|
+
<h1>SceneScout live</h1>
|
|
136
|
+
<span class="meta" id="engine" data-testid="live-engine-summary"></span>
|
|
137
|
+
<span class="spacer"></span>
|
|
138
|
+
<span class="meta" id="counts" data-testid="live-session-counts"></span>
|
|
139
|
+
<span class="actions">
|
|
140
|
+
<button type="button" id="report-open" data-testid="live-report-toggle">Report</button>
|
|
141
|
+
<button type="button" id="all" aria-pressed="false" data-testid="live-all-toggle">Stream all</button>
|
|
142
|
+
</span>
|
|
143
|
+
</header>
|
|
144
|
+
<div id="banner" role="alert" data-testid="live-unreachable-banner">The engine is not answering. It may have exited; this page will pick up again if it comes back.</div>
|
|
145
|
+
<div id="empty" data-testid="live-empty-state">No session is attached yet. Cards appear here as soon as one attaches.</div>
|
|
146
|
+
<main id="grid"></main>
|
|
147
|
+
<div id="report" role="dialog" aria-modal="true" aria-label="The run's report" data-testid="live-report-dialog">
|
|
148
|
+
<div class="bar">
|
|
149
|
+
<strong>Report</strong>
|
|
150
|
+
<span class="meta" id="report-meta" data-testid="live-report-meta"></span>
|
|
151
|
+
<span class="spacer"></span>
|
|
152
|
+
<button type="button" id="report-close" data-testid="live-report-close">Close</button>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="doc" id="report-doc" data-testid="live-report-doc"></div>
|
|
155
|
+
</div>
|
|
156
|
+
<div id="focus" role="dialog" aria-modal="true" aria-label="Session close-up" data-testid="live-focus-dialog">
|
|
157
|
+
<div class="bar">
|
|
158
|
+
<strong id="focus-name"></strong>
|
|
159
|
+
<span class="line" id="focus-line"></span>
|
|
160
|
+
<button type="button" id="focus-close" data-testid="live-focus-close">Close</button>
|
|
161
|
+
</div>
|
|
162
|
+
<img id="focus-img" alt="">
|
|
163
|
+
<div class="lower">
|
|
164
|
+
<div class="feed" id="focus-feed" data-testid="live-focus-feed"></div>
|
|
165
|
+
<aside class="brief" aria-label="What this session is doing" data-testid="live-focus-brief">
|
|
166
|
+
<h3>Task</h3>
|
|
167
|
+
<p id="focus-task" data-testid="live-focus-task"></p>
|
|
168
|
+
<h3 id="focus-objective-head">Current objective</h3>
|
|
169
|
+
<p id="focus-objective" data-testid="live-focus-objective"></p>
|
|
170
|
+
<p class="since" id="focus-objective-since"></p>
|
|
171
|
+
</aside>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
<script>
|
|
175
|
+
(function () {
|
|
176
|
+
var THUMB_EVERY_MS = 3000;
|
|
177
|
+
var cards = {};
|
|
178
|
+
var streamAll = false;
|
|
179
|
+
var focused = null;
|
|
180
|
+
var skew = 0;
|
|
181
|
+
var latest = {};
|
|
182
|
+
var focusTick = 0;
|
|
183
|
+
var events = null;
|
|
184
|
+
var eventsKey = '';
|
|
185
|
+
var frames = {};
|
|
186
|
+
var hoverObjective = null;
|
|
187
|
+
var reportOpen = false;
|
|
188
|
+
var reportTimer = null;
|
|
189
|
+
var reportProblem = null;
|
|
190
|
+
// A result that reads as a failure is shown in red.
|
|
191
|
+
var BAD_RESULT = /error|fail|refus|block|violation|abandoned/i;
|
|
192
|
+
|
|
193
|
+
function el(tag, cls, text) {
|
|
194
|
+
var node = document.createElement(tag);
|
|
195
|
+
if (cls) node.className = cls;
|
|
196
|
+
if (text !== undefined) node.textContent = text;
|
|
197
|
+
return node;
|
|
198
|
+
}
|
|
199
|
+
function held(ms) {
|
|
200
|
+
var total = Math.max(0, Math.floor(ms / 1000));
|
|
201
|
+
if (total < 60) return total + 's';
|
|
202
|
+
var minutes = Math.floor(total / 60);
|
|
203
|
+
if (minutes < 60) return minutes + 'm' + String(total % 60).padStart(2, '0') + 's';
|
|
204
|
+
return Math.floor(minutes / 60) + 'h' + String(minutes % 60).padStart(2, '0') + 'm';
|
|
205
|
+
}
|
|
206
|
+
// The viewer's clock, 24-hour: the log stores UTC, and a feed an hour off the person's own watch reads as stale.
|
|
207
|
+
function clock(iso) {
|
|
208
|
+
var d = new Date(iso);
|
|
209
|
+
if (isNaN(d.getTime())) return '';
|
|
210
|
+
return [d.getHours(), d.getMinutes(), d.getSeconds()].map(function (n) { return String(n).padStart(2, '0'); }).join(':');
|
|
211
|
+
}
|
|
212
|
+
function shotUrl(name) { return 'shot/' + encodeURIComponent(name) + '.jpg?ts=' + Date.now(); }
|
|
213
|
+
|
|
214
|
+
// Every watched session's frames come over ONE connection. A browser allows
|
|
215
|
+
// about six open connections to a host, and a stream per <img> used them
|
|
216
|
+
// all up on six sessions, leaving the status poll queued behind them.
|
|
217
|
+
function syncEvents() {
|
|
218
|
+
var want = {};
|
|
219
|
+
Object.keys(cards).forEach(function (name) { if (cards[name].live) want[name] = true; });
|
|
220
|
+
if (focused && cards[focused]) want[focused] = true;
|
|
221
|
+
var key = Object.keys(want).sort().join(',');
|
|
222
|
+
if (key === eventsKey) return;
|
|
223
|
+
eventsKey = key;
|
|
224
|
+
if (events) { events.close(); events = null; }
|
|
225
|
+
if (!key) return;
|
|
226
|
+
events = new EventSource('events?sessions=' + encodeURIComponent(key));
|
|
227
|
+
events.addEventListener('frame', function (e) {
|
|
228
|
+
var d;
|
|
229
|
+
try { d = JSON.parse(e.data); } catch (err) { return; }
|
|
230
|
+
if (!d || typeof d.jpeg !== 'string') return;
|
|
231
|
+
var src = 'data:image/jpeg;base64,' + d.jpeg;
|
|
232
|
+
frames[d.session] = src;
|
|
233
|
+
var card = cards[d.session];
|
|
234
|
+
if (card && card.live) card.img.src = src;
|
|
235
|
+
if (focused === d.session) document.getElementById('focus-img').src = src;
|
|
236
|
+
});
|
|
237
|
+
events.addEventListener('unavailable', function (e) {
|
|
238
|
+
var d;
|
|
239
|
+
try { d = JSON.parse(e.data); } catch (err) { return; }
|
|
240
|
+
var card = d && cards[d.session];
|
|
241
|
+
if (!card) return;
|
|
242
|
+
// The session cannot stream, or its page is gone: the LIVE tag would lie.
|
|
243
|
+
setLive(card, false);
|
|
244
|
+
card.shot.classList.add('empty');
|
|
245
|
+
});
|
|
246
|
+
// A connection the browser gave up on (a 500, say) is not retried by EventSource; the next poll reopens it.
|
|
247
|
+
events.onerror = function () { if (events && events.readyState === EventSource.CLOSED) eventsKey = ''; };
|
|
248
|
+
}
|
|
249
|
+
function describe(s) {
|
|
250
|
+
var since = held(Date.now() + skew - Date.parse(s.since));
|
|
251
|
+
if (s.state === 'idle') return { badge: 'idle ' + since, tool: 'last: ' + s.tool };
|
|
252
|
+
return { badge: (s.state === 'stuck' ? 'stuck ' : 'running ') + since, tool: s.tool };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function setLive(card, on) {
|
|
256
|
+
if (card.live === on) return;
|
|
257
|
+
card.live = on;
|
|
258
|
+
card.shot.classList.toggle('live', on);
|
|
259
|
+
card.toggle.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
260
|
+
card.toggle.textContent = on ? 'Streaming' : 'Stream';
|
|
261
|
+
// Frames for a live card arrive over the shared connection; the thumbnail poll takes over again when it is switched off.
|
|
262
|
+
if (on && frames[card.name]) card.img.src = frames[card.name];
|
|
263
|
+
if (!on) card.img.src = shotUrl(card.name);
|
|
264
|
+
syncEvents();
|
|
265
|
+
}
|
|
266
|
+
function refreshThumb(card) {
|
|
267
|
+
if (card.live || document.hidden) return;
|
|
268
|
+
var next = new Image();
|
|
269
|
+
next.onload = function () { if (!card.live) card.img.src = next.src; };
|
|
270
|
+
next.onerror = function () { if (!card.live) card.shot.classList.add('empty'); };
|
|
271
|
+
next.src = shotUrl(card.name);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function build(name) {
|
|
275
|
+
var root = el('section', 'card');
|
|
276
|
+
root.setAttribute('data-testid', 'live-card-' + name);
|
|
277
|
+
var top = el('div', 'top');
|
|
278
|
+
var nameEl = el('span', 'name', name);
|
|
279
|
+
nameEl.title = name;
|
|
280
|
+
var role = el('span', 'role');
|
|
281
|
+
var badge = el('span', 'badge idle');
|
|
282
|
+
top.appendChild(nameEl); top.appendChild(role); top.appendChild(badge);
|
|
283
|
+
var task = el('div', 'task');
|
|
284
|
+
task.setAttribute('data-testid', 'live-card-task-' + name);
|
|
285
|
+
var tool = el('div', 'line');
|
|
286
|
+
var url = el('div', 'line');
|
|
287
|
+
var shot = el('button', 'shot');
|
|
288
|
+
shot.type = 'button';
|
|
289
|
+
shot.setAttribute('aria-label', 'Open a close-up of ' + name);
|
|
290
|
+
shot.setAttribute('data-testid', 'live-card-image-' + name);
|
|
291
|
+
var img = el('img');
|
|
292
|
+
img.alt = 'What ' + name + ' is showing';
|
|
293
|
+
shot.appendChild(img); shot.appendChild(el('span', 'tag', 'LIVE'));
|
|
294
|
+
shot.appendChild(el('span', 'none', 'No frame available. The page may be closed, or not answering.'));
|
|
295
|
+
// A session with nothing to show answers 503. Say so instead of leaving a broken-image icon.
|
|
296
|
+
img.addEventListener('error', function () { shot.classList.add('empty'); });
|
|
297
|
+
img.addEventListener('load', function () { shot.classList.remove('empty'); });
|
|
298
|
+
var feed = el('div', 'feed');
|
|
299
|
+
feed.setAttribute('data-testid', 'live-card-feed-' + name);
|
|
300
|
+
var foot = el('div', 'foot');
|
|
301
|
+
var toggle = el('button', '', 'Stream');
|
|
302
|
+
toggle.type = 'button';
|
|
303
|
+
toggle.setAttribute('aria-pressed', 'false');
|
|
304
|
+
toggle.setAttribute('data-testid', 'live-card-toggle-' + name);
|
|
305
|
+
var spec = el('span', 'spec');
|
|
306
|
+
foot.appendChild(toggle); foot.appendChild(spec);
|
|
307
|
+
root.appendChild(top); root.appendChild(task); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
|
|
308
|
+
|
|
309
|
+
var card = { name: name, root: root, role: role, badge: badge, task: task, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
|
|
310
|
+
toggle.addEventListener('click', function () { setLive(card, !card.live); });
|
|
311
|
+
shot.addEventListener('click', function () { openFocus(name); });
|
|
312
|
+
img.src = shotUrl(name);
|
|
313
|
+
if (streamAll) setLive(card, true);
|
|
314
|
+
return card;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// The report is Markdown whose text comes from the app under test (finding
|
|
318
|
+
// titles, element names, URLs). It is turned into elements here, node by
|
|
319
|
+
// node with textContent, so none of it is ever parsed as markup.
|
|
320
|
+
function inline(node, text) {
|
|
321
|
+
var re = /(\`[^\`]+\`|\\*\\*[^*]+\\*\\*)/g;
|
|
322
|
+
var last = 0;
|
|
323
|
+
var m;
|
|
324
|
+
while ((m = re.exec(text))) {
|
|
325
|
+
if (m.index > last) node.appendChild(document.createTextNode(text.slice(last, m.index)));
|
|
326
|
+
var tok = m[0];
|
|
327
|
+
node.appendChild(tok.charAt(0) === '\`' ? el('code', '', tok.slice(1, -1)) : el('strong', '', tok.slice(2, -2)));
|
|
328
|
+
last = m.index + tok.length;
|
|
329
|
+
}
|
|
330
|
+
if (last < text.length) node.appendChild(document.createTextNode(text.slice(last)));
|
|
331
|
+
}
|
|
332
|
+
function renderMarkdown(root, md) {
|
|
333
|
+
root.textContent = '';
|
|
334
|
+
var lines = md.split('\\n');
|
|
335
|
+
var i = 0;
|
|
336
|
+
var container = root;
|
|
337
|
+
var para = [];
|
|
338
|
+
function flush() {
|
|
339
|
+
if (!para.length) return;
|
|
340
|
+
var p = el('p');
|
|
341
|
+
inline(p, para.join(' '));
|
|
342
|
+
container.appendChild(p);
|
|
343
|
+
para = [];
|
|
344
|
+
}
|
|
345
|
+
function list(tag, re) {
|
|
346
|
+
var box = el(tag);
|
|
347
|
+
var m;
|
|
348
|
+
while (i < lines.length && (m = re.exec(lines[i]))) {
|
|
349
|
+
var li = el('li');
|
|
350
|
+
inline(li, m[1]);
|
|
351
|
+
box.appendChild(li);
|
|
352
|
+
i += 1;
|
|
353
|
+
}
|
|
354
|
+
container.appendChild(box);
|
|
355
|
+
}
|
|
356
|
+
while (i < lines.length) {
|
|
357
|
+
var line = lines[i];
|
|
358
|
+
var m;
|
|
359
|
+
if (/^\`\`\`/.test(line)) {
|
|
360
|
+
flush();
|
|
361
|
+
var code = [];
|
|
362
|
+
i += 1;
|
|
363
|
+
while (i < lines.length && !/^\`\`\`/.test(lines[i])) { code.push(lines[i]); i += 1; }
|
|
364
|
+
i += 1;
|
|
365
|
+
var pre = el('pre');
|
|
366
|
+
pre.appendChild(el('code', '', code.join('\\n')));
|
|
367
|
+
container.appendChild(pre);
|
|
368
|
+
} else if ((m = /^(#{1,6}) (.*)$/.exec(line))) {
|
|
369
|
+
flush();
|
|
370
|
+
var h = el('h' + Math.min(6, m[1].length + 1));
|
|
371
|
+
inline(h, m[2]);
|
|
372
|
+
container.appendChild(h);
|
|
373
|
+
i += 1;
|
|
374
|
+
} else if ((m = /^<details><summary>(.*)<\\/summary>$/.exec(line))) {
|
|
375
|
+
flush();
|
|
376
|
+
var details = el('details');
|
|
377
|
+
details.appendChild(el('summary', '', m[1]));
|
|
378
|
+
container.appendChild(details);
|
|
379
|
+
container = details;
|
|
380
|
+
i += 1;
|
|
381
|
+
} else if (/^<\\/details>$/.test(line)) {
|
|
382
|
+
flush();
|
|
383
|
+
container = root;
|
|
384
|
+
i += 1;
|
|
385
|
+
} else if (/^\\|/.test(line)) {
|
|
386
|
+
flush();
|
|
387
|
+
var table = el('table');
|
|
388
|
+
var rowIndex = 0;
|
|
389
|
+
while (i < lines.length && /^\\|/.test(lines[i])) {
|
|
390
|
+
var row = lines[i];
|
|
391
|
+
i += 1;
|
|
392
|
+
if (/^\\|(\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)) continue;
|
|
393
|
+
var tr = el('tr');
|
|
394
|
+
row.replace(/^\\||\\|\\s*$/g, '').split('|').forEach(function (cell) {
|
|
395
|
+
var td = el(rowIndex === 0 ? 'th' : 'td');
|
|
396
|
+
inline(td, cell.trim());
|
|
397
|
+
tr.appendChild(td);
|
|
398
|
+
});
|
|
399
|
+
table.appendChild(tr);
|
|
400
|
+
rowIndex += 1;
|
|
401
|
+
}
|
|
402
|
+
container.appendChild(table);
|
|
403
|
+
} else if (/^\\s*[-*] /.test(line)) {
|
|
404
|
+
flush();
|
|
405
|
+
list('ul', /^\\s*[-*] (.*)$/);
|
|
406
|
+
} else if (/^\\d+\\. /.test(line)) {
|
|
407
|
+
flush();
|
|
408
|
+
list('ol', /^\\d+\\. (.*)$/);
|
|
409
|
+
} else if (line.trim() === '') {
|
|
410
|
+
flush();
|
|
411
|
+
i += 1;
|
|
412
|
+
} else {
|
|
413
|
+
para.push(line);
|
|
414
|
+
i += 1;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
flush();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function loadReport() {
|
|
421
|
+
fetch('api/report', { cache: 'no-store' })
|
|
422
|
+
.then(function (r) {
|
|
423
|
+
if (r.status === 404) return null;
|
|
424
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
425
|
+
return r.json();
|
|
426
|
+
})
|
|
427
|
+
.then(function (d) {
|
|
428
|
+
if (!reportOpen) return;
|
|
429
|
+
var doc = document.getElementById('report-doc');
|
|
430
|
+
var meta = document.getElementById('report-meta');
|
|
431
|
+
reportProblem = null;
|
|
432
|
+
if (!d) {
|
|
433
|
+
doc.textContent = '';
|
|
434
|
+
doc.appendChild(el('p', 'unset', 'No run is attached, so there is nothing to report yet.'));
|
|
435
|
+
meta.textContent = '';
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
meta.textContent = 'as the run stands at ' + clock(d.at) + ' · scout_report writes this document to .scenescout/report.md at the end';
|
|
439
|
+
renderMarkdown(doc, d.markdown);
|
|
440
|
+
})
|
|
441
|
+
.catch(function (err) {
|
|
442
|
+
// The last rendering stays; with none, say why there is nothing to read.
|
|
443
|
+
var doc = document.getElementById('report-doc');
|
|
444
|
+
reportProblem = err && err.message ? err.message : 'no answer';
|
|
445
|
+
if (!doc.childElementCount) {
|
|
446
|
+
doc.textContent = '';
|
|
447
|
+
doc.appendChild(el('p', 'unset', 'The report could not be loaded (' + reportProblem + '). Trying again.'));
|
|
448
|
+
}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
function openReport() {
|
|
452
|
+
reportOpen = true;
|
|
453
|
+
document.getElementById('report').classList.add('open');
|
|
454
|
+
loadReport();
|
|
455
|
+
reportTimer = setInterval(loadReport, 5000);
|
|
456
|
+
document.getElementById('report-close').focus();
|
|
457
|
+
}
|
|
458
|
+
function closeReport() {
|
|
459
|
+
reportOpen = false;
|
|
460
|
+
clearInterval(reportTimer);
|
|
461
|
+
document.getElementById('report').classList.remove('open');
|
|
462
|
+
document.getElementById('report-open').focus();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Built with textContent only: an action's target is text from the app under test.
|
|
466
|
+
function renderFeed(node, lines, onGroup) {
|
|
467
|
+
// Measured before the node is emptied: an empty node always reads as scrolled to its end.
|
|
468
|
+
var atTail = node.scrollTop + node.clientHeight >= node.scrollHeight - 4;
|
|
469
|
+
node.textContent = '';
|
|
470
|
+
if (!lines || !lines.length) { node.appendChild(el('div', 'none', 'nothing recorded yet')); return; }
|
|
471
|
+
var group = null;
|
|
472
|
+
var groupObjective = null;
|
|
473
|
+
var groups = 0;
|
|
474
|
+
lines.forEach(function (line) {
|
|
475
|
+
var objective = line.objective || '';
|
|
476
|
+
if (!group || objective !== groupObjective) {
|
|
477
|
+
group = el('div', 'group' + (objective ? ' g' + (groups % 4) : ''));
|
|
478
|
+
if (objective) { groups += 1; group.title = objective; }
|
|
479
|
+
if (onGroup) {
|
|
480
|
+
group.addEventListener('mouseenter', function () { onGroup(objective); });
|
|
481
|
+
group.addEventListener('mouseleave', function () { onGroup(null); });
|
|
482
|
+
}
|
|
483
|
+
groupObjective = objective;
|
|
484
|
+
node.appendChild(group);
|
|
485
|
+
}
|
|
486
|
+
var row = el('div', 'row');
|
|
487
|
+
row.appendChild(el('span', 't', clock(line.at)));
|
|
488
|
+
row.appendChild(el('span', 'a' + (BAD_RESULT.test(line.result || '') ? ' bad' : ''), line.action));
|
|
489
|
+
var rest = [line.target, line.result ? '-> ' + line.result : ''].filter(Boolean).join(' ');
|
|
490
|
+
var detail = el('span', 'd', rest || line.url || '');
|
|
491
|
+
detail.title = [line.target, line.result, line.url].filter(Boolean).join(' · ');
|
|
492
|
+
row.appendChild(detail);
|
|
493
|
+
group.appendChild(row);
|
|
494
|
+
});
|
|
495
|
+
// Follow the tail unless the reader has scrolled up to look at something.
|
|
496
|
+
if (atTail) node.scrollTop = node.scrollHeight;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function paint(card, s) {
|
|
500
|
+
var d = describe(s);
|
|
501
|
+
card.root.className = 'card' + (s.state === 'stuck' ? ' stuck' : '');
|
|
502
|
+
card.role.textContent = s.role === 'anonymous' ? '' : s.role;
|
|
503
|
+
card.task.textContent = s.task || '';
|
|
504
|
+
card.task.title = s.task || '';
|
|
505
|
+
card.task.hidden = !s.task;
|
|
506
|
+
card.badge.className = 'badge ' + s.state;
|
|
507
|
+
card.badge.textContent = d.badge;
|
|
508
|
+
card.tool.textContent = d.tool;
|
|
509
|
+
card.url.textContent = s.url || '(no page yet)';
|
|
510
|
+
card.url.title = s.url || '';
|
|
511
|
+
card.spec.textContent = [s.mode, s.browser, s.headed ? 'headed' : 'headless'].filter(Boolean).join(' · ');
|
|
512
|
+
renderFeed(card.feed, s.feed);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function openFocus(name) {
|
|
516
|
+
focused = name;
|
|
517
|
+
document.getElementById('focus-name').textContent = name;
|
|
518
|
+
document.getElementById('focus-img').alt = 'Live view of ' + name;
|
|
519
|
+
document.getElementById('focus-img').src = frames[name] || shotUrl(name);
|
|
520
|
+
document.getElementById('focus').classList.add('open');
|
|
521
|
+
hoverObjective = null;
|
|
522
|
+
renderFeed(document.getElementById('focus-feed'), (latest[name] || {}).feed, showObjective);
|
|
523
|
+
syncEvents();
|
|
524
|
+
loadFullFeed(name);
|
|
525
|
+
paintFocus();
|
|
526
|
+
document.getElementById('focus-close').focus();
|
|
527
|
+
}
|
|
528
|
+
function closeFocus() {
|
|
529
|
+
var was = focused;
|
|
530
|
+
focused = null;
|
|
531
|
+
document.getElementById('focus').classList.remove('open');
|
|
532
|
+
document.getElementById('focus-img').removeAttribute('src');
|
|
533
|
+
syncEvents();
|
|
534
|
+
if (was && cards[was]) cards[was].shot.focus();
|
|
535
|
+
}
|
|
536
|
+
function loadFullFeed(name) {
|
|
537
|
+
fetch('api/activity?session=' + encodeURIComponent(name), { cache: 'no-store' })
|
|
538
|
+
.then(function (r) { return r.ok ? r.json() : null; })
|
|
539
|
+
.then(function (d) { if (d && focused === d.session) renderFeed(document.getElementById('focus-feed'), d.feed, showObjective); })
|
|
540
|
+
.catch(function () { /* the short feed from the last poll stays on screen */ });
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function setBrief(id, text, unset) {
|
|
544
|
+
var node = document.getElementById(id);
|
|
545
|
+
node.textContent = text || unset;
|
|
546
|
+
node.className = text ? '' : 'unset';
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// The pointer is over a group of the close-up's feed: the brief shows the
|
|
550
|
+
// objective those actions served. It goes back to the current one on leaving.
|
|
551
|
+
function showObjective(objective) {
|
|
552
|
+
hoverObjective = objective;
|
|
553
|
+
paintBrief();
|
|
554
|
+
}
|
|
555
|
+
function paintBrief() {
|
|
556
|
+
var s = focused && latest[focused];
|
|
557
|
+
// The engine sees tool calls, not reasoning: both of these are the agent's own words, or nothing.
|
|
558
|
+
setBrief('focus-task', s && s.task, 'Not given. An agent sets it when it attaches the session.');
|
|
559
|
+
var head = document.getElementById('focus-objective-head');
|
|
560
|
+
if (hoverObjective !== null) {
|
|
561
|
+
head.textContent = 'Objective for these actions';
|
|
562
|
+
setBrief('focus-objective', hoverObjective, 'No journey was running.');
|
|
563
|
+
document.getElementById('focus-objective-since').textContent = '';
|
|
564
|
+
} else {
|
|
565
|
+
head.textContent = 'Current objective';
|
|
566
|
+
setBrief('focus-objective', s && s.objective, 'No journey running. An agent starts one with scout_journey.');
|
|
567
|
+
document.getElementById('focus-objective-since').textContent =
|
|
568
|
+
s && s.objective && s.objectiveSince ? 'for ' + held(Date.now() + skew - Date.parse(s.objectiveSince)) : '';
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
// Once a second, from the status poll: the bar, the brief, and every third time the long feed.
|
|
572
|
+
function paintFocus() {
|
|
573
|
+
var s = focused && latest[focused];
|
|
574
|
+
var line = document.getElementById('focus-line');
|
|
575
|
+
paintBrief();
|
|
576
|
+
if (!s) { line.textContent = focused ? 'This session has closed.' : ''; return; }
|
|
577
|
+
var d = describe(s);
|
|
578
|
+
line.textContent = d.badge + ' · ' + d.tool + ' · ' + (s.url || '');
|
|
579
|
+
if (focusTick % 3 === 0) loadFullFeed(focused);
|
|
580
|
+
focusTick += 1;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function apply(snap) {
|
|
584
|
+
skew = Date.parse(snap.at) - Date.now();
|
|
585
|
+
var grid = document.getElementById('grid');
|
|
586
|
+
var seen = {};
|
|
587
|
+
latest = {};
|
|
588
|
+
var counts = { running: 0, idle: 0, stuck: 0 };
|
|
589
|
+
snap.sessions.forEach(function (s) {
|
|
590
|
+
seen[s.session] = true;
|
|
591
|
+
latest[s.session] = s;
|
|
592
|
+
counts[s.state] += 1;
|
|
593
|
+
if (!cards[s.session]) {
|
|
594
|
+
cards[s.session] = build(s.session);
|
|
595
|
+
// Sessions arrive sorted; inserting in that order keeps the grid
|
|
596
|
+
// stable without ever moving a card that is already streaming.
|
|
597
|
+
// Before the first card that sorts after this one; at the end when there is none.
|
|
598
|
+
var next = Object.keys(cards).sort().filter(function (k) { return k > s.session && cards[k].root.parentNode; })[0];
|
|
599
|
+
grid.insertBefore(cards[s.session].root, next ? cards[next].root : null);
|
|
600
|
+
}
|
|
601
|
+
paint(cards[s.session], s);
|
|
602
|
+
});
|
|
603
|
+
Object.keys(cards).forEach(function (name) {
|
|
604
|
+
if (seen[name]) return;
|
|
605
|
+
cards[name].img.removeAttribute('src');
|
|
606
|
+
cards[name].root.remove();
|
|
607
|
+
delete cards[name];
|
|
608
|
+
delete frames[name];
|
|
609
|
+
});
|
|
610
|
+
syncEvents();
|
|
611
|
+
document.getElementById('empty').style.display = snap.sessions.length ? 'none' : 'block';
|
|
612
|
+
document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
|
|
613
|
+
document.getElementById('counts').textContent = snap.sessions.length + ' session' + (snap.sessions.length === 1 ? '' : 's') +
|
|
614
|
+
' · ' + counts.running + ' running · ' + counts.idle + ' idle' + (counts.stuck ? ' · ' + counts.stuck + ' stuck' : '');
|
|
615
|
+
paintFocus();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function poll() {
|
|
619
|
+
fetch('api/status', { cache: 'no-store' })
|
|
620
|
+
.then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
|
|
621
|
+
.then(function (snap) { document.getElementById('banner').style.display = 'none'; apply(snap); })
|
|
622
|
+
.catch(function () { document.getElementById('banner').style.display = 'block'; });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
document.getElementById('all').addEventListener('click', function () {
|
|
626
|
+
streamAll = !streamAll;
|
|
627
|
+
this.setAttribute('aria-pressed', streamAll ? 'true' : 'false');
|
|
628
|
+
this.textContent = streamAll ? 'Streaming all' : 'Stream all';
|
|
629
|
+
Object.keys(cards).forEach(function (name) { setLive(cards[name], streamAll); });
|
|
630
|
+
});
|
|
631
|
+
document.getElementById('focus-close').addEventListener('click', closeFocus);
|
|
632
|
+
// A re-rendered feed replaces the group under the pointer without a mouseleave; leaving the feed itself still resets.
|
|
633
|
+
document.getElementById('focus-feed').addEventListener('mouseleave', function () { showObjective(null); });
|
|
634
|
+
document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
|
|
635
|
+
document.getElementById('report-open').addEventListener('click', openReport);
|
|
636
|
+
document.getElementById('report-close').addEventListener('click', closeReport);
|
|
637
|
+
document.addEventListener('keydown', function (e) {
|
|
638
|
+
if (e.key !== 'Escape') return;
|
|
639
|
+
if (reportOpen) closeReport();
|
|
640
|
+
else if (focused) closeFocus();
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
poll();
|
|
644
|
+
setInterval(poll, 1000);
|
|
645
|
+
setInterval(function () { Object.keys(cards).forEach(function (name) { refreshThumb(cards[name]); }); }, THUMB_EVERY_MS);
|
|
646
|
+
})();
|
|
647
|
+
</script>
|
|
648
|
+
</body>
|
|
649
|
+
</html>
|
|
650
|
+
`;
|