scenescout 3.0.0 → 3.1.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 +32 -0
- package/README.md +50 -8
- package/dist/engine/browser.js +61 -1
- package/dist/engine/live-page.js +230 -17
- package/dist/engine/live.js +40 -2
- package/dist/engine/replay.js +348 -0
- package/dist/engine/report.js +101 -1
- package/dist/mcp-server.js +84 -8
- package/package.json +1 -1
package/dist/engine/live.js
CHANGED
|
@@ -80,6 +80,8 @@ export function feedForSession(log, session, limit, redact = (s) => s) {
|
|
|
80
80
|
line.target = e.target;
|
|
81
81
|
if (e.result !== undefined)
|
|
82
82
|
line.result = e.result;
|
|
83
|
+
if (e.frame !== undefined)
|
|
84
|
+
line.frame = e.frame;
|
|
83
85
|
if (task !== undefined)
|
|
84
86
|
line.task = task;
|
|
85
87
|
lines.push(line);
|
|
@@ -90,8 +92,13 @@ export function feedForSession(log, session, limit, redact = (s) => s) {
|
|
|
90
92
|
}
|
|
91
93
|
/** How many feed lines a status poll carries per session. Enough to read the last move at a glance; the close-up asks for more. */
|
|
92
94
|
export const FEED_LINES = 6;
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The cap the close-up gets. A long run's log is thousands of lines and none
|
|
97
|
+
* of it needs to reach the page — but this is also what the timeline is drawn
|
|
98
|
+
* from, and a timeline that only reaches back a minute cannot be scrubbed.
|
|
99
|
+
* The whole run, every step of it, is the page at `run`.
|
|
100
|
+
*/
|
|
101
|
+
export const FEED_LINES_MAX = 300;
|
|
95
102
|
/**
|
|
96
103
|
* How long a call may run before the session is judged wedged rather than
|
|
97
104
|
* busy, when the entry does not carry the tool's own watchdog budget (an
|
|
@@ -251,6 +258,13 @@ export function watchTarget(input) {
|
|
|
251
258
|
};
|
|
252
259
|
return { url: `http://127.0.0.1:${port}/${clean}/` };
|
|
253
260
|
}
|
|
261
|
+
/** What `run` says when there is no run to show: a page, because a person navigated here. */
|
|
262
|
+
export const NO_RUN_PAGE = '<!doctype html><html lang="en"><head><meta charset="utf-8"><title>No run to show</title>' +
|
|
263
|
+
"<style>body{margin:0;display:grid;place-items:center;min-height:100vh;background:#0e1116;color:#e6e9ee;" +
|
|
264
|
+
'font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:34rem;padding:2rem;text-align:center}' +
|
|
265
|
+
"a{color:#7aa2ff}</style></head><body><main><h1>Nothing to show yet</h1>" +
|
|
266
|
+
"<p>This run has not produced a report. It may not have started, or its engine may have exited since you opened this page.</p>" +
|
|
267
|
+
'<p><a href="./">Back to the live board</a></p></main></body></html>';
|
|
254
268
|
const LIVE_CSP = "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; base-uri 'none'; form-action 'none'";
|
|
255
269
|
const BOUNDARY = "scenescoutframe";
|
|
256
270
|
/** A thumbnail younger than this is served again instead of taking a new one. */
|
|
@@ -381,6 +395,30 @@ export class LiveServer {
|
|
|
381
395
|
return this.send(res, 404, "no run attached");
|
|
382
396
|
return this.send(res, 200, JSON.stringify(report), "application/json; charset=utf-8");
|
|
383
397
|
}
|
|
398
|
+
if (route === "run" && !name) {
|
|
399
|
+
const doc = this.provider.replay();
|
|
400
|
+
// Somebody clicked a button or refreshed a bookmark to get here, so an
|
|
401
|
+
// answer they cannot read is worse than no link at all.
|
|
402
|
+
if (!doc)
|
|
403
|
+
return this.send(res, 404, NO_RUN_PAGE, "text/html; charset=utf-8", { "Content-Security-Policy": LIVE_CSP });
|
|
404
|
+
return this.send(res, 200, doc, "text/html; charset=utf-8", { "Content-Security-Policy": LIVE_CSP });
|
|
405
|
+
}
|
|
406
|
+
if (route === "record" && name) {
|
|
407
|
+
// A recorded frame, by the path the feed gave. The provider decides what
|
|
408
|
+
// exists; nothing here builds a filesystem path from the request.
|
|
409
|
+
const rel = parts.slice(2).join("/");
|
|
410
|
+
let asked;
|
|
411
|
+
try {
|
|
412
|
+
asked = decodeURIComponent(rel);
|
|
413
|
+
}
|
|
414
|
+
catch {
|
|
415
|
+
return this.send(res, 404, "not found");
|
|
416
|
+
}
|
|
417
|
+
const jpeg = await this.provider.frame(asked);
|
|
418
|
+
if (!jpeg)
|
|
419
|
+
return this.send(res, 404, "no such frame");
|
|
420
|
+
return this.send(res, 200, jpeg, "image/jpeg", { "Content-Length": jpeg.length, "Cache-Control": "private, max-age=3600" });
|
|
421
|
+
}
|
|
384
422
|
if (route === "events" && !name) {
|
|
385
423
|
const wanted = this.param(req, "sessions");
|
|
386
424
|
if (wanted === null)
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run, as one file that outlives it.
|
|
3
|
+
*
|
|
4
|
+
* The live view dies with the engine: its address is a port in a process, so
|
|
5
|
+
* refreshing after a run is over gets nothing. This builds a self-contained
|
|
6
|
+
* HTML document instead — the report, and every session's steps grouped by
|
|
7
|
+
* the task they served — written next to report.md. It opens from the file
|
|
8
|
+
* system with no server, works offline, and can be handed to somebody who was
|
|
9
|
+
* never watching.
|
|
10
|
+
*
|
|
11
|
+
* Frames appear beside the steps only when the run was recorded
|
|
12
|
+
* (`scout_attach {record:true}`), which is off by default: a recording is
|
|
13
|
+
* pictures of somebody's app sitting in their project folder, and ADR 7's
|
|
14
|
+
* "no frame touches the disk" is the rule it deliberately relaxes. The
|
|
15
|
+
* document is built the same way either way; a step with no frame simply
|
|
16
|
+
* shows none.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is pure string work — no browser, no filesystem — so the
|
|
19
|
+
* escaping and the grouping are table-tested.
|
|
20
|
+
*/
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
/** Most frames one recorded session keeps. A long run is thousands of actions, and a project folder is not a video store. */
|
|
23
|
+
export const RECORD_MAX_FRAMES = 600;
|
|
24
|
+
/**
|
|
25
|
+
* Where a recorded frame is stored, relative to the memory directory — and
|
|
26
|
+
* the path the live view serves it at, so it is always written with forward
|
|
27
|
+
* slashes. The session name is the agent's and the action the tool's, so both
|
|
28
|
+
* are reduced to a plain file name here: a session called `../../etc` decides
|
|
29
|
+
* nothing about where the engine writes.
|
|
30
|
+
*/
|
|
31
|
+
export function framePath(session, index, action) {
|
|
32
|
+
const plain = (text, fallback) => text
|
|
33
|
+
.replace(/[^a-z0-9._-]+/gi, "-")
|
|
34
|
+
.replace(/^[.-]+/, "")
|
|
35
|
+
.slice(0, 60) || fallback;
|
|
36
|
+
return `recordings/${plain(session, "session")}/${String(index).padStart(4, "0")}-${plain(action, "step")}.jpg`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* The file a viewer's frame request names, or null when it is not one of this
|
|
40
|
+
* run's frames. The path comes from a browser and may be anything, so it is
|
|
41
|
+
* resolved and then required to still be under the recordings directory —
|
|
42
|
+
* `..`, an absolute path and a sibling directory whose name merely starts the
|
|
43
|
+
* same way are all refused. The one filesystem rule in the frame route, kept
|
|
44
|
+
* here so it can be table-tested rather than reached only through HTTP.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveFrame(root, relPath) {
|
|
47
|
+
if (!relPath)
|
|
48
|
+
return null;
|
|
49
|
+
const inside = relPath.replace(/^recordings[\\/]/, "");
|
|
50
|
+
if (!inside || inside.includes("\0"))
|
|
51
|
+
return null;
|
|
52
|
+
// Both sides resolved: on Windows `path.resolve` prefixes the drive, so
|
|
53
|
+
// comparing its output against a root that has none rejects every frame.
|
|
54
|
+
const base = path.resolve(root);
|
|
55
|
+
const file = path.resolve(base, inside);
|
|
56
|
+
return file.startsWith(base + path.sep) ? file : null;
|
|
57
|
+
}
|
|
58
|
+
/** Text from the app under test reaches this document, so nothing is interpolated unescaped. */
|
|
59
|
+
export function escapeHtml(text) {
|
|
60
|
+
return text.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* A session's steps in the blocks its tasks made. Consecutive steps that
|
|
64
|
+
* served the same task are one block, so the document reads the way the live
|
|
65
|
+
* feed did: a change of task is a change of block, and of colour.
|
|
66
|
+
*/
|
|
67
|
+
export function taskBlocks(steps) {
|
|
68
|
+
const blocks = [];
|
|
69
|
+
for (const step of steps) {
|
|
70
|
+
const task = step.task ?? null;
|
|
71
|
+
const last = blocks[blocks.length - 1];
|
|
72
|
+
if (!last || last.task !== task)
|
|
73
|
+
blocks.push({ task, steps: [step] });
|
|
74
|
+
else
|
|
75
|
+
last.steps.push(step);
|
|
76
|
+
}
|
|
77
|
+
return blocks;
|
|
78
|
+
}
|
|
79
|
+
/** The viewer's own clock is not this document's to assume; times are shown as they were logged. */
|
|
80
|
+
function clock(iso) {
|
|
81
|
+
const at = iso.length >= 19 ? iso.slice(11, 19) : iso;
|
|
82
|
+
return at;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* When the document was written. The log is UTC and this page may be opened
|
|
86
|
+
* anywhere, so it says which clock it is quoting rather than implying the
|
|
87
|
+
* reader's own.
|
|
88
|
+
*/
|
|
89
|
+
function stamp(iso) {
|
|
90
|
+
return iso.length >= 16 ? `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC` : iso;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* What an image that will not load does. This page is meant to be handed on,
|
|
94
|
+
* and a copy sent without its `recordings/` folder would otherwise show a row
|
|
95
|
+
* of broken icons under a finding that still counts them as evidence.
|
|
96
|
+
*/
|
|
97
|
+
const GONE = `onerror="this.parentNode.classList.add('gone')"`;
|
|
98
|
+
/** A result that reads as a failure, so a step that went wrong is visible without reading every line. */
|
|
99
|
+
const BAD_RESULT = /error|fail|refus|block|violation|abandoned/i;
|
|
100
|
+
function renderStep(step, framePrefix = "", savedAt = "") {
|
|
101
|
+
const detail = [step.target, step.result ? `→ ${step.result}` : ""].filter(Boolean).join(" ") || step.url || "";
|
|
102
|
+
const bad = BAD_RESULT.test(step.result ?? "") ? " bad" : "";
|
|
103
|
+
const frame = step.frame
|
|
104
|
+
? `<a class="frame" href="${escapeHtml(framePrefix + step.frame)}" target="_blank" rel="noreferrer"><img loading="lazy" ${GONE} src="${escapeHtml(framePrefix + step.frame)}" alt="What the page showed at this step"></a>`
|
|
105
|
+
: "";
|
|
106
|
+
const where = savedAt && step.frame ? `<p class="onDisk">${escapeHtml(savedAt + "/" + step.frame)}</p>` : "";
|
|
107
|
+
return (`<li class="step"><div class="row"><span class="t">${escapeHtml(clock(step.at))}</span>` +
|
|
108
|
+
`<span class="a${bad}">${escapeHtml(step.action)}</span>` +
|
|
109
|
+
`<span class="d" title="${escapeHtml([step.target, step.result, step.url].filter(Boolean).join(" · "))}">${escapeHtml(detail)}</span></div>${frame}${where}</li>`);
|
|
110
|
+
}
|
|
111
|
+
function renderSession(s, index, framePrefix = "", savedAt = "") {
|
|
112
|
+
const blocks = taskBlocks(s.steps);
|
|
113
|
+
const body = blocks
|
|
114
|
+
.map((b, i) => {
|
|
115
|
+
const head = b.task ? `<p class="task">${escapeHtml(b.task)}</p>` : `<p class="task none">No task stated for these</p>`;
|
|
116
|
+
return `<section class="block g${i % 4}">${head}<ol class="steps">${b.steps.map((step) => renderStep(step, framePrefix, savedAt)).join("")}</ol></section>`;
|
|
117
|
+
})
|
|
118
|
+
.join("");
|
|
119
|
+
const framed = s.steps.filter((x) => x.frame).length;
|
|
120
|
+
return (`<details class="session"${index === 0 ? " open" : ""}><summary><b>${escapeHtml(s.session)}</b> <span class="role">${escapeHtml(s.role)}</span>` +
|
|
121
|
+
`<span class="count">${s.steps.length} steps · ${blocks.length} task${blocks.length === 1 ? "" : "s"}${framed ? ` · ${framed} frames` : ""}</span></summary>` +
|
|
122
|
+
(s.objective ? `<p class="objective">${escapeHtml(s.objective)}</p>` : "") +
|
|
123
|
+
body +
|
|
124
|
+
`</details>`);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The steps that were on screen just before a finding was filed. The report's
|
|
128
|
+
* own repro trace says what happened; on a recorded run these show it.
|
|
129
|
+
*/
|
|
130
|
+
export function evidenceFor(steps, foundAt, most = 4) {
|
|
131
|
+
const before = steps.filter((x) => x.frame && x.at <= foundAt);
|
|
132
|
+
return before.slice(-most).map((x) => ({
|
|
133
|
+
at: x.at,
|
|
134
|
+
action: x.action,
|
|
135
|
+
detail: [x.target, x.result ? `→ ${x.result}` : ""].filter(Boolean).join(" ") || x.url || "",
|
|
136
|
+
frame: x.frame,
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
function renderEvidence(e, framePrefix = "", savedAt = "") {
|
|
140
|
+
if (e.frames.length === 0)
|
|
141
|
+
return "";
|
|
142
|
+
const shots = e.frames
|
|
143
|
+
.map((f) => `<figure><img loading="lazy" ${GONE} src="${escapeHtml(framePrefix + f.frame)}" alt="The page when this step ran">` +
|
|
144
|
+
`<p class="gone-note">This frame is not beside this file. Frames live in the run's <code>recordings/</code> folder, which travels with it.</p>` +
|
|
145
|
+
`<figcaption>${escapeHtml(clock(f.at))} <b>${escapeHtml(f.action)}</b> ${escapeHtml(f.detail)}` +
|
|
146
|
+
(savedAt ? `<span class="onDisk">${escapeHtml(savedAt + "/" + f.frame)}</span>` : "") +
|
|
147
|
+
`</figcaption></figure>`)
|
|
148
|
+
.join("");
|
|
149
|
+
return `<details class="evidence"><summary>Evidence — the ${e.frames.length} step${e.frames.length === 1 ? "" : "s"} on screen before this was filed</summary><div class="shots">${shots}</div></details>`;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The report's markdown as elements. A deliberately small subset — headings,
|
|
153
|
+
* tables, lists, code fences, the report's own <details> repro blocks — built
|
|
154
|
+
* by escaping every piece of text, because finding titles and element names
|
|
155
|
+
* come from the app under test.
|
|
156
|
+
*/
|
|
157
|
+
export function renderMarkdown(md, evidence = [], framePrefix = "", savedAt = "") {
|
|
158
|
+
const out = [];
|
|
159
|
+
const lines = md.split("\n");
|
|
160
|
+
let i = 0;
|
|
161
|
+
let para = [];
|
|
162
|
+
const inline = (text) => escapeHtml(text)
|
|
163
|
+
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
164
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
165
|
+
const flush = () => {
|
|
166
|
+
if (para.length)
|
|
167
|
+
out.push(`<p>${inline(para.join(" "))}</p>`);
|
|
168
|
+
para = [];
|
|
169
|
+
};
|
|
170
|
+
while (i < lines.length) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
let m;
|
|
173
|
+
if (/^```/.test(line)) {
|
|
174
|
+
flush();
|
|
175
|
+
const code = [];
|
|
176
|
+
i += 1;
|
|
177
|
+
while (i < lines.length && !/^```/.test(lines[i])) {
|
|
178
|
+
code.push(lines[i]);
|
|
179
|
+
i += 1;
|
|
180
|
+
}
|
|
181
|
+
i += 1;
|
|
182
|
+
out.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
|
183
|
+
}
|
|
184
|
+
else if ((m = /^(#{1,6}) (.*)$/.exec(line))) {
|
|
185
|
+
flush();
|
|
186
|
+
const level = Math.min(6, m[1].length + 1);
|
|
187
|
+
out.push(`<h${level}>${inline(m[2])}</h${level}>`);
|
|
188
|
+
i += 1;
|
|
189
|
+
}
|
|
190
|
+
else if ((m = /^<details><summary>(.*)<\/summary>$/.exec(line))) {
|
|
191
|
+
flush();
|
|
192
|
+
out.push(`<details><summary>${inline(m[1])}</summary>`);
|
|
193
|
+
i += 1;
|
|
194
|
+
}
|
|
195
|
+
else if (/^<\/details>$/.test(line)) {
|
|
196
|
+
flush();
|
|
197
|
+
out.push(`</details>`);
|
|
198
|
+
i += 1;
|
|
199
|
+
}
|
|
200
|
+
else if (/^\|/.test(line)) {
|
|
201
|
+
flush();
|
|
202
|
+
const rows = [];
|
|
203
|
+
let first = true;
|
|
204
|
+
while (i < lines.length && /^\|/.test(lines[i])) {
|
|
205
|
+
const row = lines[i];
|
|
206
|
+
i += 1;
|
|
207
|
+
if (/^\|(\s*:?-+:?\s*\|)+\s*$/.test(row))
|
|
208
|
+
continue;
|
|
209
|
+
const cells = row
|
|
210
|
+
.replace(/^\||\|\s*$/g, "")
|
|
211
|
+
.split("|")
|
|
212
|
+
.map((c) => `<${first ? "th" : "td"}>${inline(c.trim())}</${first ? "th" : "td"}>`);
|
|
213
|
+
rows.push(`<tr>${cells.join("")}</tr>`);
|
|
214
|
+
first = false;
|
|
215
|
+
}
|
|
216
|
+
out.push(`<table>${rows.join("")}</table>`);
|
|
217
|
+
}
|
|
218
|
+
else if (/^\s*[-*] /.test(line)) {
|
|
219
|
+
flush();
|
|
220
|
+
const items = [];
|
|
221
|
+
// A finding's id sits in this list; its evidence goes straight under it,
|
|
222
|
+
// which is where a reader is when they ask "show me".
|
|
223
|
+
let found;
|
|
224
|
+
while (i < lines.length && (m = /^\s*[-*] (.*)$/.exec(lines[i]))) {
|
|
225
|
+
const id = /\*\*Id:\*\* `([0-9a-f]+)`/.exec(m[1])?.[1];
|
|
226
|
+
if (id)
|
|
227
|
+
found = evidence.find((e) => e.id === id);
|
|
228
|
+
items.push(`<li>${inline(m[1])}</li>`);
|
|
229
|
+
i += 1;
|
|
230
|
+
}
|
|
231
|
+
out.push(`<ul>${items.join("")}</ul>`);
|
|
232
|
+
if (found)
|
|
233
|
+
out.push(renderEvidence(found, framePrefix, savedAt));
|
|
234
|
+
}
|
|
235
|
+
else if (/^\d+\. /.test(line)) {
|
|
236
|
+
flush();
|
|
237
|
+
const items = [];
|
|
238
|
+
while (i < lines.length && (m = /^\d+\. (.*)$/.exec(lines[i]))) {
|
|
239
|
+
items.push(`<li>${inline(m[1])}</li>`);
|
|
240
|
+
i += 1;
|
|
241
|
+
}
|
|
242
|
+
out.push(`<ol>${items.join("")}</ol>`);
|
|
243
|
+
}
|
|
244
|
+
else if (line.trim() === "") {
|
|
245
|
+
flush();
|
|
246
|
+
i += 1;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
para.push(line);
|
|
250
|
+
i += 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
flush();
|
|
254
|
+
return out.join("\n");
|
|
255
|
+
}
|
|
256
|
+
const STYLE = `
|
|
257
|
+
:root { color-scheme: light dark; --bg:#f6f7f9; --panel:#fff; --line:#d9dde3; --text:#15181d; --muted:#5d6673; --accent:#2563eb; --bad:#b91c1c; }
|
|
258
|
+
@media (prefers-color-scheme: dark) { :root { --bg:#0e1116; --panel:#161a21; --line:#2a303a; --text:#e6e9ee; --muted:#98a2b3; --accent:#7aa2ff; --bad:#fca5a5; } }
|
|
259
|
+
* { box-sizing: border-box; }
|
|
260
|
+
body { margin:0; background:var(--bg); color:var(--text); font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif; }
|
|
261
|
+
header { position:sticky; top:0; z-index:2; display:flex; flex-wrap:wrap; gap:8px 16px; align-items:baseline; padding:14px 20px; background:var(--panel); border-bottom:1px solid var(--line); }
|
|
262
|
+
header h1 { margin:0; font-size:17px; }
|
|
263
|
+
header .meta { color:var(--muted); font-size:13px; }
|
|
264
|
+
nav { margin-left:auto; display:flex; gap:12px; font-size:14px; }
|
|
265
|
+
nav a { color:var(--accent); }
|
|
266
|
+
main { max-width:1000px; margin:0 auto; padding:24px 20px 64px; }
|
|
267
|
+
h2 { font-size:21px; margin:32px 0 12px; padding-top:20px; border-top:1px solid var(--line); }
|
|
268
|
+
h3 { font-size:17px; margin:24px 0 8px; }
|
|
269
|
+
h4 { font-size:15px; margin:20px 0 6px; }
|
|
270
|
+
table { border-collapse:collapse; margin:10px 0 16px; font-size:13px; }
|
|
271
|
+
th, td { border:1px solid var(--line); padding:5px 9px; text-align:left; vertical-align:top; }
|
|
272
|
+
th { background:var(--panel); }
|
|
273
|
+
code { font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; background:var(--panel); border:1px solid var(--line); border-radius:4px; padding:0 4px; }
|
|
274
|
+
pre { padding:12px; background:var(--panel); border:1px solid var(--line); border-radius:6px; overflow-x:auto; }
|
|
275
|
+
pre code { border:0; padding:0; background:none; }
|
|
276
|
+
details.session { background:var(--panel); border:1px solid var(--line); border-radius:8px; margin:12px 0; padding:10px 14px; }
|
|
277
|
+
details.session > summary { cursor:pointer; font-size:15px; display:flex; gap:10px; align-items:baseline; }
|
|
278
|
+
details.session .role { color:var(--muted); font-size:13px; }
|
|
279
|
+
details.session .count { margin-left:auto; color:var(--muted); font-size:12px; }
|
|
280
|
+
.objective { margin:8px 0 14px; color:var(--muted); }
|
|
281
|
+
.block { border-left:2px solid transparent; border-radius:4px; padding:6px 10px; margin:8px 0; }
|
|
282
|
+
.block.g0 { background:rgba(96,165,250,.22); border-color:rgba(96,165,250,.85); }
|
|
283
|
+
.block.g1 { background:rgba(52,211,153,.22); border-color:rgba(52,211,153,.85); }
|
|
284
|
+
.block.g2 { background:rgba(251,191,36,.24); border-color:rgba(251,191,36,.9); }
|
|
285
|
+
.block.g3 { background:rgba(244,114,182,.22); border-color:rgba(244,114,182,.85); }
|
|
286
|
+
@media (prefers-color-scheme: light) {
|
|
287
|
+
.block.g0 { background:rgba(37,99,235,.13); } .block.g1 { background:rgba(5,150,105,.13); }
|
|
288
|
+
.block.g2 { background:rgba(217,119,6,.15); } .block.g3 { background:rgba(219,39,119,.12); }
|
|
289
|
+
}
|
|
290
|
+
.task { margin:2px 0 8px; font-weight:600; }
|
|
291
|
+
.task.none { font-weight:400; color:var(--muted); font-style:italic; }
|
|
292
|
+
ol.steps { list-style:none; margin:0; padding:0; }
|
|
293
|
+
.step { margin:0 0 2px; }
|
|
294
|
+
.step .row { display:flex; gap:8px; font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }
|
|
295
|
+
.step .t { color:var(--muted); flex:0 0 auto; }
|
|
296
|
+
.step .a { font-weight:600; flex:0 0 auto; }
|
|
297
|
+
.step .a.bad { color:var(--bad); }
|
|
298
|
+
.step .d { color:var(--muted); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
299
|
+
.step .frame { display:block; margin:4px 0 10px; }
|
|
300
|
+
.step .frame img { max-width:min(100%,720px); max-height:360px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; }
|
|
301
|
+
details.evidence { margin:6px 0 18px; padding:8px 12px; background:var(--panel); border:1px solid var(--line); border-radius:8px; }
|
|
302
|
+
details.evidence > summary { cursor:pointer; color:var(--muted); font-size:13px; }
|
|
303
|
+
details.evidence .shots { display:flex; flex-wrap:wrap; gap:14px; margin-top:12px; }
|
|
304
|
+
details.evidence figure { margin:0; max-width:min(100%,460px); }
|
|
305
|
+
details.evidence img { width:100%; max-height:300px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
|
|
306
|
+
.onDisk { display:block; margin-top:4px; color:var(--muted); font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
|
|
307
|
+
header .served { flex:1 1 100%; margin:6px 0 0; color:var(--muted); font-size:12px; }
|
|
308
|
+
header .served code { font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
|
|
309
|
+
.gone-note { display:none; margin:0; padding:14px; border:1px dashed var(--line); border-radius:6px; color:var(--muted); font-size:12px; }
|
|
310
|
+
figure.gone .gone-note, a.gone .gone-note { display:block; }
|
|
311
|
+
figure.gone img, a.gone img { display:none; }
|
|
312
|
+
a.frame.gone { display:block; max-width:min(100%,720px); }
|
|
313
|
+
details.evidence figcaption { margin-top:4px; color:var(--muted); font:11px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; overflow-wrap:anywhere; }
|
|
314
|
+
`;
|
|
315
|
+
/** The whole document: one file, no external assets, opens from the file system. */
|
|
316
|
+
export function buildReplayHtml(input) {
|
|
317
|
+
const framed = input.sessions.some((s) => s.steps.some((x) => x.frame));
|
|
318
|
+
const prefix = input.framePrefix ?? "";
|
|
319
|
+
const savedAt = input.savedAt ?? "";
|
|
320
|
+
const sessions = input.sessions.map((s, i) => renderSession(s, i, prefix, savedAt)).join("\n");
|
|
321
|
+
return `<!doctype html>
|
|
322
|
+
<html lang="en">
|
|
323
|
+
<head>
|
|
324
|
+
<meta charset="utf-8">
|
|
325
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
326
|
+
<title>SceneScout run — ${escapeHtml(input.project)}</title>
|
|
327
|
+
<style>${STYLE}</style>
|
|
328
|
+
</head>
|
|
329
|
+
<body>
|
|
330
|
+
<header>
|
|
331
|
+
<h1>SceneScout run</h1>
|
|
332
|
+
<span class="meta">${escapeHtml(input.project)} · written ${escapeHtml(stamp(input.at))}${input.version ? ` · v${escapeHtml(input.version)}` : ""}</span>
|
|
333
|
+
${savedAt ? `<p class="served">This page is served by the engine and goes when it does. The copy that stays is <code>${escapeHtml(savedAt)}/report.html</code>, beside the frames it shows.</p>` : ""}
|
|
334
|
+
<nav><a href="#report">Report</a><a href="#steps">Steps</a></nav>
|
|
335
|
+
</header>
|
|
336
|
+
<main>
|
|
337
|
+
<h2 id="report">Report</h2>
|
|
338
|
+
${renderMarkdown(input.markdown, input.evidence ?? [], prefix, savedAt)}
|
|
339
|
+
<h2 id="steps">What each session did</h2>
|
|
340
|
+
<p class="objective">Every action, in the blocks its tasks made. ${framed
|
|
341
|
+
? "Each step shows the page as it was; click a frame to open it full size."
|
|
342
|
+
: "This run was not recorded, so there are no frames — attach with record:true to keep them."}</p>
|
|
343
|
+
${sessions || "<p>No session recorded any action.</p>"}
|
|
344
|
+
</main>
|
|
345
|
+
</body>
|
|
346
|
+
</html>
|
|
347
|
+
`;
|
|
348
|
+
}
|
package/dist/engine/report.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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 { feedForSession } from "./live.js";
|
|
5
|
+
import { buildReplayHtml, evidenceFor } from "./replay.js";
|
|
4
6
|
function playwrightSkeleton(f) {
|
|
5
7
|
const routeClass = f.state.split("#")[0].split("?")[0];
|
|
6
8
|
let gotoPath = routeClass;
|
|
@@ -56,6 +58,66 @@ function violationRollup(oracleLog) {
|
|
|
56
58
|
}
|
|
57
59
|
const SEVERITY_ORDER = { high: 0, medium: 1, low: 2 };
|
|
58
60
|
const SEVERITY_ICON = { high: "🔴", medium: "🟠", low: "🟡" };
|
|
61
|
+
/**
|
|
62
|
+
* What to call the project in a document meant to be handed on. The memory
|
|
63
|
+
* directory's absolute path names a person's home directory and often the
|
|
64
|
+
* machine it ran on; the project's own folder name says which project it was
|
|
65
|
+
* without any of that.
|
|
66
|
+
*/
|
|
67
|
+
function projectName(dir) {
|
|
68
|
+
const parent = path.dirname(dir);
|
|
69
|
+
return path.basename(parent) || path.basename(dir) || "project";
|
|
70
|
+
}
|
|
71
|
+
/** Every session that did anything, with its steps in order — what the HTML replays. */
|
|
72
|
+
function replaySessions(memory) {
|
|
73
|
+
const names = [...new Set(memory.actionLog.map((e) => e.session ?? "default"))];
|
|
74
|
+
return names.map((session) => ({
|
|
75
|
+
session,
|
|
76
|
+
// The role a session ran as is not on the log's entries; until it is, the
|
|
77
|
+
// session's own name is the honest label rather than a guess from coverage.
|
|
78
|
+
role: "",
|
|
79
|
+
// No redactor: the log is redacted as it is WRITTEN (memory.logAction
|
|
80
|
+
// covers url, target and result), so what is stored is already clean.
|
|
81
|
+
// Filtering again here would run a regex over every step of a long run to
|
|
82
|
+
// find what cannot be there. `replay-redaction` in contract-test pins it.
|
|
83
|
+
steps: feedForSession(memory.actionLog, session, Number.MAX_SAFE_INTEGER),
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
/** The frames that were on screen while each finding was being found. */
|
|
87
|
+
export function findingEvidence(memory, sessions) {
|
|
88
|
+
const all = sessions.flatMap((s) => s.steps).sort((a, b) => a.at.localeCompare(b.at));
|
|
89
|
+
return memory.findings
|
|
90
|
+
.map((f) => {
|
|
91
|
+
// The session that filed it, where it said so: with three browsers
|
|
92
|
+
// running at once, the run's whole log interleaves them, and the steps
|
|
93
|
+
// before a finding would come from whichever lane acted last.
|
|
94
|
+
const own = f.session ? sessions.find((s) => s.session === f.session) : undefined;
|
|
95
|
+
return { id: f.id, frames: evidenceFor(own ? own.steps : all, f.foundAt) };
|
|
96
|
+
})
|
|
97
|
+
.filter((e) => e.frames.length > 0);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The whole run as one page, for the live view to serve at its own address.
|
|
101
|
+
* Frames go through the view's own route, since the browser is reading this
|
|
102
|
+
* over HTTP rather than from the folder the frames live in.
|
|
103
|
+
*/
|
|
104
|
+
export function replayDocument(memory, markdown, version = "") {
|
|
105
|
+
const sessions = replaySessions(memory);
|
|
106
|
+
return buildReplayHtml({
|
|
107
|
+
markdown,
|
|
108
|
+
sessions,
|
|
109
|
+
evidence: findingEvidence(memory, sessions),
|
|
110
|
+
project: projectName(memory.dir),
|
|
111
|
+
at: new Date().toISOString(),
|
|
112
|
+
version,
|
|
113
|
+
framePrefix: "record/",
|
|
114
|
+
savedAt: memory.dir,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
/** What the live view shows under each finding on a recorded run: the frames it was found on. */
|
|
118
|
+
export function reportEvidence(memory) {
|
|
119
|
+
return findingEvidence(memory, replaySessions(memory));
|
|
120
|
+
}
|
|
59
121
|
/**
|
|
60
122
|
* Split an element key into words. Keys are `tid:some-test-id` or `role:name`,
|
|
61
123
|
* and testids come in every casing convention there is — a `\b`-anchored regex
|
|
@@ -492,13 +554,51 @@ export function generateReport(memory, oracleLog, extras, opts = {}) {
|
|
|
492
554
|
}
|
|
493
555
|
const markdown = lines.join("\n");
|
|
494
556
|
const outPath = path.join(memory.dir, "report.md");
|
|
495
|
-
|
|
557
|
+
const htmlPath = path.join(memory.dir, "report.html");
|
|
558
|
+
let htmlWritten = false;
|
|
559
|
+
let htmlProblem = "";
|
|
560
|
+
if (opts.write !== false) {
|
|
496
561
|
fs.writeFileSync(outPath, markdown);
|
|
562
|
+
// The same run as one file that outlives the engine: the live view's
|
|
563
|
+
// address is a port in a process, and refreshing after the run is over
|
|
564
|
+
// gets nothing. This opens from the file system, offline, forever.
|
|
565
|
+
try {
|
|
566
|
+
const sessions = replaySessions(memory);
|
|
567
|
+
fs.writeFileSync(htmlPath, buildReplayHtml({
|
|
568
|
+
markdown,
|
|
569
|
+
sessions,
|
|
570
|
+
evidence: findingEvidence(memory, sessions),
|
|
571
|
+
project: projectName(memory.dir),
|
|
572
|
+
at: new Date().toISOString(),
|
|
573
|
+
version: extras?.version ?? "",
|
|
574
|
+
}));
|
|
575
|
+
htmlWritten = true;
|
|
576
|
+
}
|
|
577
|
+
catch (err) {
|
|
578
|
+
// The markdown is the report of record and is already on disk, so this
|
|
579
|
+
// must not fail the report — but the summary then says what went wrong
|
|
580
|
+
// rather than naming a file that is not there.
|
|
581
|
+
htmlProblem = err instanceof Error ? err.message : String(err);
|
|
582
|
+
// An earlier run's page would otherwise sit beside a fresh report.md,
|
|
583
|
+
// carrying its own timestamp, looking like this run.
|
|
584
|
+
try {
|
|
585
|
+
fs.rmSync(htmlPath, { force: true });
|
|
586
|
+
}
|
|
587
|
+
catch {
|
|
588
|
+
htmlProblem += "; an older one may still be beside it";
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
497
592
|
// Bounded summary for the tool result: full reports have exceeded client
|
|
498
593
|
// token limits in real runs (66–72KB observed) — the wire gets the digest,
|
|
499
594
|
// the disk gets the document.
|
|
500
595
|
const summaryLines = [
|
|
501
596
|
`Report written to ${outPath}`,
|
|
597
|
+
...(htmlWritten
|
|
598
|
+
? [`The same run as one page, with every session's steps: ${htmlPath}`]
|
|
599
|
+
: htmlProblem
|
|
600
|
+
? [`The one-page version of this run could NOT be written (${htmlProblem}); ${outPath} is unaffected.`]
|
|
601
|
+
: []),
|
|
502
602
|
``,
|
|
503
603
|
`OPEN FINDINGS: ${open.length} (${open.filter((f) => f.severity === "high").length} high) — ${current.length} this session, ${historical.length} historical${resolved.length ? `, ${resolved.length} resolved` : ""}`,
|
|
504
604
|
...(extras && extras.routesTotal > 0
|