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/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
|
|
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); }
|
|
@@ -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
|
-
|
|
86
|
-
|
|
87
|
-
.feed .
|
|
88
|
-
.feed .
|
|
89
|
-
.feed .
|
|
90
|
-
|
|
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
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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) {
|
|
596
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|