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 CHANGED
@@ -1,5 +1,37 @@
1
1
  # scenescout
2
2
 
3
+ ## 3.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 52d97d8: Record a run, and read the whole thing back afterwards.
8
+
9
+ `scout_attach {record: true}` keeps a frame of the page after every action, and
10
+ `scout_report` then writes `report.html` beside `report.md`: the report, the
11
+ screenshots taken around each finding, and every session's trail in the blocks
12
+ its tasks made — one self-contained page that opens from the file system with
13
+ nothing running. Recording is off unless asked for, because the frames are
14
+ pictures of the app under test and no redaction can read a picture
15
+ ([ADR 8](docs/adr/0008-a-recorded-run-is-evidence-and-must-be-asked-for.md)).
16
+
17
+ The live view serves the same document at its own address and goes there when
18
+ the run ends, so the report survives a refresh instead of dying with the board.
19
+ The close-up gains a timeline: a tick per action, coloured by task, that plays
20
+ a recorded run back while it is still going. A finding's screenshots also hang
21
+ under it in the live report panel, which no longer resets itself while it is
22
+ being read.
23
+
24
+ Fixes: a listener left on a control that had been replaced threw on load and
25
+ left the board blank; a viewer arriving after the last browser closed saw the
26
+ empty state instead of the finished run; a finding's evidence could be drawn
27
+ from a different session than the one that filed it.
28
+
29
+ ## 3.0.1
30
+
31
+ ### Patch Changes
32
+
33
+ - f99062d: The close-up's feed shows one pastel tint per task again, so a change of task is a change of colour and the block of actions a task covers is legible at a glance; hovering a block names the task in the brief, under the session's objective. The tints are lifted on a light card so they read the same either way. A close-up with no frame now says "No frame available" like a card does, instead of showing a broken-image icon.
34
+
3
35
  ## 3.0.0
4
36
 
5
37
  ### Major 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 six parallel agents against the demo app: one card per session, each with its role, the tool it is running and for how long, the page it is on, a thumbnail, and a feed of the actions it just took" width="880" /></p>
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: the task the agent gave it when it attached (`scout_attach {task}`), and the goal of the journey it is on right now (`scout_journey`). Actions of one journey share a tint in the feed; point at a group and the brief shows the goal those actions served.
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: its page streaming live, the feed of its recent actions grouped by journey, and beside it the task and current objective" width="880" /></p>
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 ever written to disk ([ADR 7](docs/adr/0007-the-live-view-is-local-read-only-and-leaves-nothing-behind.md)). A stream runs only while someone is watching it. `SCENESCOUT_LIVE=off` keeps the port closed.
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 six. Two things keep a parallel run efficient:
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; generate the report |
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 12 test suites (smoke/ holds the real-browser ones)
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
@@ -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.logAction({ action, target, url });
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();
@@ -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 button { margin-top: 8px; padding: 7px 14px; font-weight: 600; }
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); }
@@ -82,12 +83,23 @@ export const LIVE_PAGE = `<!doctype html>
82
83
  font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; max-height: 108px; overflow-y: auto; overscroll-behavior: contain; }
83
84
  .feed .row { display: flex; gap: 6px; white-space: nowrap; }
84
85
  /* Consecutive actions of one journey share a tint, so where one goal ends and the next begins is visible in the log. */
85
- .feed .group { border-left: 1px solid transparent; padding-left: 5px; margin-left: -6px; border-radius: 3px; }
86
- .feed .g0 { background: rgba(96, 165, 250, .13); border-color: rgba(96, 165, 250, .7); }
87
- .feed .g1 { background: rgba(52, 211, 153, .13); border-color: rgba(52, 211, 153, .7); }
88
- .feed .g2 { background: rgba(251, 191, 36, .13); border-color: rgba(251, 191, 36, .7); }
89
- .feed .g3 { background: rgba(244, 114, 182, .13); border-color: rgba(244, 114, 182, .7); }
90
- #focus .feed .group:hover { outline: 1px solid rgba(230, 233, 238, .35); }
86
+ /* One tint per task, so a change of task is a change of colour: the block of
87
+ actions a task covers is legible at a glance, and hovering it names the task. */
88
+ .feed .group { border-left: 2px solid transparent; padding-left: 6px; margin-left: -8px; border-radius: 4px; margin-bottom: 2px; }
89
+ .feed .g0 { background: rgba(96, 165, 250, .22); border-color: rgba(96, 165, 250, .85); }
90
+ .feed .g1 { background: rgba(52, 211, 153, .22); border-color: rgba(52, 211, 153, .85); }
91
+ .feed .g2 { background: rgba(251, 191, 36, .24); border-color: rgba(251, 191, 36, .9); }
92
+ .feed .g3 { background: rgba(244, 114, 182, .22); border-color: rgba(244, 114, 182, .85); }
93
+ .feed .group:hover { cursor: default; }
94
+ #focus .feed .group:hover { outline: 1px solid rgba(230, 233, 238, .45); }
95
+ /* The tints sit on a dark panel in the close-up and on a light one in a card;
96
+ lift them there so they read as the same pastel either way. */
97
+ @media (prefers-color-scheme: light) {
98
+ :root:not([data-theme="dark"]) .feed .g0 { background: rgba(37, 99, 235, .14); }
99
+ :root:not([data-theme="dark"]) .feed .g1 { background: rgba(5, 150, 105, .14); }
100
+ :root:not([data-theme="dark"]) .feed .g2 { background: rgba(217, 119, 6, .16); }
101
+ :root:not([data-theme="dark"]) .feed .g3 { background: rgba(219, 39, 119, .13); }
102
+ }
91
103
  .feed .t { color: var(--muted); flex: 0 0 auto; }
92
104
  .feed .a { color: var(--text); font-weight: 600; flex: 0 0 auto; }
93
105
  .feed .d { color: var(--muted); overflow: hidden; text-overflow: ellipsis; }
@@ -116,7 +128,24 @@ export const LIVE_PAGE = `<!doctype html>
116
128
  #focus .bar { display: flex; align-items: center; gap: 12px; color: #fff; }
117
129
  #focus .bar .line { color: #cbd5e1; padding: 0; flex: 1 1 auto; }
118
130
  #focus .bar .line b { color: #fff; }
119
- #focus img { flex: 1 1 auto; min-height: 0; width: 100%; object-fit: contain; background: #000; border-radius: 6px; }
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; }
144
+ #focus img { width: 100%; height: 100%; object-fit: contain; background: #000; border-radius: 6px; display: block; }
145
+ #focus .none { position: absolute; inset: 0; display: none; align-items: center; justify-content: center; padding: 16px;
146
+ color: #98a2b3; font-size: 13px; text-align: center; }
147
+ #focus .stage.empty .none { display: flex; }
148
+ #focus .stage.empty img { visibility: hidden; }
120
149
  #report { display: none; position: fixed; inset: 0; z-index: 6; background: var(--bg); overflow-y: auto; padding: 0 16px 32px; }
121
150
  #report.open { display: block; }
122
151
  #report .bar { position: sticky; top: 0; z-index: 1; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px;
@@ -136,6 +165,18 @@ export const LIVE_PAGE = `<!doctype html>
136
165
  #report .doc details { margin: 8px 0; }
137
166
  #report .doc summary { cursor: pointer; color: var(--muted); }
138
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); }
139
180
  @media (prefers-reduced-motion: no-preference) { .badge.running { animation: pulse 1.6s ease-in-out infinite; } }
140
181
  @keyframes pulse { 50% { opacity: .55; } }
141
182
  </style>
@@ -157,7 +198,7 @@ export const LIVE_PAGE = `<!doctype html>
157
198
  <h2>The run has finished</h2>
158
199
  <p>Its browsers are closed, so there is nothing left to watch. What it found is in the report.</p>
159
200
  <p class="where" id="finished-where" data-testid="live-finished-where"></p>
160
- <button type="button" id="finished-report" data-testid="live-finished-report">Read the report</button>
201
+ <p><a class="btn" href="run" id="finished-run" data-testid="live-finished-run">Open the whole run</a></p>
161
202
  </div>
162
203
  <main id="grid"></main>
163
204
  <div id="report" role="dialog" aria-modal="true" aria-label="The run's report" data-testid="live-report-dialog">
@@ -176,7 +217,13 @@ export const LIVE_PAGE = `<!doctype html>
176
217
  <span class="line" id="focus-line"></span>
177
218
  <button type="button" id="focus-close" data-testid="live-focus-close">Close</button>
178
219
  </div>
179
- <img id="focus-img" alt="">
220
+ <div class="stage" id="focus-stage">
221
+ <img id="focus-img" alt="">
222
+ <span class="none">No frame available. The session's page may be closed, or not answering.</span>
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>
180
227
  <div class="lower">
181
228
  <div class="feed" id="focus-feed" data-testid="live-focus-feed"></div>
182
229
  <aside class="brief" aria-label="What this session is doing" data-testid="live-focus-brief">
@@ -201,6 +248,10 @@ export const LIVE_PAGE = `<!doctype html>
201
248
  var eventsKey = '';
202
249
  var frames = {};
203
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 = [];
204
255
  var reportOpen = false;
205
256
  var reportTimer = null;
206
257
  var reportProblem = null;
@@ -211,6 +262,10 @@ export const LIVE_PAGE = `<!doctype html>
211
262
  var reportShown = false;
212
263
  var reportFile = null;
213
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 = [];
214
269
  var savedACopy = false;
215
270
  // A result that reads as a failure is shown in red.
216
271
  var BAD_RESULT = /error|fail|refus|block|violation|abandoned/i;
@@ -257,7 +312,7 @@ export const LIVE_PAGE = `<!doctype html>
257
312
  frames[d.session] = src;
258
313
  var card = cards[d.session];
259
314
  if (card && card.live) card.img.src = src;
260
- if (focused === d.session) document.getElementById('focus-img').src = src;
315
+ if (focused === d.session && !scrubbed) document.getElementById('focus-img').src = src;
261
316
  });
262
317
  events.addEventListener('unavailable', function (e) {
263
318
  var d;
@@ -356,6 +411,35 @@ export const LIVE_PAGE = `<!doctype html>
356
411
  }
357
412
  if (last < text.length) node.appendChild(document.createTextNode(text.slice(last)));
358
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
+ }
359
443
  function renderMarkdown(root, md) {
360
444
  root.textContent = '';
361
445
  var lines = md.split('\\n');
@@ -371,14 +455,23 @@ export const LIVE_PAGE = `<!doctype html>
371
455
  }
372
456
  function list(tag, re) {
373
457
  var box = el(tag);
458
+ var ids = [];
374
459
  var m;
375
460
  while (i < lines.length && (m = re.exec(lines[i]))) {
376
461
  var li = el('li');
377
462
  inline(li, m[1]);
378
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]);
379
468
  i += 1;
380
469
  }
381
470
  container.appendChild(box);
471
+ ids.forEach(function (id) {
472
+ var shots = evidenceFor(id);
473
+ if (shots) container.appendChild(shots);
474
+ });
382
475
  }
383
476
  while (i < lines.length) {
384
477
  var line = lines[i];
@@ -463,8 +556,26 @@ export const LIVE_PAGE = `<!doctype html>
463
556
  return;
464
557
  }
465
558
  reportMarkdown = d.markdown;
559
+ reportEvidence = d.evidence || [];
466
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;
467
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;
468
579
  })
469
580
  .catch(function (err) {
470
581
  // The last rendering stays; with none, say why there is nothing to read.
@@ -512,6 +623,65 @@ export const LIVE_PAGE = `<!doctype html>
512
623
  document.getElementById('report-open').focus();
513
624
  }
514
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
+
515
685
  // Built with textContent only: an action's target is text from the app under test.
516
686
  function renderFeed(node, lines, onGroup) {
517
687
  // Measured before the node is emptied: an empty node always reads as scrolled to its end.
@@ -570,9 +740,17 @@ export const LIVE_PAGE = `<!doctype html>
570
740
 
571
741
  function openFocus(name) {
572
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([]);
748
+ document.getElementById('focus-stage').classList.remove('empty');
573
749
  document.getElementById('focus-name').textContent = name;
574
750
  document.getElementById('focus-img').alt = 'Live view of ' + name;
575
751
  document.getElementById('focus-img').src = frames[name] || shotUrl(name);
752
+ document.getElementById('scrub-where').textContent = 'Live';
753
+ document.getElementById('scrub-live').hidden = true;
576
754
  document.getElementById('focus').classList.add('open');
577
755
  hoverTask = null;
578
756
  renderFeed(document.getElementById('focus-feed'), (latest[name] || {}).feed, showTask);
@@ -592,8 +770,21 @@ export const LIVE_PAGE = `<!doctype html>
592
770
  function loadFullFeed(name) {
593
771
  fetch('api/activity?session=' + encodeURIComponent(name), { cache: 'no-store' })
594
772
  .then(function (r) { return r.ok ? r.json() : null; })
595
- .then(function (d) { if (d && focused === d.session) renderFeed(document.getElementById('focus-feed'), d.feed, showTask); })
596
- .catch(function () { /* the short feed from the last poll stays on screen */ });
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
+ });
597
788
  }
598
789
 
599
790
  function setBrief(id, text, unset) {
@@ -665,7 +856,10 @@ export const LIVE_PAGE = `<!doctype html>
665
856
  });
666
857
  syncEvents();
667
858
  reportFile = snap.report || reportFile;
668
- if (snap.sessions.length > 0) sawRun = true;
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;
669
863
  finished = sawRun && snap.sessions.length === 0;
670
864
  document.getElementById('empty').style.display = snap.sessions.length || finished ? 'none' : 'block';
671
865
  document.getElementById('finished').classList.toggle('open', finished);
@@ -673,10 +867,18 @@ export const LIVE_PAGE = `<!doctype html>
673
867
  var where = document.getElementById('finished-where');
674
868
  where.textContent = whereItIs();
675
869
  where.className = 'where' + (reportFile && reportFile.written ? '' : ' unset');
676
- // The moment somebody wants the report is the moment the run ends: show it.
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.
677
873
  if (!reportShown) {
678
874
  reportShown = true;
679
- if (!reportOpen) openReport();
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(); });
680
882
  }
681
883
  }
682
884
  document.getElementById('engine').textContent = 'engine pid ' + snap.pid + ' · v' + snap.version;
@@ -698,12 +900,23 @@ export const LIVE_PAGE = `<!doctype html>
698
900
  this.textContent = streamAll ? 'Streaming all' : 'Stream all';
699
901
  Object.keys(cards).forEach(function (name) { setLive(cards[name], streamAll); });
700
902
  });
903
+ // A session with nothing to show answers 503; say so rather than leaving a broken-image icon.
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
+ });
913
+ document.getElementById('focus-img').addEventListener('load', function () { document.getElementById('focus-stage').classList.remove('empty'); });
701
914
  document.getElementById('focus-close').addEventListener('click', closeFocus);
702
915
  // A re-rendered feed replaces the group under the pointer without a mouseleave; leaving the feed itself still resets.
703
916
  document.getElementById('focus-feed').addEventListener('mouseleave', function () { showTask(null); });
704
917
  document.getElementById('focus').addEventListener('click', function (e) { if (e.target === this) closeFocus(); });
918
+ document.getElementById('scrub-live').addEventListener('click', backToLive);
705
919
  document.getElementById('report-open').addEventListener('click', openReport);
706
- document.getElementById('finished-report').addEventListener('click', openReport);
707
920
  document.getElementById('report-save').addEventListener('click', saveACopy);
708
921
  // Closing the tab on a finished run whose report was never written to disk
709
922
  // throws the only copy away. The browser shows its own confirm/dismiss, and