scenescout 3.0.1 → 3.1.1
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 +44 -0
- package/README.md +50 -8
- package/dist/engine/browser.js +61 -1
- package/dist/engine/live-page.js +200 -10
- package/dist/engine/live.js +40 -2
- package/dist/engine/replay.js +410 -0
- package/dist/engine/report.js +101 -1
- package/dist/mcp-server.js +84 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 99562e7: The run's page now notices when the engine behind it has exited.
|
|
8
|
+
|
|
9
|
+
That address is served by a process. Once the process is gone, reloading it
|
|
10
|
+
gets the browser's own "site can't be reached" and the tab is lost, although
|
|
11
|
+
everything on the page was still readable a moment earlier. The served copy
|
|
12
|
+
now watches the engine, and when it goes says so in place: the page is still
|
|
13
|
+
good, reloading will not reach anything, and the copy that survives — with its
|
|
14
|
+
frames — is at the path it names, ready to copy. Leaving the page from then on
|
|
15
|
+
asks first, so a reflexive refresh cannot throw it away.
|
|
16
|
+
|
|
17
|
+
A browser will not follow a `file://` link from a served page, so the saved
|
|
18
|
+
copy cannot be opened from there; the path is offered instead. The copy on
|
|
19
|
+
disk carries none of this and stays a plain document with no script in it.
|
|
20
|
+
|
|
21
|
+
## 3.1.0
|
|
22
|
+
|
|
23
|
+
### Minor Changes
|
|
24
|
+
|
|
25
|
+
- 52d97d8: Record a run, and read the whole thing back afterwards.
|
|
26
|
+
|
|
27
|
+
`scout_attach {record: true}` keeps a frame of the page after every action, and
|
|
28
|
+
`scout_report` then writes `report.html` beside `report.md`: the report, the
|
|
29
|
+
screenshots taken around each finding, and every session's trail in the blocks
|
|
30
|
+
its tasks made — one self-contained page that opens from the file system with
|
|
31
|
+
nothing running. Recording is off unless asked for, because the frames are
|
|
32
|
+
pictures of the app under test and no redaction can read a picture
|
|
33
|
+
([ADR 8](docs/adr/0008-a-recorded-run-is-evidence-and-must-be-asked-for.md)).
|
|
34
|
+
|
|
35
|
+
The live view serves the same document at its own address and goes there when
|
|
36
|
+
the run ends, so the report survives a refresh instead of dying with the board.
|
|
37
|
+
The close-up gains a timeline: a tick per action, coloured by task, that plays
|
|
38
|
+
a recorded run back while it is still going. A finding's screenshots also hang
|
|
39
|
+
under it in the live report panel, which no longer resets itself while it is
|
|
40
|
+
being read.
|
|
41
|
+
|
|
42
|
+
Fixes: a listener left on a control that had been replaced threw on load and
|
|
43
|
+
left the board blank; a viewer arriving after the last browser closed saw the
|
|
44
|
+
empty state instead of the finished run; a finding's evidence could be drawn
|
|
45
|
+
from a different session than the one that filed it.
|
|
46
|
+
|
|
3
47
|
## 3.0.1
|
|
4
48
|
|
|
5
49
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -187,28 +187,67 @@ The agent scans the project (if there is one), attaches read-only, explores, and
|
|
|
187
187
|
|
|
188
188
|
When a session attaches, the engine starts a small live view and hands the agent its address on a `Live view:` line, which the agent passes on to you. From a terminal, `scenescout watch` opens the same page. There is one card per session:
|
|
189
189
|
|
|
190
|
-
<p align="center"><img src="examples/screenshots/live-view.png" alt="The live view during a run of
|
|
190
|
+
<p align="center"><img src="examples/screenshots/live-view.png" alt="The live view during a run of three parallel agents against the demo app: one card per session, each with its role and objective, the task it is on, the tool it is running, the page it is on, a live thumbnail, and a feed of the actions it just took, tinted one colour per task" width="880" /></p>
|
|
191
191
|
|
|
192
192
|
- **What it is doing:** the tool it is running and for how long, the page it is on, and a thumbnail of that page. This works for headless runs too, which have no window to look at.
|
|
193
193
|
- **What it just did:** a rolling feed of its actions, each with its target and how it turned out, with failures in red. It is the same trail a finding's repro trace uses. The engine never sees the agent's reasoning, so this is what the session *did*, not what it thought.
|
|
194
194
|
- **Stuck, not slow:** a call still running past its own tool's watchdog budget turns the card red, so a wedged session is visible without asking. A crawl legitimately runs for minutes; it is judged against the crawl's budget, not a click's.
|
|
195
195
|
- **Live stream:** switch it on for one card, or for all of them. Click a thumbnail for a close-up.
|
|
196
196
|
- **The report, as it stands:** the Report button in the top bar shows the same document `scout_report` writes at the end, rendered from the run's current state, so findings can be read while the agents are still working.
|
|
197
|
-
- **What it is for:** the close-up puts the feed beside the session's brief
|
|
197
|
+
- **What it is for:** the close-up puts the feed beside the session's brief — the objective it was given when it attached (`scout_attach {objective}`), and underneath it the task it is on right now (`scout_task`), which the engine requires before any tool will act. Each task tints its own block of actions, so a change of task is a change of colour; point at a block and the brief names the task those actions served.
|
|
198
|
+
- **Scrub it back:** under the page is a tick per action, coloured by task. Click one to see the frame from that moment, and `Back to live` to return. On a run that was not recorded the ticks still read the trail; they just have no picture behind them.
|
|
198
199
|
|
|
199
|
-
<p align="center"><img src="examples/screenshots/live-view-closeup.png" alt="A close-up of one session:
|
|
200
|
+
<p align="center"><img src="examples/screenshots/live-view-closeup.png" alt="A close-up of one session: a frame from a step picked out of the timeline, the timeline itself as a tick per action coloured by task, the feed of the session's actions in the same colours, and beside it the objective and the task it is on" width="880" /></p>
|
|
200
201
|
|
|
201
|
-
<p align="center"><img src="examples/screenshots/live-view-report.png" alt="The report opened from the live view's top bar while the run is still going: summary table, gap ledger and the findings filed so far" width="880" /></p>
|
|
202
|
+
<p align="center"><img src="examples/screenshots/live-view-report.png" alt="The report opened from the live view's top bar while the run is still going: summary table, gap ledger, and the findings filed so far, each with an accordion of the screenshots taken around it" width="880" /></p>
|
|
202
203
|
|
|
203
|
-
The view is served on `127.0.0.1` only, behind a token that changes every time the engine starts. It answers `GET` and nothing else, so a viewer can watch a run but not act in it, and no frame is
|
|
204
|
+
The view is served on `127.0.0.1` only, behind a token that changes every time the engine starts. It answers `GET` and nothing else, so a viewer can watch a run but not act in it, and no frame it shows is written to disk ([ADR 7](docs/adr/0007-the-live-view-is-local-read-only-and-leaves-nothing-behind.md)) unless the run was recorded, which is asked for and off by default ([ADR 8](docs/adr/0008-a-recorded-run-is-evidence-and-must-be-asked-for.md)). A stream runs only while someone is watching it. `SCENESCOUT_LIVE=off` keeps the port closed.
|
|
204
205
|
|
|
205
|
-
**Try it with parallel agents.** The demo app has three roles and several separate areas, so a run can be split between agents. Start it with `npm run demo:serve`, then ask your agent to explore it with several agents in parallel, one role and one area each. The pictures above come from a run of
|
|
206
|
+
**Try it with parallel agents.** The demo app has three roles and several separate areas, so a run can be split between agents. Start it with `npm run demo:serve`, then ask your agent to explore it with several agents in parallel, one role and one area each. The pictures above come from a run of three. Two things keep a parallel run efficient:
|
|
206
207
|
|
|
207
208
|
- **Each agent opens its own session when it starts and closes it when it is done.** An agent waiting for its turn then holds no browser. Opening every session up front leaves browsers idling while the machine runs out of memory for the agents that are working.
|
|
208
209
|
- **Run about as many agents at once as your machine has cores, less two.** Each one drives a real browser.
|
|
209
210
|
|
|
210
211
|
---
|
|
211
212
|
|
|
213
|
+
## 🎬 Recording a run, and reading it back
|
|
214
|
+
|
|
215
|
+
A report says what happened. For QA work that is not always enough — the point
|
|
216
|
+
is often to *show* what was checked, not to assert it. Ask for a recorded run
|
|
217
|
+
and the engine keeps a frame of the page after every action:
|
|
218
|
+
|
|
219
|
+
```
|
|
220
|
+
Use SceneScout to test http://localhost:3000, record the run
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
or, on the tool directly, `scout_attach {record: true}`.
|
|
224
|
+
|
|
225
|
+
Then `scout_report` writes two files side by side in `.scenescout/`:
|
|
226
|
+
`report.md` as always, and `report.html` — the whole run as one self-contained
|
|
227
|
+
page. It opens from the file system with nothing running, needs no network, and
|
|
228
|
+
holds:
|
|
229
|
+
|
|
230
|
+
- **The report**, rendered from the same Markdown.
|
|
231
|
+
- **The screenshots around each finding**, in an accordion under it, from the
|
|
232
|
+
session that filed it.
|
|
233
|
+
- **Every session's trail**, in the blocks its tasks made, each step with the
|
|
234
|
+
page as it was at that moment.
|
|
235
|
+
|
|
236
|
+
<p align="center"><img src="examples/screenshots/run-page.png" alt="The saved copy of a run, opened from the file system with nothing running: a finding with its evidence accordion open, showing the four screenshots taken around it with the action and time under each" width="880" /></p>
|
|
237
|
+
|
|
238
|
+
The live view serves the same document at `run` while the engine is still up,
|
|
239
|
+
and sends you there when the run ends — so the address survives a refresh
|
|
240
|
+
instead of a panel over a dead board.
|
|
241
|
+
|
|
242
|
+
**What it costs.** Frames are pictures of the app under test, inside the tested
|
|
243
|
+
project's folder, and the secret redaction that protects everything else the
|
|
244
|
+
engine writes cannot read a picture. That is why it is off unless asked for,
|
|
245
|
+
capped per session, and written only under `.scenescout/`, which ignores itself
|
|
246
|
+
so `git add -A` in the tested project cannot pick the frames up. The reasoning is in
|
|
247
|
+
[ADR 8](docs/adr/0008-a-recorded-run-is-evidence-and-must-be-asked-for.md).
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
212
251
|
## 🔄 How a run works
|
|
213
252
|
|
|
214
253
|
One curiosity loop, repeated — breadth first, then judgment where it matters:
|
|
@@ -242,7 +281,7 @@ Snapshots are cheap: re-snapshotting a route returns only *what changed*, with s
|
|
|
242
281
|
| **Look** | `scout_snapshot` `scout_hover` `scout_screenshot` | Read the structured scene (diffed); reveal tooltips/hover cards; capture pixels only when needed |
|
|
243
282
|
| **Act** | `scout_click` `scout_type` `scout_select` `scout_upload` `scout_press` `scout_scroll` `scout_navigate` `scout_back` `scout_run_plan` | Drive the UI like a user; `scout_run_plan` batches a whole mechanical sequence into one call |
|
|
244
283
|
| **Assess** | `scout_design_audit` `scout_journey` | Score a page's craft/a11y/consistency; measure how hard a task is to complete |
|
|
245
|
-
| **Record** | `scout_note` `scout_finding` `scout_resolve` `scout_report` | Curate durable notes; file deduped findings; mark fixes;
|
|
284
|
+
| **Record** | `scout_note` `scout_finding` `scout_resolve` `scout_report` | Curate durable notes; file deduped findings; mark fixes; write the report, and on a recorded run the whole run as one page |
|
|
246
285
|
| **Close** | `scout_close` | Tear down one session or all |
|
|
247
286
|
|
|
248
287
|
A few that punch above their weight:
|
|
@@ -290,6 +329,8 @@ A `🛡 WRITE-POLICY blocked` notice is the safety net doing its job, not an app
|
|
|
290
329
|
- 👥 **A role capability matrix** — what each role could and couldn't reach.
|
|
291
330
|
- 🧾 **A gap ledger** — everything *not* done, so the report is honest about its own coverage.
|
|
292
331
|
|
|
332
|
+
`.scenescout/report.html` — the same report as one self-contained page, with every session's trail beside it, and on a [recorded run](#-recording-a-run-and-reading-it-back) the screenshots under each finding.
|
|
333
|
+
|
|
293
334
|
👀 Watch a run live: `node dist/cli.js status <project-path>`.
|
|
294
335
|
|
|
295
336
|
---
|
|
@@ -514,8 +555,9 @@ src/
|
|
|
514
555
|
design.ts the design audit + page scoring
|
|
515
556
|
memory.ts cross-run storage + finding dedup
|
|
516
557
|
report.ts the gap ledger + report generation
|
|
558
|
+
replay.ts the run as one page: steps, tasks, frames under each finding
|
|
517
559
|
… collector · dispatch · fixtures · authloss · reaper
|
|
518
|
-
scripts/ the
|
|
560
|
+
scripts/ the 14 test suites (smoke/ holds the real-browser ones)
|
|
519
561
|
test-app/ fixtures for the real-browser smoke tests
|
|
520
562
|
skills/scenescout/ the testing method (SKILL.md): a skill in Claude Code, served by the server everywhere else
|
|
521
563
|
docs/adr/ why it's built this way
|
package/dist/engine/browser.js
CHANGED
|
@@ -10,6 +10,7 @@ import { COLLECT_INTERACTABLES_SCRIPT, VISIBLE_SRC, geometryIssues, BROKEN_IMAGE
|
|
|
10
10
|
import { OracleMonitor, formatViolations } from "./oracles.js";
|
|
11
11
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
12
12
|
import { formatJourney, measureJourney } from "./journey.js";
|
|
13
|
+
import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
|
|
13
14
|
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, } from "../browsers.js";
|
|
14
15
|
import { revealedLines } from "./hover.js";
|
|
15
16
|
import { explainLaunchFailure, isMissingBrowser } from "./launch.js";
|
|
@@ -102,6 +103,8 @@ function actionabilityDiagnostic(message) {
|
|
|
102
103
|
*/
|
|
103
104
|
/** A screencast whose page has been gone for this many 500 ms ticks ends and says so; a re-attach takes fewer. */
|
|
104
105
|
const SCREENCAST_PAGELESS_TICKS = 20;
|
|
106
|
+
/** A frame for the record is worth a moment, not a stall: the action has already happened. */
|
|
107
|
+
const RECORD_SHOT_TIMEOUT_MS = 2500;
|
|
105
108
|
export class BrowserEngine {
|
|
106
109
|
browser = null;
|
|
107
110
|
context = null;
|
|
@@ -206,6 +209,57 @@ export class BrowserEngine {
|
|
|
206
209
|
journey = null;
|
|
207
210
|
/** The session's objective: the whole remit the agent was given at scout_attach. Empty when none was given. */
|
|
208
211
|
sessionObjective = "";
|
|
212
|
+
/**
|
|
213
|
+
* Keep a frame of the page after each action, as evidence. Off by default:
|
|
214
|
+
* a recording is pictures of somebody's app sitting in their project
|
|
215
|
+
* folder, which is the rule ADR 7 otherwise holds ("no frame touches the
|
|
216
|
+
* disk"). QA work is what earns the exception — a report says what was
|
|
217
|
+
* checked, a recording shows it.
|
|
218
|
+
*/
|
|
219
|
+
recording = false;
|
|
220
|
+
framesKept = 0;
|
|
221
|
+
/** How many frames could not be written. The first one says so in the log; the rest are counted. */
|
|
222
|
+
framesFailed = 0;
|
|
223
|
+
/**
|
|
224
|
+
* The frame for the step just taken, as a path relative to the memory
|
|
225
|
+
* directory, or undefined when this run is not recorded. Failing to write
|
|
226
|
+
* one must never fail the action: evidence is worth having, not worth
|
|
227
|
+
* losing a run over.
|
|
228
|
+
*/
|
|
229
|
+
async recordFrame(action) {
|
|
230
|
+
const dir = this.memory?.dir;
|
|
231
|
+
if (!this.recording || !dir || this.framesKept >= RECORD_MAX_FRAMES)
|
|
232
|
+
return undefined;
|
|
233
|
+
const jpeg = await this.liveShot(RECORD_SHOT_TIMEOUT_MS);
|
|
234
|
+
if (!jpeg)
|
|
235
|
+
return undefined;
|
|
236
|
+
const rel = framePath(this.sessionKey, this.framesKept + 1, action);
|
|
237
|
+
try {
|
|
238
|
+
await fs.promises.mkdir(path.dirname(path.join(dir, rel)), { recursive: true });
|
|
239
|
+
await fs.promises.writeFile(path.join(dir, rel), jpeg);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
// A frame that cannot be written must not fail the action, but a run
|
|
243
|
+
// that keeps NOTHING — a read-only checkout, a full disk — would
|
|
244
|
+
// otherwise finish silently and produce a report with no evidence in
|
|
245
|
+
// it, which is the one thing recording exists to prevent. Said once,
|
|
246
|
+
// through the action log, so it reaches the live feed and the report.
|
|
247
|
+
this.framesFailed += 1;
|
|
248
|
+
if (this.framesFailed === 1) {
|
|
249
|
+
this.logAction({
|
|
250
|
+
action: "record:failed",
|
|
251
|
+
target: `no frame could be written under ${dir} (${err instanceof Error ? err.message : String(err)}); later failures are not repeated here`,
|
|
252
|
+
url: this.page?.url() ?? "",
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return undefined;
|
|
256
|
+
}
|
|
257
|
+
this.framesKept += 1;
|
|
258
|
+
if (this.framesKept === RECORD_MAX_FRAMES) {
|
|
259
|
+
this.logAction({ action: "record:full", target: `${RECORD_MAX_FRAMES} frames kept; later steps have none`, url: this.page?.url() ?? "" });
|
|
260
|
+
}
|
|
261
|
+
return rel;
|
|
262
|
+
}
|
|
209
263
|
/**
|
|
210
264
|
* The batch of actions running right now. Required before a tool may act
|
|
211
265
|
* (task.ts), stated by the agent on the call or by a journey, and kept
|
|
@@ -352,6 +406,11 @@ export class BrowserEngine {
|
|
|
352
406
|
}
|
|
353
407
|
this.mode = opts.mode ?? "read-only";
|
|
354
408
|
this.sessionObjective = (opts.objective ?? "").trim().replace(/\s+/g, " ").slice(0, 300);
|
|
409
|
+
this.recording = opts.record === true;
|
|
410
|
+
// A re-attached engine starts a new recording: numbering from where the
|
|
411
|
+
// last one stopped would run into the cap with frames it never took.
|
|
412
|
+
this.framesKept = 0;
|
|
413
|
+
this.framesFailed = 0;
|
|
355
414
|
this.headed = opts.headed ?? false;
|
|
356
415
|
this.blockedRequests = [];
|
|
357
416
|
this.pendingCreations = new Set();
|
|
@@ -887,7 +946,8 @@ export class BrowserEngine {
|
|
|
887
946
|
blocked +
|
|
888
947
|
formatViolations(this.oracles.drain()));
|
|
889
948
|
}
|
|
890
|
-
this.
|
|
949
|
+
const frame = await this.recordFrame(action);
|
|
950
|
+
this.logAction({ action, target, url, ...(frame ? { frame } : {}) });
|
|
891
951
|
await this.scanForInjections();
|
|
892
952
|
const violations = this.oracles.drain();
|
|
893
953
|
const mutations = this.drainMutations() + this.drainBlocked() + this.drainCreated();
|
package/dist/engine/live-page.js
CHANGED
|
@@ -54,7 +54,8 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
54
54
|
#finished h2 { margin: 0 0 8px; font-size: 18px; }
|
|
55
55
|
#finished p { margin: 0 0 8px; color: var(--muted); }
|
|
56
56
|
#finished .where { font: 12px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; overflow-wrap: anywhere; }
|
|
57
|
-
#finished
|
|
57
|
+
#finished .btn { display: inline-block; margin-top: 8px; padding: 8px 16px; font-weight: 600; text-decoration: none;
|
|
58
|
+
color: #fff; background: var(--accent); border-radius: 6px; }
|
|
58
59
|
main { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr)); gap: 12px; padding: 16px; }
|
|
59
60
|
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; display: flex; flex-direction: column; }
|
|
60
61
|
.card.stuck { border-color: var(--stuck); }
|
|
@@ -128,6 +129,18 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
128
129
|
#focus .bar .line { color: #cbd5e1; padding: 0; flex: 1 1 auto; }
|
|
129
130
|
#focus .bar .line b { color: #fff; }
|
|
130
131
|
#focus .stage { flex: 1 1 auto; min-height: 0; position: relative; }
|
|
132
|
+
#timeline { flex: 0 0 auto; display: flex; gap: 2px; overflow-x: auto; padding: 6px 0 2px; scrollbar-width: thin; }
|
|
133
|
+
#timeline button { flex: 0 0 auto; width: 16px; height: 26px; padding: 0; border: 0; border-radius: 3px; cursor: pointer;
|
|
134
|
+
background: rgba(230,233,238,.18); }
|
|
135
|
+
#timeline button.g0 { background: rgba(96,165,250,.75); } #timeline button.g1 { background: rgba(52,211,153,.75); }
|
|
136
|
+
#timeline button.g2 { background: rgba(251,191,36,.8); } #timeline button.g3 { background: rgba(244,114,182,.75); }
|
|
137
|
+
#timeline button.framed { height: 34px; }
|
|
138
|
+
#timeline .none { color: var(--muted); font-size: 12px; align-self: center; }
|
|
139
|
+
#timeline button.bad { outline: 2px solid var(--stuck); outline-offset: -2px; }
|
|
140
|
+
#timeline button[aria-pressed="true"] { outline: 2px solid #fff; outline-offset: -2px; }
|
|
141
|
+
#focus .scrub { display: flex; align-items: center; gap: 10px; color: #cbd5e1; font-size: 12px; }
|
|
142
|
+
#focus .scrub b { color: #fff; }
|
|
143
|
+
#focus .scrub button { padding: 3px 10px; }
|
|
131
144
|
#focus img { width: 100%; height: 100%; object-fit: contain; background: #000; border-radius: 6px; display: block; }
|
|
132
145
|
#focus .none { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; padding: 16px;
|
|
133
146
|
color: #98a2b3; font-size: 13px; text-align: center; }
|
|
@@ -152,6 +165,18 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
152
165
|
#report .doc details { margin: 8px 0; }
|
|
153
166
|
#report .doc summary { cursor: pointer; color: var(--muted); }
|
|
154
167
|
#report .doc .unset { color: var(--muted); font-style: italic; }
|
|
168
|
+
#report .doc details.evidence { margin: 10px 0 16px; padding: 8px 12px; background: var(--panel); border: 1px solid var(--line); border-radius: 6px; }
|
|
169
|
+
#report .doc details.evidence[open] summary { margin-bottom: 10px; }
|
|
170
|
+
#report .doc details.evidence figure { margin: 0 0 12px; }
|
|
171
|
+
#report .doc details.evidence .shots { display: flex; flex-wrap: wrap; gap: 14px; }
|
|
172
|
+
#report .doc details.evidence figure { flex: 1 1 320px; max-width: 440px; }
|
|
173
|
+
#report .doc details.evidence img { display: block; width: 100%; max-height: 280px; object-fit: cover; object-position: top;
|
|
174
|
+
border: 1px solid var(--line); border-radius: 4px; background: var(--shade); }
|
|
175
|
+
#report .doc details.evidence .gone img { display: none; }
|
|
176
|
+
#report .doc details.evidence .gone-note { display: none; margin: 0 0 4px; padding: 12px; border: 1px dashed var(--line);
|
|
177
|
+
border-radius: 4px; color: var(--muted); font-size: 12px; }
|
|
178
|
+
#report .doc details.evidence .gone .gone-note { display: block; }
|
|
179
|
+
#report .doc details.evidence figcaption { margin-top: 4px; font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--muted); }
|
|
155
180
|
@media (prefers-reduced-motion: no-preference) { .badge.running { animation: pulse 1.6s ease-in-out infinite; } }
|
|
156
181
|
@keyframes pulse { 50% { opacity: .55; } }
|
|
157
182
|
</style>
|
|
@@ -173,7 +198,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
173
198
|
<h2>The run has finished</h2>
|
|
174
199
|
<p>Its browsers are closed, so there is nothing left to watch. What it found is in the report.</p>
|
|
175
200
|
<p class="where" id="finished-where" data-testid="live-finished-where"></p>
|
|
176
|
-
<
|
|
201
|
+
<p><a class="btn" href="run" id="finished-run" data-testid="live-finished-run">Open the whole run</a></p>
|
|
177
202
|
</div>
|
|
178
203
|
<main id="grid"></main>
|
|
179
204
|
<div id="report" role="dialog" aria-modal="true" aria-label="The run's report" data-testid="live-report-dialog">
|
|
@@ -196,6 +221,9 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
196
221
|
<img id="focus-img" alt="">
|
|
197
222
|
<span class="none">No frame available. The session's page may be closed, or not answering.</span>
|
|
198
223
|
</div>
|
|
224
|
+
<div class="scrub"><span id="scrub-where" data-testid="live-scrub-where">Live</span><span class="spacer"></span>
|
|
225
|
+
<button type="button" id="scrub-live" data-testid="live-scrub-live" hidden>Back to live</button></div>
|
|
226
|
+
<div id="timeline" data-testid="live-timeline" role="group" aria-label="The steps this session took"></div>
|
|
199
227
|
<div class="lower">
|
|
200
228
|
<div class="feed" id="focus-feed" data-testid="live-focus-feed"></div>
|
|
201
229
|
<aside class="brief" aria-label="What this session is doing" data-testid="live-focus-brief">
|
|
@@ -220,6 +248,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
220
248
|
var eventsKey = '';
|
|
221
249
|
var frames = {};
|
|
222
250
|
var hoverTask = null;
|
|
251
|
+
// A step the viewer picked from the timeline: the stage shows its frame
|
|
252
|
+
// instead of the live page, so a run can be watched back while it runs.
|
|
253
|
+
var scrubbed = null;
|
|
254
|
+
var timelineLines = [];
|
|
223
255
|
var reportOpen = false;
|
|
224
256
|
var reportTimer = null;
|
|
225
257
|
var reportProblem = null;
|
|
@@ -230,6 +262,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
230
262
|
var reportShown = false;
|
|
231
263
|
var reportFile = null;
|
|
232
264
|
var reportMarkdown = null;
|
|
265
|
+
// What the panel last rendered, so an unchanged report is left alone.
|
|
266
|
+
var reportKey = null;
|
|
267
|
+
// Per-finding frames from api/report: empty unless the run was recorded.
|
|
268
|
+
var reportEvidence = [];
|
|
233
269
|
var savedACopy = false;
|
|
234
270
|
// A result that reads as a failure is shown in red.
|
|
235
271
|
var BAD_RESULT = /error|fail|refus|block|violation|abandoned/i;
|
|
@@ -276,7 +312,7 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
276
312
|
frames[d.session] = src;
|
|
277
313
|
var card = cards[d.session];
|
|
278
314
|
if (card && card.live) card.img.src = src;
|
|
279
|
-
if (focused === d.session) document.getElementById('focus-img').src = src;
|
|
315
|
+
if (focused === d.session && !scrubbed) document.getElementById('focus-img').src = src;
|
|
280
316
|
});
|
|
281
317
|
events.addEventListener('unavailable', function (e) {
|
|
282
318
|
var d;
|
|
@@ -375,6 +411,35 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
375
411
|
}
|
|
376
412
|
if (last < text.length) node.appendChild(document.createTextNode(text.slice(last)));
|
|
377
413
|
}
|
|
414
|
+
/**
|
|
415
|
+
* The frames recorded around one finding, as a closed accordion. Nothing is
|
|
416
|
+
* shown when the run was not recorded: the report reads the same as before.
|
|
417
|
+
*/
|
|
418
|
+
function evidenceFor(id) {
|
|
419
|
+
var found = null;
|
|
420
|
+
reportEvidence.forEach(function (e) { if (e.id === id) found = e; });
|
|
421
|
+
if (!found || !found.frames.length) return null;
|
|
422
|
+
var box = el('details', 'evidence');
|
|
423
|
+
box.setAttribute('data-testid', 'live-report-evidence-' + id);
|
|
424
|
+
box.appendChild(el('summary', '', found.frames.length + (found.frames.length === 1 ? ' screenshot' : ' screenshots') + ' from around this finding'));
|
|
425
|
+
var shots = el('div', 'shots');
|
|
426
|
+
box.appendChild(shots);
|
|
427
|
+
found.frames.forEach(function (f) {
|
|
428
|
+
var fig = el('figure');
|
|
429
|
+
var img = el('img');
|
|
430
|
+
img.loading = 'lazy';
|
|
431
|
+
img.src = 'record/' + f.frame;
|
|
432
|
+
img.alt = f.action + ' ' + f.detail;
|
|
433
|
+
// A recording deleted since the run leaves a broken icon under a finding
|
|
434
|
+
// that still counts it as evidence. Say which it is.
|
|
435
|
+
img.addEventListener('error', function () { fig.classList.add('gone'); });
|
|
436
|
+
fig.appendChild(img);
|
|
437
|
+
fig.appendChild(el('figcaption', '', clock(f.at) + ' · ' + f.action + ' · ' + f.detail));
|
|
438
|
+
fig.appendChild(el('p', 'gone-note', 'This frame is no longer in ' + f.frame.replace(/\\/[^/]*$/, '') + '.'));
|
|
439
|
+
shots.appendChild(fig);
|
|
440
|
+
});
|
|
441
|
+
return box;
|
|
442
|
+
}
|
|
378
443
|
function renderMarkdown(root, md) {
|
|
379
444
|
root.textContent = '';
|
|
380
445
|
var lines = md.split('\\n');
|
|
@@ -390,14 +455,23 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
390
455
|
}
|
|
391
456
|
function list(tag, re) {
|
|
392
457
|
var box = el(tag);
|
|
458
|
+
var ids = [];
|
|
393
459
|
var m;
|
|
394
460
|
while (i < lines.length && (m = re.exec(lines[i]))) {
|
|
395
461
|
var li = el('li');
|
|
396
462
|
inline(li, m[1]);
|
|
397
463
|
box.appendChild(li);
|
|
464
|
+
// A finding names its id on its first bullet; the frames recorded
|
|
465
|
+
// around it hang under that list, so the proof sits with the claim.
|
|
466
|
+
var id = /^\\*\\*Id:\\*\\* \`([^\`]+)\`/.exec(m[1]);
|
|
467
|
+
if (id) ids.push(id[1]);
|
|
398
468
|
i += 1;
|
|
399
469
|
}
|
|
400
470
|
container.appendChild(box);
|
|
471
|
+
ids.forEach(function (id) {
|
|
472
|
+
var shots = evidenceFor(id);
|
|
473
|
+
if (shots) container.appendChild(shots);
|
|
474
|
+
});
|
|
401
475
|
}
|
|
402
476
|
while (i < lines.length) {
|
|
403
477
|
var line = lines[i];
|
|
@@ -482,8 +556,26 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
482
556
|
return;
|
|
483
557
|
}
|
|
484
558
|
reportMarkdown = d.markdown;
|
|
559
|
+
reportEvidence = d.evidence || [];
|
|
485
560
|
meta.textContent = (finished ? 'as the run left it at ' : 'as the run stands at ') + clock(d.at) + ' · ' + whereItIs();
|
|
561
|
+
// The report is re-read every few seconds while the run goes on.
|
|
562
|
+
// Re-rendering an unchanged document threw away what the reader was
|
|
563
|
+
// doing with it: an opened accordion shut itself, and the page jumped
|
|
564
|
+
// back to the top, every five seconds.
|
|
565
|
+
var key = d.markdown + '\\u0000' + JSON.stringify(reportEvidence);
|
|
566
|
+
if (key === reportKey) return;
|
|
567
|
+
var open = {};
|
|
568
|
+
var was = doc.querySelectorAll('details[open][data-testid]');
|
|
569
|
+
for (var k = 0; k < was.length; k += 1) open[was[k].getAttribute('data-testid')] = true;
|
|
570
|
+
var top = doc.parentNode ? doc.parentNode.scrollTop : 0;
|
|
571
|
+
reportKey = key;
|
|
486
572
|
renderMarkdown(doc, d.markdown);
|
|
573
|
+
// A finding whose evidence the reader had open stays open through a change.
|
|
574
|
+
Object.keys(open).forEach(function (id) {
|
|
575
|
+
var node = doc.querySelector('details[data-testid="' + id + '"]');
|
|
576
|
+
if (node) node.open = true;
|
|
577
|
+
});
|
|
578
|
+
if (doc.parentNode) doc.parentNode.scrollTop = top;
|
|
487
579
|
})
|
|
488
580
|
.catch(function (err) {
|
|
489
581
|
// The last rendering stays; with none, say why there is nothing to read.
|
|
@@ -531,6 +623,65 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
531
623
|
document.getElementById('report-open').focus();
|
|
532
624
|
}
|
|
533
625
|
|
|
626
|
+
// One tick per step, coloured by the task it served: a run can be scrolled
|
|
627
|
+
// back through while it is still going, and a step with a frame is taller.
|
|
628
|
+
function renderTimeline(lines) {
|
|
629
|
+
// Kept so a re-render (picking a step, say) draws the same run back. The
|
|
630
|
+
// status poll carries six lines and the close-up three hundred: redrawing
|
|
631
|
+
// from whichever was nearest to hand shrank the timeline to six ticks.
|
|
632
|
+
timelineLines = lines || timelineLines;
|
|
633
|
+
lines = timelineLines;
|
|
634
|
+
var node = document.getElementById('timeline');
|
|
635
|
+
var atEnd = node.scrollLeft + node.clientWidth >= node.scrollWidth - 8;
|
|
636
|
+
node.textContent = '';
|
|
637
|
+
var groups = 0;
|
|
638
|
+
var last = null;
|
|
639
|
+
(lines || []).forEach(function (line) {
|
|
640
|
+
var task = line.task || '';
|
|
641
|
+
if (task && task !== last) groups += 1;
|
|
642
|
+
last = task;
|
|
643
|
+
var tick = el('button');
|
|
644
|
+
tick.type = 'button';
|
|
645
|
+
tick.className = (task ? 'g' + ((groups - 1) % 4) : '') + (line.frame ? ' framed' : '') + (BAD_RESULT.test(line.result || '') ? ' bad' : '');
|
|
646
|
+
tick.title = clock(line.at) + ' ' + line.action + (line.target ? ' ' + line.target : '') + (task ? '\\n' + task : '');
|
|
647
|
+
tick.setAttribute('aria-label', tick.title);
|
|
648
|
+
tick.setAttribute('aria-pressed', scrubbed && scrubbed.at === line.at && scrubbed.action === line.action ? 'true' : 'false');
|
|
649
|
+
tick.addEventListener('click', function () { showStep(line); });
|
|
650
|
+
node.appendChild(tick);
|
|
651
|
+
});
|
|
652
|
+
if (atEnd) node.scrollLeft = node.scrollWidth;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Show one step: its frame if the run was recorded, and what it was doing.
|
|
656
|
+
function showStep(line) {
|
|
657
|
+
var where = document.getElementById('scrub-where');
|
|
658
|
+
var back = document.getElementById('scrub-live');
|
|
659
|
+
scrubbed = line;
|
|
660
|
+
back.hidden = false;
|
|
661
|
+
if (!line.frame) {
|
|
662
|
+
where.textContent = clock(line.at) + ' ' + line.action + ' — this run kept no frame for that step';
|
|
663
|
+
// Leaving the live frame up under that caption is the most misleading
|
|
664
|
+
// state this page can reach: it reads as the page at that moment.
|
|
665
|
+
document.getElementById('focus-img').removeAttribute('src');
|
|
666
|
+
document.getElementById('focus-stage').classList.add('empty');
|
|
667
|
+
renderTimeline(null);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
document.getElementById('focus-stage').classList.remove('empty');
|
|
671
|
+
document.getElementById('focus-img').src = 'record/' + line.frame;
|
|
672
|
+
where.textContent = clock(line.at) + ' ' + line.action + (line.task ? ' · ' + line.task : '');
|
|
673
|
+
renderTimeline(null);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function backToLive() {
|
|
677
|
+
scrubbed = null;
|
|
678
|
+
document.getElementById('scrub-where').textContent = 'Live';
|
|
679
|
+
document.getElementById('scrub-live').hidden = true;
|
|
680
|
+
if (focused) document.getElementById('focus-img').src = frames[focused] || shotUrl(focused);
|
|
681
|
+
// Otherwise the step just left keeps its outline until the next full feed.
|
|
682
|
+
renderTimeline(null);
|
|
683
|
+
}
|
|
684
|
+
|
|
534
685
|
// Built with textContent only: an action's target is text from the app under test.
|
|
535
686
|
function renderFeed(node, lines, onGroup) {
|
|
536
687
|
// Measured before the node is emptied: an empty node always reads as scrolled to its end.
|
|
@@ -589,10 +740,17 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
589
740
|
|
|
590
741
|
function openFocus(name) {
|
|
591
742
|
focused = name;
|
|
743
|
+
scrubbed = null;
|
|
744
|
+
// The ticks belong to the session just left; showing them under this one's
|
|
745
|
+
// name, and playing its frames when one is clicked, is worse than none.
|
|
746
|
+
timelineLines = [];
|
|
747
|
+
renderTimeline([]);
|
|
592
748
|
document.getElementById('focus-stage').classList.remove('empty');
|
|
593
749
|
document.getElementById('focus-name').textContent = name;
|
|
594
750
|
document.getElementById('focus-img').alt = 'Live view of ' + name;
|
|
595
751
|
document.getElementById('focus-img').src = frames[name] || shotUrl(name);
|
|
752
|
+
document.getElementById('scrub-where').textContent = 'Live';
|
|
753
|
+
document.getElementById('scrub-live').hidden = true;
|
|
596
754
|
document.getElementById('focus').classList.add('open');
|
|
597
755
|
hoverTask = null;
|
|
598
756
|
renderFeed(document.getElementById('focus-feed'), (latest[name] || {}).feed, showTask);
|
|
@@ -612,8 +770,21 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
612
770
|
function loadFullFeed(name) {
|
|
613
771
|
fetch('api/activity?session=' + encodeURIComponent(name), { cache: 'no-store' })
|
|
614
772
|
.then(function (r) { return r.ok ? r.json() : null; })
|
|
615
|
-
.then(function (d) {
|
|
616
|
-
|
|
773
|
+
.then(function (d) {
|
|
774
|
+
if (!d || focused !== d.session) return;
|
|
775
|
+
renderFeed(document.getElementById('focus-feed'), d.feed, showTask);
|
|
776
|
+
renderTimeline(d.feed);
|
|
777
|
+
})
|
|
778
|
+
.catch(function () {
|
|
779
|
+
// The short feed from the last poll stays on screen, but the timeline
|
|
780
|
+
// is drawn from this call alone: an empty strip would read as a
|
|
781
|
+
// session that did nothing.
|
|
782
|
+
if (!timelineLines.length) {
|
|
783
|
+
var strip = document.getElementById('timeline');
|
|
784
|
+
strip.textContent = '';
|
|
785
|
+
strip.appendChild(el('span', 'none', 'The step list could not be loaded. Trying again.'));
|
|
786
|
+
}
|
|
787
|
+
});
|
|
617
788
|
}
|
|
618
789
|
|
|
619
790
|
function setBrief(id, text, unset) {
|
|
@@ -685,7 +856,10 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
685
856
|
});
|
|
686
857
|
syncEvents();
|
|
687
858
|
reportFile = snap.report || reportFile;
|
|
688
|
-
|
|
859
|
+
// A viewer who opens the board after the last browser closed never sees a
|
|
860
|
+
// session, but the engine only names a report file once one has attached:
|
|
861
|
+
// that is a finished run, not a board waiting for its first attach.
|
|
862
|
+
if (snap.sessions.length > 0 || snap.report) sawRun = true;
|
|
689
863
|
finished = sawRun && snap.sessions.length === 0;
|
|
690
864
|
document.getElementById('empty').style.display = snap.sessions.length || finished ? 'none' : 'block';
|
|
691
865
|
document.getElementById('finished').classList.toggle('open', finished);
|
|
@@ -693,10 +867,18 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
693
867
|
var where = document.getElementById('finished-where');
|
|
694
868
|
where.textContent = whereItIs();
|
|
695
869
|
where.className = 'where' + (reportFile && reportFile.written ? '' : ' unset');
|
|
696
|
-
// The moment somebody wants the
|
|
870
|
+
// The moment somebody wants the run is the moment it ends, and a panel
|
|
871
|
+
// over a dead board is lost on the next refresh. The run has its own
|
|
872
|
+
// address; go there, so the address IS the report from then on.
|
|
697
873
|
if (!reportShown) {
|
|
698
874
|
reportShown = true;
|
|
699
|
-
|
|
875
|
+
// The run has its own address, and going there means a refresh shows
|
|
876
|
+
// the report instead of a dead board. Ask whether it is there first:
|
|
877
|
+
// a run with no page to serve would otherwise land on a 404, and the
|
|
878
|
+
// panel below is the only other copy.
|
|
879
|
+
fetch('run', { cache: 'no-store' })
|
|
880
|
+
.then(function (r) { if (r.ok) location.href = 'run'; else openReport(); })
|
|
881
|
+
.catch(function () { openReport(); });
|
|
700
882
|
}
|
|
701
883
|
}
|
|
702
884
|
document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
|
|
@@ -719,14 +901,22 @@ export const LIVE_PAGE = `<!doctype html>
|
|
|
719
901
|
Object.keys(cards).forEach(function (name) { setLive(cards[name], streamAll); });
|
|
720
902
|
});
|
|
721
903
|
// A session with nothing to show answers 503; say so rather than leaving a broken-image icon.
|
|
722
|
-
document.getElementById('focus-img').addEventListener('error', function () {
|
|
904
|
+
document.getElementById('focus-img').addEventListener('error', function () {
|
|
905
|
+
document.getElementById('focus-stage').classList.add('empty');
|
|
906
|
+
// A live session with nothing to show and a recorded frame that has since
|
|
907
|
+
// been deleted look identical here; only the second is worth explaining.
|
|
908
|
+
if (scrubbed) {
|
|
909
|
+
document.getElementById('scrub-where').textContent =
|
|
910
|
+
clock(scrubbed.at) + ' ' + scrubbed.action + ' — its frame could not be loaded; the recording may have been removed';
|
|
911
|
+
}
|
|
912
|
+
});
|
|
723
913
|
document.getElementById('focus-img').addEventListener('load', function () { document.getElementById('focus-stage').classList.remove('empty'); });
|
|
724
914
|
document.getElementById('focus-close').addEventListener('click', closeFocus);
|
|
725
915
|
// A re-rendered feed replaces the group under the pointer without a mouseleave; leaving the feed itself still resets.
|
|
726
916
|
document.getElementById('focus-feed').addEventListener('mouseleave', function () { showTask(null); });
|
|
727
917
|
document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
|
|
918
|
+
document.getElementById('scrub-live').addEventListener('click', backToLive);
|
|
728
919
|
document.getElementById('report-open').addEventListener('click', openReport);
|
|
729
|
-
document.getElementById('finished-report').addEventListener('click', openReport);
|
|
730
920
|
document.getElementById('report-save').addEventListener('click', saveACopy);
|
|
731
921
|
// Closing the tab on a finished run whose report was never written to disk
|
|
732
922
|
// throws the only copy away. The browser shows its own confirm/dismiss, and
|