scenescout 3.2.0 → 3.3.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 +79 -0
- package/README.md +3 -2
- package/dist/cli.js +39 -23
- package/dist/engine/brief.js +134 -0
- package/dist/engine/browser.js +262 -19
- package/dist/engine/claims.js +194 -0
- package/dist/engine/live-page.js +101 -2
- package/dist/engine/live.js +60 -0
- package/dist/engine/memory.js +100 -1
- package/dist/engine/oracles.js +9 -0
- package/dist/engine/pace.js +112 -0
- package/dist/engine/report.js +70 -6
- package/dist/engine/request.js +144 -0
- package/dist/engine/settle.js +97 -0
- package/dist/engine/verify.js +142 -0
- package/dist/mcp-server.js +144 -11
- package/dist/scan.js +47 -1
- package/package.json +9 -3
- package/skills/scenescout/SKILL.md +9 -1
package/dist/engine/live-page.js
CHANGED
|
@@ -58,6 +58,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
58
58
|
color: #fff; background: var(--accent); border-radius: 6px; }
|
|
59
59
|
main { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; padding: 16px; }
|
|
60
60
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
|
|
61
|
+
/* Every rule here that sets display beats the browser's own [hidden] rule,
|
|
62
|
+
so a hidden card stayed on screen with the "nothing matches" notice above
|
|
63
|
+
it. Say it once, for everything. */
|
|
64
|
+
[hidden] { display: none !important; }
|
|
61
65
|
.card.stuck { border-color: var(--stuck); }
|
|
62
66
|
.top { display: flex; align-items: center; gap: 8px; padding: 10px 12px 6px; }
|
|
63
67
|
.name { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
@@ -118,6 +122,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
118
122
|
.doing { padding: 0 12px 4px; font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
119
123
|
.doing::before { content: "▸ "; color: var(--accent); }
|
|
120
124
|
.doing.unset { color: var(--stuck); }
|
|
125
|
+
.pace { padding: 0 12px 4px; font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
126
|
+
.pace .bad { color: var(--stuck); }
|
|
127
|
+
header input#filter { font: inherit; font-size: 12px; padding: 4px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--text); min-width: 150px; }
|
|
128
|
+
#no-match { padding: 16px; color: var(--muted); }
|
|
121
129
|
#focus .feed .a { color: #e6e9ee; }
|
|
122
130
|
#focus .feed .t, #focus .feed .d, #focus .feed .none { color: #98a2b3; }
|
|
123
131
|
#focus .feed .bad { color: #fca5a5; }
|
|
@@ -188,12 +196,14 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
188
196
|
<span class="spacer"></span>
|
|
189
197
|
<span class="meta" id="counts" data-testid="live-session-counts"></span>
|
|
190
198
|
<span class="actions">
|
|
199
|
+
<input type="search" id="filter" placeholder="Filter sessions" aria-label="Filter sessions by name, role, objective, task or page" data-testid="live-filter" />
|
|
191
200
|
<button type="button" id="report-open" data-testid="live-report-toggle">Report</button>
|
|
192
201
|
<button type="button" id="all" aria-pressed="false" data-testid="live-all-toggle">Stream all</button>
|
|
193
202
|
</span>
|
|
194
203
|
</header>
|
|
195
204
|
<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>
|
|
196
205
|
<div id="empty" data-testid="live-empty-state">No session is attached yet. Cards appear here as soon as one attaches.</div>
|
|
206
|
+
<div id="no-match" data-testid="live-no-match" hidden></div>
|
|
197
207
|
<div id="finished" data-testid="live-finished-state">
|
|
198
208
|
<h2>The run has finished</h2>
|
|
199
209
|
<p>Its browsers are closed, so there is nothing left to watch. What it found is in the report.</p>
|
|
@@ -240,6 +250,8 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
240
250
|
var THUMB_EVERY_MS = 3000;
|
|
241
251
|
var cards = {};
|
|
242
252
|
var streamAll = false;
|
|
253
|
+
/** The header filter, lower-cased. Hides cards; never stops a session running. */
|
|
254
|
+
var filter = '';
|
|
243
255
|
var focused = null;
|
|
244
256
|
var skew = 0;
|
|
245
257
|
var latest = {};
|
|
@@ -364,6 +376,14 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
364
376
|
task.setAttribute('data-testid', 'live-card-objective-' + name);
|
|
365
377
|
var doing = el('div', 'doing');
|
|
366
378
|
doing.setAttribute('data-testid', 'live-card-task-' + name);
|
|
379
|
+
// How long it has been on this task, and how many of its recent steps went
|
|
380
|
+
// wrong. Both were already in the status payload and reached nobody: the
|
|
381
|
+
// first only inside the close-up, the second only as a red word in a feed
|
|
382
|
+
// somebody had to read. On a board of eleven cards, "which one is stuck on
|
|
383
|
+
// the same thing, and which one is having trouble" is the question being
|
|
384
|
+
// asked, and it was the one thing the board could not answer.
|
|
385
|
+
var pace = el('div', 'pace');
|
|
386
|
+
pace.setAttribute('data-testid', 'live-card-pace-' + name);
|
|
367
387
|
var tool = el('div', 'line');
|
|
368
388
|
var url = el('div', 'line');
|
|
369
389
|
var shot = el('button', 'shot');
|
|
@@ -386,9 +406,9 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
386
406
|
toggle.setAttribute('data-testid', 'live-card-toggle-' + name);
|
|
387
407
|
var spec = el('span', 'spec');
|
|
388
408
|
foot.appendChild(toggle); foot.appendChild(spec);
|
|
389
|
-
root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
|
|
409
|
+
root.appendChild(top); root.appendChild(task); root.appendChild(doing); root.appendChild(pace); root.appendChild(tool); root.appendChild(url); root.appendChild(shot); root.appendChild(feed); root.appendChild(foot);
|
|
390
410
|
|
|
391
|
-
var card = { name: name, root: root, role: role, badge: badge, task: task, doing: doing, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
|
|
411
|
+
var card = { name: name, root: root, role: role, badge: badge, task: task, doing: doing, pace: pace, tool: tool, url: url, shot: shot, img: img, feed: feed, toggle: toggle, spec: spec, live: false };
|
|
392
412
|
toggle.addEventListener('click', function () { setLive(card, !card.live); });
|
|
393
413
|
shot.addEventListener('click', function () { openFocus(name); });
|
|
394
414
|
img.src = shotUrl(name);
|
|
@@ -735,9 +755,31 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
735
755
|
card.url.textContent = s.url || '(no page yet)';
|
|
736
756
|
card.url.title = s.url || '';
|
|
737
757
|
card.spec.textContent = [s.mode, s.browser, s.headed ? 'headed' : 'headless'].filter(Boolean).join(' · ');
|
|
758
|
+
paintPace(card, s);
|
|
738
759
|
renderFeed(card.feed, s.feed);
|
|
739
760
|
}
|
|
740
761
|
|
|
762
|
+
/** Steps in the visible feed whose result reads as trouble. */
|
|
763
|
+
function troubled(feed) {
|
|
764
|
+
var n = 0;
|
|
765
|
+
(feed || []).forEach(function (line) { if (BAD_RESULT.test(line.result || '')) n += 1; });
|
|
766
|
+
return n;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function paintPace(card, s) {
|
|
770
|
+
var on = s.task && s.taskSince ? 'on this for ' + held(Date.now() + skew - Date.parse(s.taskSince)) : '';
|
|
771
|
+
var bad = troubled(s.feed);
|
|
772
|
+
card.pace.textContent = '';
|
|
773
|
+
if (on) card.pace.appendChild(el('span', '', on));
|
|
774
|
+
if (bad > 0) {
|
|
775
|
+
if (on) card.pace.appendChild(el('span', '', ' · '));
|
|
776
|
+
// Only the trouble is red. Reddening the whole line made "on this for
|
|
777
|
+
// 9s" look like the complaint.
|
|
778
|
+
card.pace.appendChild(el('span', 'bad', bad + ' of the last ' + s.feed.length + ' steps went wrong'));
|
|
779
|
+
}
|
|
780
|
+
card.pace.hidden = !on && bad === 0;
|
|
781
|
+
}
|
|
782
|
+
|
|
741
783
|
function openFocus(name) {
|
|
742
784
|
focused = name;
|
|
743
785
|
scrubbed = null;
|
|
@@ -827,6 +869,32 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
827
869
|
focusTick += 1;
|
|
828
870
|
}
|
|
829
871
|
|
|
872
|
+
/**
|
|
873
|
+
* Whether a session survives the header's filter. Matched against everything
|
|
874
|
+
* the card already shows, because on a board of eleven the reader is looking
|
|
875
|
+
* for "the one on the orders register" as often as for a session by name.
|
|
876
|
+
* Filtering hides cards; it never stops them being polled or streamed, so a
|
|
877
|
+
* hidden session is still running and still counted in the header.
|
|
878
|
+
*/
|
|
879
|
+
function matches(s) {
|
|
880
|
+
if (!filter) return true;
|
|
881
|
+
var hay = [s.session, s.role, s.objective, s.task, s.url, s.tool].join(' ').toLowerCase();
|
|
882
|
+
return hay.indexOf(filter) !== -1;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
function applyFilter() {
|
|
886
|
+
var shown = 0;
|
|
887
|
+
Object.keys(cards).forEach(function (name) {
|
|
888
|
+
var s = latest[name];
|
|
889
|
+
var keep = !s || matches(s);
|
|
890
|
+
cards[name].root.hidden = !keep;
|
|
891
|
+
if (keep) shown += 1;
|
|
892
|
+
});
|
|
893
|
+
var none = document.getElementById('no-match');
|
|
894
|
+
none.hidden = !filter || shown > 0 || Object.keys(cards).length === 0;
|
|
895
|
+
none.textContent = 'No session matches ' + JSON.stringify(filter) + '.';
|
|
896
|
+
}
|
|
897
|
+
|
|
830
898
|
function apply(snap) {
|
|
831
899
|
skew = Date.parse(snap.at) - Date.now();
|
|
832
900
|
var grid = document.getElementById('grid');
|
|
@@ -884,6 +952,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
884
952
|
document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
|
|
885
953
|
document.getElementById('counts').textContent = snap.sessions.length + ' session' + (snap.sessions.length === 1 ? '' : 's') +
|
|
886
954
|
' · ' + counts.running + ' running · ' + counts.idle + ' idle' + (counts.stuck ? ' · ' + counts.stuck + ' stuck' : '');
|
|
955
|
+
applyFilter();
|
|
887
956
|
paintFocus();
|
|
888
957
|
}
|
|
889
958
|
|
|
@@ -927,7 +996,37 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
927
996
|
e.returnValue = '';
|
|
928
997
|
});
|
|
929
998
|
document.getElementById('report-close').addEventListener('click', closeReport);
|
|
999
|
+
document.getElementById('filter').addEventListener('input', function (e) {
|
|
1000
|
+
filter = String(e.target.value || '').trim().toLowerCase();
|
|
1001
|
+
applyFilter();
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Step through the timeline from the keyboard.
|
|
1006
|
+
*
|
|
1007
|
+
* Scrubbing a long run by clicking 16px ticks is the kind of thing a mouse
|
|
1008
|
+
* is bad at, and the run being examined is usually the one with hundreds of
|
|
1009
|
+
* steps. Arrow keys move one step, Home and End jump to the ends, and Space
|
|
1010
|
+
* returns to the live picture — only while the close-up is open, and never
|
|
1011
|
+
* while the reader is typing in the filter.
|
|
1012
|
+
*/
|
|
1013
|
+
function stepBy(delta) {
|
|
1014
|
+
if (timelineLines.length === 0) return;
|
|
1015
|
+
var at = scrubbed
|
|
1016
|
+
? timelineLines.findIndex(function (l) { return l.at === scrubbed.at && l.action === scrubbed.action; })
|
|
1017
|
+
: timelineLines.length - 1;
|
|
1018
|
+
var next = Math.max(0, Math.min(timelineLines.length - 1, (at === -1 ? timelineLines.length - 1 : at) + delta));
|
|
1019
|
+
showStep(timelineLines[next]);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
930
1022
|
document.addEventListener('keydown', function (e) {
|
|
1023
|
+
if (focused && !reportOpen && e.target !== document.getElementById('filter')) {
|
|
1024
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') { e.preventDefault(); stepBy(e.key === 'ArrowLeft' ? -1 : 1); return; }
|
|
1025
|
+
if (e.key === 'Home' || e.key === 'End') { e.preventDefault(); stepBy(e.key === 'Home' ? -1e9 : 1e9); return; }
|
|
1026
|
+
// Back to what the session is showing NOW, which is otherwise a click
|
|
1027
|
+
// on a button the reader has to find.
|
|
1028
|
+
if (e.key === ' ' && scrubbed) { e.preventDefault(); backToLive(); return; }
|
|
1029
|
+
}
|
|
931
1030
|
if (e.key !== 'Escape') return;
|
|
932
1031
|
if (reportOpen) closeReport();
|
|
933
1032
|
else if (focused) closeFocus();
|
package/dist/engine/live.js
CHANGED
|
@@ -30,6 +30,10 @@ import { LIVE_PAGE } from "./live-page.js";
|
|
|
30
30
|
import { JOURNEY_END, JOURNEY_START, TASK_SET } from "./memory.js";
|
|
31
31
|
/** Holds the live view's token, next to status.json. Written owner-only; removed when the engine shuts down. */
|
|
32
32
|
export const LIVE_TOKEN_FILE = "live-token";
|
|
33
|
+
/** This engine's own token file. Shares the directory with other engines, so it carries the pid. */
|
|
34
|
+
export function liveTokenFileName(pid) {
|
|
35
|
+
return `${LIVE_TOKEN_FILE}.${pid}`;
|
|
36
|
+
}
|
|
33
37
|
/** `SCENESCOUT_LIVE=off` keeps the engine from opening the live view's port at all. */
|
|
34
38
|
export const LIVE_ENV = "SCENESCOUT_LIVE";
|
|
35
39
|
/**
|
|
@@ -178,12 +182,68 @@ const statusWrites = new Map();
|
|
|
178
182
|
* longer one after it (which is what overlapping `writeFile`s produced).
|
|
179
183
|
* Best-effort: a failed write is dropped and the next one lands.
|
|
180
184
|
*/
|
|
185
|
+
/**
|
|
186
|
+
* Whether a process is still there. EPERM means it EXISTS but belongs to
|
|
187
|
+
* another user; only ESRCH means no such process, and treating both as dead
|
|
188
|
+
* reported a live engine as stale.
|
|
189
|
+
*/
|
|
190
|
+
export function pidAlive(pid) {
|
|
191
|
+
if (!pid)
|
|
192
|
+
return false;
|
|
193
|
+
try {
|
|
194
|
+
process.kill(pid, 0);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
catch (err) {
|
|
198
|
+
return err?.code === "EPERM";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function statusFileName(pid) {
|
|
202
|
+
return `status.${pid}.json`;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Every engine that has written a status file here, newest first, one entry
|
|
206
|
+
* per pid. Two engines on one project used to overwrite each other in a single
|
|
207
|
+
* status.json, so `watch` could only ever find whichever attached last and the
|
|
208
|
+
* other run was reachable only from the transcript that started it.
|
|
209
|
+
* status.json is still written for readers from before this, and is used only
|
|
210
|
+
* when no per-pid file exists.
|
|
211
|
+
*/
|
|
212
|
+
export function liveEngines(dir, isAlive) {
|
|
213
|
+
let names = [];
|
|
214
|
+
try {
|
|
215
|
+
names = fs.readdirSync(dir);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return [];
|
|
219
|
+
}
|
|
220
|
+
const perPid = names.filter((n) => /^status\.\d+\.json$/.test(n));
|
|
221
|
+
const out = [];
|
|
222
|
+
for (const name of perPid.length > 0 ? perPid : names.filter((n) => n === "status.json")) {
|
|
223
|
+
let parsed;
|
|
224
|
+
try {
|
|
225
|
+
parsed = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8"));
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const pid = typeof parsed.pid === "number" ? parsed.pid : NaN;
|
|
231
|
+
if (!Number.isInteger(pid) || !isAlive(pid))
|
|
232
|
+
continue;
|
|
233
|
+
out.push({ pid, status: parsed, file: name });
|
|
234
|
+
}
|
|
235
|
+
return out.sort((a, b) => (b.status.at ?? "").localeCompare(a.status.at ?? ""));
|
|
236
|
+
}
|
|
181
237
|
export function writeStatusFile(dir, body) {
|
|
182
238
|
const file = path.join(dir, "status.json");
|
|
183
239
|
const tmp = `${file}.${process.pid}.tmp`;
|
|
240
|
+
// Both: status.json for readers from before per-pid files, and one named for
|
|
241
|
+
// this process so a second engine on the same project does not erase it.
|
|
242
|
+
const mine = path.join(dir, statusFileName(process.pid));
|
|
184
243
|
const next = (statusWrites.get(dir) ?? Promise.resolve())
|
|
185
244
|
.then(() => fs.promises.writeFile(tmp, body))
|
|
186
245
|
.then(() => fs.promises.rename(tmp, file))
|
|
246
|
+
.then(() => fs.promises.writeFile(mine, body))
|
|
187
247
|
.catch(() => fs.promises.rm(tmp, { force: true }).catch(() => { }));
|
|
188
248
|
statusWrites.set(dir, next);
|
|
189
249
|
return next;
|
package/dist/engine/memory.js
CHANGED
|
@@ -25,6 +25,47 @@ export const FINDING_CATEGORIES = [
|
|
|
25
25
|
"missing-testid",
|
|
26
26
|
"other",
|
|
27
27
|
];
|
|
28
|
+
/**
|
|
29
|
+
* Most states a route may keep. A route accumulates one state per distinct
|
|
30
|
+
* element set, so a register with filters, tabs and paging produces dozens;
|
|
31
|
+
* one project reached 6,075 states across 48 routes, a 36 MB history parsed
|
|
32
|
+
* and re-serialised on every save. Coverage is asked per ROUTE, so keeping the
|
|
33
|
+
* most recent states of each route preserves every answer the gap ledger
|
|
34
|
+
* needs while dropping the long tail nothing will ask about again.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_STATES_PER_ROUTE = 40;
|
|
37
|
+
/**
|
|
38
|
+
* The states to keep. Never drops one a finding points at — a finding's repro
|
|
39
|
+
* trace and its route identity are read back from it — and never drops the
|
|
40
|
+
* newest of a route, so a route that was visited stays visited.
|
|
41
|
+
*
|
|
42
|
+
* Returns the pruned map rather than mutating, so the rule can be table-tested.
|
|
43
|
+
*/
|
|
44
|
+
export function pruneStates(states, findings, perRoute = MAX_STATES_PER_ROUTE) {
|
|
45
|
+
const pinned = new Set(findings.map((f) => f.state));
|
|
46
|
+
const byRoute = new Map();
|
|
47
|
+
for (const entry of Object.entries(states)) {
|
|
48
|
+
const route = entry[1].route;
|
|
49
|
+
const list = byRoute.get(route);
|
|
50
|
+
if (list)
|
|
51
|
+
list.push(entry);
|
|
52
|
+
else
|
|
53
|
+
byRoute.set(route, [entry]);
|
|
54
|
+
}
|
|
55
|
+
const kept = {};
|
|
56
|
+
let dropped = 0;
|
|
57
|
+
for (const list of byRoute.values()) {
|
|
58
|
+
// Newest first, so the survivors are the ones a next run will meet again.
|
|
59
|
+
list.sort((a, b) => (b[1].lastSeen ?? b[1].firstSeen).localeCompare(a[1].lastSeen ?? a[1].firstSeen));
|
|
60
|
+
list.forEach(([fp, rec], index) => {
|
|
61
|
+
if (index < perRoute || pinned.has(fp))
|
|
62
|
+
kept[fp] = rec;
|
|
63
|
+
else
|
|
64
|
+
dropped += 1;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return { kept, dropped };
|
|
68
|
+
}
|
|
28
69
|
/** An element class must appear on this many routes at minimum before it can count as shared chrome. */
|
|
29
70
|
const CHROME_MIN_ROUTES = 4;
|
|
30
71
|
/** …and on at least this share of all visited routes (a majority — "it's on every page"). */
|
|
@@ -684,9 +725,34 @@ export class MemoryStore {
|
|
|
684
725
|
constraints: "Constraints — rules discovered the hard way (gates, preconditions, limits)",
|
|
685
726
|
risks: "Risks & watchpoints — fragile areas worth re-testing every run",
|
|
686
727
|
glossary: "Glossary — domain terms and what they mean here",
|
|
728
|
+
setup: "Setup — how to get this app into a testable state (how a login state is regenerated, what has to be running)",
|
|
687
729
|
};
|
|
688
730
|
return `## ${titles[section] ?? section}`;
|
|
689
731
|
}
|
|
732
|
+
/**
|
|
733
|
+
* What earlier runs recorded about getting this app testable, if anything.
|
|
734
|
+
*
|
|
735
|
+
* Read back by the auth-failure message. A storage state expires on a timer
|
|
736
|
+
* nobody remembers, and "regenerate it" is advice the reader already had;
|
|
737
|
+
* the command that worked last time is the part worth keeping, and it is
|
|
738
|
+
* exactly the kind of thing a run pays to find out and then forgets.
|
|
739
|
+
*/
|
|
740
|
+
setupRecipe() {
|
|
741
|
+
if (!fs.existsSync(this.assumptionsPath))
|
|
742
|
+
return [];
|
|
743
|
+
const text = fs.readFileSync(this.assumptionsPath, "utf8");
|
|
744
|
+
const heading = MemoryStore.sectionHeading("setup");
|
|
745
|
+
const start = text.indexOf(heading);
|
|
746
|
+
if (start === -1)
|
|
747
|
+
return [];
|
|
748
|
+
const rest = text.slice(start + heading.length);
|
|
749
|
+
const end = rest.indexOf("\n## ");
|
|
750
|
+
return (end === -1 ? rest : rest.slice(0, end))
|
|
751
|
+
.split("\n")
|
|
752
|
+
.map((l) => l.trim())
|
|
753
|
+
.filter((l) => l.startsWith("- "))
|
|
754
|
+
.map((l) => l.slice(2).trim());
|
|
755
|
+
}
|
|
690
756
|
readAssumptions() {
|
|
691
757
|
if (!fs.existsSync(this.assumptionsPath))
|
|
692
758
|
return "(no ASSUMPTIONS.md yet — record what you learn with scout_note as you explore)";
|
|
@@ -725,13 +791,43 @@ export class MemoryStore {
|
|
|
725
791
|
this.flush();
|
|
726
792
|
return f;
|
|
727
793
|
}
|
|
794
|
+
/**
|
|
795
|
+
* Record what a re-test of this finding found. "gone" resolves it; the other
|
|
796
|
+
* two leave it open and stamp the confirmation, which is what lets the report
|
|
797
|
+
* stop describing a finding somebody checked yesterday as unverified.
|
|
798
|
+
*/
|
|
799
|
+
verifyFinding(id, verdict, note) {
|
|
800
|
+
const f = this.data.findings.find((x) => x.id === id);
|
|
801
|
+
if (!f)
|
|
802
|
+
return null;
|
|
803
|
+
f.verdict = verdict;
|
|
804
|
+
f.verifiedAt = new Date().toISOString();
|
|
805
|
+
if (note)
|
|
806
|
+
f.verifyNote = redactSecrets(note).slice(0, 500);
|
|
807
|
+
else
|
|
808
|
+
delete f.verifyNote;
|
|
809
|
+
if (verdict === "gone")
|
|
810
|
+
f.status = "resolved";
|
|
811
|
+
this.flush();
|
|
812
|
+
return f;
|
|
813
|
+
}
|
|
728
814
|
load() {
|
|
729
815
|
if (!fs.existsSync(this.memoryPath))
|
|
730
816
|
return structuredClone(EMPTY);
|
|
731
817
|
try {
|
|
732
818
|
const raw = JSON.parse(fs.readFileSync(this.memoryPath, "utf8"));
|
|
733
|
-
if (raw.version === 1)
|
|
819
|
+
if (raw.version === 1) {
|
|
820
|
+
// Prune once, on open: the long tail of per-route states is what makes
|
|
821
|
+
// an old history slow to parse and re-serialise, and it answers no
|
|
822
|
+
// question the gap ledger asks. Findings and the newest states of
|
|
823
|
+
// every route are kept, so coverage does not regress.
|
|
824
|
+
const { kept, dropped } = pruneStates(raw.states ?? {}, raw.findings ?? []);
|
|
825
|
+
if (dropped > 0) {
|
|
826
|
+
raw.states = kept;
|
|
827
|
+
this.prunedStates = dropped;
|
|
828
|
+
}
|
|
734
829
|
return raw;
|
|
830
|
+
}
|
|
735
831
|
this.loadWarning = `memory.json has unknown version ${String(raw.version)} — starting fresh.`;
|
|
736
832
|
}
|
|
737
833
|
catch (err) {
|
|
@@ -783,6 +879,8 @@ export class MemoryStore {
|
|
|
783
879
|
// Deliberately NOT unref'd: a pending coverage write briefly holds the
|
|
784
880
|
// process open so an exit without scout_close still lands the last save.
|
|
785
881
|
}
|
|
882
|
+
/** How many states the last open pruned. Reported once, so a shrinking history is never silent. */
|
|
883
|
+
prunedStates = 0;
|
|
786
884
|
/** Set when a debounced background write failed — cleared on the next successful write. Surfaced by scout_coverage/scout_close so a broken persistence path is never silently invisible. */
|
|
787
885
|
lastSaveError = null;
|
|
788
886
|
/**
|
|
@@ -907,6 +1005,7 @@ export class MemoryStore {
|
|
|
907
1005
|
this.data.states[fingerprint] = rec;
|
|
908
1006
|
}
|
|
909
1007
|
rec.visits += 1;
|
|
1008
|
+
rec.lastSeen = new Date().toISOString();
|
|
910
1009
|
const present = new Set(elementKeys);
|
|
911
1010
|
for (const key of elementKeys) {
|
|
912
1011
|
if (!rec.elements[key])
|
package/dist/engine/oracles.js
CHANGED
|
@@ -137,6 +137,15 @@ export class OracleMonitor {
|
|
|
137
137
|
noteInjection(detail, url) {
|
|
138
138
|
this.record({ kind: "dom_injection", severity: "high", detail, url });
|
|
139
139
|
}
|
|
140
|
+
/**
|
|
141
|
+
* The page contradicted a request that was refused during the same action:
|
|
142
|
+
* a refused list rendered as an empty state, or a refused save reported as a
|
|
143
|
+
* success. Found by the engine's DOM scan (claims.ts), like injections, so
|
|
144
|
+
* it is reported through here rather than by a page event.
|
|
145
|
+
*/
|
|
146
|
+
noteContradiction(c, url) {
|
|
147
|
+
this.record({ kind: c.kind, severity: "high", detail: c.detail, url });
|
|
148
|
+
}
|
|
140
149
|
record(v) {
|
|
141
150
|
if (isPolicyInduced(v, this.lastPolicyBlockAt === null ? null : Date.now() - this.lastPolicyBlockAt)) {
|
|
142
151
|
this.policyAttributed += 1;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log entries that are not actions: a stated task, an attach, the note that a
|
|
3
|
+
* resource was created. They carry no browser work and take no time, so
|
|
4
|
+
* counting them inflates the action count and drags the median gap toward
|
|
5
|
+
* zero. One real run logged 325 entries of which 107 were stated tasks.
|
|
6
|
+
*/
|
|
7
|
+
const MARKER_ACTIONS = new Set(["task", "attach", "created-resource", "journey:start", "journey:end", "record:full", "record:failed"]);
|
|
8
|
+
/** Whether a log entry represents work the browser actually did. */
|
|
9
|
+
export function isActing(action) {
|
|
10
|
+
return !MARKER_ACTIONS.has(action);
|
|
11
|
+
}
|
|
12
|
+
/** A gap longer than this is the agent thinking, not the browser working. */
|
|
13
|
+
export const IDLE_GAP_MS = 30_000;
|
|
14
|
+
/** A session with no action for this long is probably forgotten, and is holding a browser for nothing. */
|
|
15
|
+
export const STALE_SESSION_MS = 5 * 60_000;
|
|
16
|
+
function median(sorted) {
|
|
17
|
+
if (sorted.length === 0)
|
|
18
|
+
return 0;
|
|
19
|
+
const mid = Math.floor(sorted.length / 2);
|
|
20
|
+
return sorted.length % 2 === 1 ? sorted[mid] : Math.round((sorted[mid - 1] + sorted[mid]) / 2);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The pace of each session in the log, and of the run as a whole. `nowMs` is
|
|
24
|
+
* passed rather than read so the numbers are the same every time they are
|
|
25
|
+
* computed from the same log.
|
|
26
|
+
*/
|
|
27
|
+
export function measurePace(log, nowMs, attached = []) {
|
|
28
|
+
const stillOpen = new Set(attached);
|
|
29
|
+
const bySession = new Map();
|
|
30
|
+
for (const entry of log) {
|
|
31
|
+
if (!isActing(entry.action))
|
|
32
|
+
continue;
|
|
33
|
+
const name = entry.session ?? "default";
|
|
34
|
+
const list = bySession.get(name);
|
|
35
|
+
if (list)
|
|
36
|
+
list.push(entry);
|
|
37
|
+
else
|
|
38
|
+
bySession.set(name, [entry]);
|
|
39
|
+
}
|
|
40
|
+
const sessions = [];
|
|
41
|
+
let first = Number.POSITIVE_INFINITY;
|
|
42
|
+
let last = 0;
|
|
43
|
+
for (const [session, entries] of bySession) {
|
|
44
|
+
const times = entries.map((e) => Date.parse(e.at)).filter((t) => Number.isFinite(t));
|
|
45
|
+
if (times.length === 0)
|
|
46
|
+
continue;
|
|
47
|
+
times.sort((a, b) => a - b);
|
|
48
|
+
first = Math.min(first, times[0]);
|
|
49
|
+
last = Math.max(last, times[times.length - 1]);
|
|
50
|
+
const gaps = [];
|
|
51
|
+
for (let i = 1; i < times.length; i += 1)
|
|
52
|
+
gaps.push(times[i] - times[i - 1]);
|
|
53
|
+
const spanMs = times[times.length - 1] - times[0];
|
|
54
|
+
const idleMs = gaps.filter((g) => g > IDLE_GAP_MS).reduce((sum, g) => sum + g, 0);
|
|
55
|
+
sessions.push({
|
|
56
|
+
session,
|
|
57
|
+
actions: entries.length,
|
|
58
|
+
spanMs,
|
|
59
|
+
medianGapMs: median([...gaps].sort((a, b) => a - b)),
|
|
60
|
+
maxGapMs: gaps.length > 0 ? Math.max(...gaps) : 0,
|
|
61
|
+
idleShare: spanMs > 0 ? idleMs / spanMs : 0,
|
|
62
|
+
quietMs: Math.max(0, nowMs - times[times.length - 1]),
|
|
63
|
+
framed: entries.filter((e) => e.frame).length,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
sessions.sort((a, b) => b.actions - a.actions);
|
|
67
|
+
return {
|
|
68
|
+
sessions,
|
|
69
|
+
spanMs: sessions.length > 0 ? last - first : 0,
|
|
70
|
+
actions: sessions.reduce((sum, x) => sum + x.actions, 0),
|
|
71
|
+
// Only a session that is STILL ATTACHED can be holding a browser. A lane
|
|
72
|
+
// that finished and closed is quiet because it is gone, and warning about
|
|
73
|
+
// it told the reader to close something that no longer exists — which is
|
|
74
|
+
// what the first run of this report did for six of its eleven sessions.
|
|
75
|
+
quiet: sessions.filter((s) => s.quietMs > STALE_SESSION_MS && stillOpen.has(s.session)).map((s) => s.session),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/** Milliseconds as a person says them: 45s, 4m12s, 1h03m. */
|
|
79
|
+
export function sayDuration(ms) {
|
|
80
|
+
const total = Math.max(0, Math.round(ms / 1000));
|
|
81
|
+
if (total < 60)
|
|
82
|
+
return `${total}s`;
|
|
83
|
+
const minutes = Math.floor(total / 60);
|
|
84
|
+
if (minutes < 60)
|
|
85
|
+
return `${minutes}m${String(total % 60).padStart(2, "0")}s`;
|
|
86
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, "0")}m`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The run's pace as report lines. Says what the numbers mean, because "idle
|
|
90
|
+
* 84%" reads as a fault in the engine when it is a description of how the
|
|
91
|
+
* agent paced itself.
|
|
92
|
+
*/
|
|
93
|
+
export function formatPace(pace) {
|
|
94
|
+
if (pace.sessions.length === 0)
|
|
95
|
+
return [];
|
|
96
|
+
const lines = [
|
|
97
|
+
`## How the run was paced`,
|
|
98
|
+
``,
|
|
99
|
+
`${pace.actions} action(s) over ${sayDuration(pace.spanMs)}. Stated tasks and attaches are not counted: they take no time. Idle share is time the browser stood still waiting for the agent, not time the engine spent working.`,
|
|
100
|
+
``,
|
|
101
|
+
`| Session | Actions | Span | Median gap | Longest gap | Idle | Frames |`,
|
|
102
|
+
`|---|---:|---:|---:|---:|---:|---:|`,
|
|
103
|
+
];
|
|
104
|
+
for (const s of pace.sessions) {
|
|
105
|
+
lines.push(`| ${s.session} | ${s.actions} | ${sayDuration(s.spanMs)} | ${sayDuration(s.medianGapMs)} | ${sayDuration(s.maxGapMs)} | ${Math.round(s.idleShare * 100)}% | ${s.framed} |`);
|
|
106
|
+
}
|
|
107
|
+
lines.push(``);
|
|
108
|
+
if (pace.quiet.length > 0) {
|
|
109
|
+
lines.push(`⚠ Held a browser with nothing to do for over ${sayDuration(STALE_SESSION_MS)}: ${pace.quiet.join(", ")}. A session waiting for its turn should be closed and re-attached when it is needed.`, ``);
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
package/dist/engine/report.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { SHARED_CHROME_ROUTE } from "./memory.js";
|
|
4
|
+
import { sayVerification } from "./verify.js";
|
|
4
5
|
import { feedForSession } from "./live.js";
|
|
5
6
|
import { buildReplayHtml, evidenceFor } from "./replay.js";
|
|
7
|
+
import { formatPace, measurePace } from "./pace.js";
|
|
6
8
|
function playwrightSkeleton(f) {
|
|
7
9
|
const routeClass = f.state.split("#")[0].split("?")[0];
|
|
8
10
|
let gotoPath = routeClass;
|
|
@@ -68,6 +70,24 @@ function projectName(dir) {
|
|
|
68
70
|
const parent = path.dirname(dir);
|
|
69
71
|
return path.basename(parent) || path.basename(dir) || "project";
|
|
70
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* How long ago a finding was last seen, for the historical index. A finding
|
|
75
|
+
* nobody has re-confirmed in four months is a different thing from one seen
|
|
76
|
+
* last week, and the report should not make a reader open both to find out.
|
|
77
|
+
*/
|
|
78
|
+
export function describeAge(foundAt, nowMs) {
|
|
79
|
+
const seen = Date.parse(foundAt);
|
|
80
|
+
if (!Number.isFinite(seen))
|
|
81
|
+
return "?";
|
|
82
|
+
const days = Math.floor((nowMs - seen) / 86_400_000);
|
|
83
|
+
if (days <= 0)
|
|
84
|
+
return "today";
|
|
85
|
+
if (days === 1)
|
|
86
|
+
return "1 day";
|
|
87
|
+
if (days < 60)
|
|
88
|
+
return `${days} days`;
|
|
89
|
+
return `${Math.floor(days / 30)} months`;
|
|
90
|
+
}
|
|
71
91
|
/** Every session that did anything, with its steps in order — what the HTML replays. */
|
|
72
92
|
function replaySessions(memory) {
|
|
73
93
|
const names = [...new Set(memory.actionLog.map((e) => e.session ?? "default"))];
|
|
@@ -378,6 +398,7 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
378
398
|
const lines = [];
|
|
379
399
|
const resolved = findings.filter((f) => f.status === "resolved");
|
|
380
400
|
const open = findings.filter((f) => f.status !== "resolved");
|
|
401
|
+
const now = Date.now();
|
|
381
402
|
const current = open.filter((f) => f.foundAt >= memory.sessionStart);
|
|
382
403
|
const historical = open.filter((f) => f.foundAt < memory.sessionStart);
|
|
383
404
|
lines.push(`# SceneScout Report`);
|
|
@@ -496,6 +517,11 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
496
517
|
lines.push(`- **Evidence:** \`${f.evidence}\``);
|
|
497
518
|
lines.push(`- **Where:** \`${f.state}\` (${f.url})`);
|
|
498
519
|
lines.push(`- **Seen in runs:** ${f.runs}`);
|
|
520
|
+
// Only printed once somebody has re-tested it. A finding nobody has looked
|
|
521
|
+
// at again says nothing here, which is the honest thing for it to say.
|
|
522
|
+
if (f.verdict && f.verifiedAt) {
|
|
523
|
+
lines.push(`- **Re-tested:**${sayVerification(f).replace(/^ · /, " ")}${f.verifyNote ? ` — ${f.verifyNote}` : ""}`);
|
|
524
|
+
}
|
|
499
525
|
lines.push(``);
|
|
500
526
|
lines.push(f.detail);
|
|
501
527
|
lines.push(``);
|
|
@@ -521,18 +547,47 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
521
547
|
if (historical.length > 0) {
|
|
522
548
|
lines.push(`## Historical findings — not re-verified this session (${historical.length})`);
|
|
523
549
|
lines.push(``);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
550
|
+
if (extras?.history === "full") {
|
|
551
|
+
lines.push(`Recorded in earlier runs and not re-confirmed. Re-test before acting; \`scout_verify\` hands them back in re-test order and records what each re-test found.`);
|
|
552
|
+
lines.push(``);
|
|
553
|
+
for (const f of historical)
|
|
554
|
+
renderFinding(f);
|
|
555
|
+
}
|
|
556
|
+
else {
|
|
557
|
+
// An index, not the findings themselves. One project reached 412
|
|
558
|
+
// historical findings and printing each in full made the report 1.75 MB
|
|
559
|
+
// — a document nobody opens, in which the eleven findings the run
|
|
560
|
+
// actually made were buried. Each row carries what decides whether to
|
|
561
|
+
// re-test it; the detail is one scout_report {history:"full"} away.
|
|
562
|
+
lines.push(`Recorded in earlier runs and NOT re-confirmed by this one, so none of it is evidence about the build under test. Listed as an index: ` +
|
|
563
|
+
`work it down with \`scout_verify\`, which hands back these findings in re-test order and records what each re-test found, ` +
|
|
564
|
+
`and pass \`history: "full"\` to scout_report for the full text.`, ``, `| Sev | Id | Age | Runs | Re-tested | Title |`, `|---|---|---:|---:|---|---|`);
|
|
565
|
+
for (const f of historical) {
|
|
566
|
+
const retested = f.verdict && f.verifiedAt ? `${f.verdict} ${f.verifiedAt.slice(0, 10)}` : "never";
|
|
567
|
+
lines.push(`| ${SEVERITY_ICON[f.severity]} | \`${f.id}\` | ${describeAge(f.foundAt, now)} | ${f.runs} | ${retested} | ${escapeTableCell(f.title)} |`);
|
|
568
|
+
}
|
|
569
|
+
lines.push(``);
|
|
570
|
+
}
|
|
528
571
|
}
|
|
529
572
|
if (resolved.length > 0) {
|
|
530
573
|
lines.push(`## ✅ Resolved (${resolved.length})`);
|
|
531
574
|
lines.push(``);
|
|
532
575
|
lines.push(`Fixed and verified (or confirmed no longer reproducing). A resolved finding that is re-found reopens automatically and is flagged as a regression above.`);
|
|
533
576
|
lines.push(``);
|
|
534
|
-
|
|
535
|
-
|
|
577
|
+
if (extras?.history === "full") {
|
|
578
|
+
for (const f of resolved)
|
|
579
|
+
renderFinding(f, true);
|
|
580
|
+
}
|
|
581
|
+
else {
|
|
582
|
+
// The least actionable content in the document: these are fixed. On one
|
|
583
|
+
// project they were 314 findings and 783 KB — nearly half the report,
|
|
584
|
+
// none of it anything to do. The index keeps the record without the bulk.
|
|
585
|
+
lines.push(`| Sev | Id | Fixed | Title |`, `|---|---|---:|---|`);
|
|
586
|
+
for (const f of resolved) {
|
|
587
|
+
lines.push(`| ${SEVERITY_ICON[f.severity]} | \`${f.id}\` | ${describeAge(f.foundAt, now)} ago | ${escapeTableCell(f.title)} |`);
|
|
588
|
+
}
|
|
589
|
+
lines.push(``);
|
|
590
|
+
}
|
|
536
591
|
}
|
|
537
592
|
if (extras?.createdResources && extras.createdResources.length > 0) {
|
|
538
593
|
lines.push(`## Data created by this session (cleanup list)`);
|
|
@@ -552,6 +607,15 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
552
607
|
}
|
|
553
608
|
lines.push(``);
|
|
554
609
|
}
|
|
610
|
+
// How the run was paced. A reader who sees a session that did four actions
|
|
611
|
+
// in an hour learns more from that than from another coverage percentage.
|
|
612
|
+
// Omitted where the report is compared byte for byte, since it is timing.
|
|
613
|
+
// The sessions still attached, so the stale-browser warning names only the
|
|
614
|
+
// ones that could act on it. A lane that finished and closed is quiet
|
|
615
|
+
// because it is gone.
|
|
616
|
+
if (extras?.pace !== false) {
|
|
617
|
+
lines.push(...formatPace(measurePace(memory.actionLog, Date.now(), extras?.attachedSessions ?? [])));
|
|
618
|
+
}
|
|
555
619
|
const markdown = lines.join("\n");
|
|
556
620
|
const outPath = path.join(memory.dir, "report.md");
|
|
557
621
|
const htmlPath = path.join(memory.dir, "report.html");
|