scenescout 3.20.0 → 3.20.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 +6 -0
- package/dist/check-run.js +37 -20
- package/dist/cli.js +2 -3
- package/dist/engine/browser.js +51 -63
- package/dist/engine/check-replay.js +61 -33
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.20.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 5d5240e: `scenescout check --video` now films each saved flow on the session's own page, so sessionStorage one flow writes is there for the next, and only the flows are filmed, not the crawl. The replay page marks and counts every step and visit left without a frame because its session reached the frame cap, has no scripts or inline event handlers, and carries a generator mark: a check removes only a `replay.html` that has it, so a file of that name the project keeps in the output folder stays.
|
|
8
|
+
|
|
3
9
|
## 3.20.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
package/dist/check-run.js
CHANGED
|
@@ -19,7 +19,7 @@ import { decodePng, encodePng } from "./engine/png.js";
|
|
|
19
19
|
import { firstLineOf } from "./engine/limits.js";
|
|
20
20
|
import { isNonPageResource } from "./engine/crawl.js";
|
|
21
21
|
import { checkRetestPlan, retestResults, wellFormedFindings } from "./engine/verify.js";
|
|
22
|
-
import { capFrames, isReplayFrameFile, journeyOf, journeyVideoPath, isJourneyVideoFile, redactReplay, REPLAY_VIDEOS_DIRNAME, replayVideos, REPLAY_FILE, REPLAY_FRAMES_DIRNAME, replayFramePath, replayFrames, replaySessionKey, visitOf, } from "./engine/check-replay.js";
|
|
22
|
+
import { capFrames, isReplayFrameFile, journeyOf, journeyVideoPath, isGeneratedReplay, isJourneyVideoFile, redactReplay, REPLAY_VIDEOS_DIRNAME, replayVideos, REPLAY_FILE, REPLAY_FRAMES_DIRNAME, replayFramePath, replayFrames, replaySessionKey, visitOf, } from "./engine/check-replay.js";
|
|
23
23
|
/** The baselines folder a check uses: the one --baselines names, else the project's own. */
|
|
24
24
|
export function baselinesDirOf(options) {
|
|
25
25
|
return options.baselinesDir ?? defaultBaselinesDir(options.projectDir);
|
|
@@ -106,8 +106,9 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
106
106
|
const store = new MemoryStore(scratch);
|
|
107
107
|
if (record)
|
|
108
108
|
engine.sessionKey = replaySessionKey();
|
|
109
|
-
// --video: each
|
|
109
|
+
// --video: each flow is filmed on its session's page, from its first step to its hand-back, and saved beside the report.
|
|
110
110
|
const video = options.video === true;
|
|
111
|
+
const frameCap = options.maxFrames;
|
|
111
112
|
const outDir = options.outDir ?? defaultCheckDir(options.projectDir);
|
|
112
113
|
// The default folder is inside .scenescout/, which ignores itself; its .gitignore goes first, so no frame or video is ever unignored.
|
|
113
114
|
if ((record || video) && !options.outDir) {
|
|
@@ -128,8 +129,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
128
129
|
memoryStore: store,
|
|
129
130
|
mode: options.mode,
|
|
130
131
|
storageStatePath: options.storageStatePath,
|
|
131
|
-
...(record ? { record: true } : {}),
|
|
132
|
-
...(video ? { videoDir: path.join(scratch, "video") } : {}),
|
|
132
|
+
...(record ? { record: true, ...(frameCap !== undefined ? { maxFrames: frameCap } : {}) } : {}),
|
|
133
133
|
...(options.browser ? { browser: options.browser } : {}),
|
|
134
134
|
actionTimeoutMs: options.actionTimeoutMs,
|
|
135
135
|
navTimeoutMs: options.navTimeoutMs,
|
|
@@ -206,8 +206,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
206
206
|
memoryStore: new MemoryStore(roleDir),
|
|
207
207
|
mode: options.mode,
|
|
208
208
|
role,
|
|
209
|
-
...(record ? { record: true } : {}),
|
|
210
|
-
...(video ? { videoDir: path.join(roleDir, "video") } : {}),
|
|
209
|
+
...(record ? { record: true, ...(frameCap !== undefined ? { maxFrames: frameCap } : {}) } : {}),
|
|
211
210
|
...(options.browser ? { browser: options.browser } : {}),
|
|
212
211
|
actionTimeoutMs: options.actionTimeoutMs,
|
|
213
212
|
navTimeoutMs: options.navTimeoutMs,
|
|
@@ -229,9 +228,9 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
229
228
|
const walk = () => runner.replayFlow(flow.steps, options.flowWrites === "never" ? "observe" : options.mode);
|
|
230
229
|
let walked;
|
|
231
230
|
if (video) {
|
|
232
|
-
//
|
|
231
|
+
// Filmed on the session's own page, so the flow runs exactly as it would unfilmed and the video holds it alone.
|
|
233
232
|
const rel = journeyVideoPath(flowRuns.length + 1, flow.file);
|
|
234
|
-
const own = await runner.
|
|
233
|
+
const own = await runner.filming(path.join(outDir, ...rel.split("/")), walk);
|
|
235
234
|
walked = own.value;
|
|
236
235
|
flowVideos.push(own.video ? rel : null);
|
|
237
236
|
if (own.videoError)
|
|
@@ -275,6 +274,7 @@ export async function runCheck(options, log = () => { }, inputs = { flows: [], f
|
|
|
275
274
|
.map((flow, i) => ({ flow, outcome: flowRuns[i].outcome, frames: flowFrames[i], video: flowVideos[i] ?? null })),
|
|
276
275
|
scratch,
|
|
277
276
|
say: log,
|
|
277
|
+
...(frameCap !== undefined ? { frameCap } : {}),
|
|
278
278
|
})
|
|
279
279
|
: null;
|
|
280
280
|
if (replay)
|
|
@@ -465,12 +465,24 @@ async function takeBaselines(engine, options, mode, targets, log) {
|
|
|
465
465
|
* Remove what an earlier recorded check left beside the report: replay.html,
|
|
466
466
|
* the frames under replay-frames/ and the journey videos under replay-videos/
|
|
467
467
|
* that a check names as it names them (isReplayFrameFile, isJourneyVideoFile),
|
|
468
|
-
* then any folder that leaves empty.
|
|
468
|
+
* then any folder that leaves empty. replay.html goes only when it carries
|
|
469
|
+
* the check's generator mark (isGeneratedReplay). Run before every
|
|
469
470
|
* check, recorded or not, so a page from an earlier run is never read, or
|
|
470
471
|
* uploaded, as this one's; whatever else the folder holds stays.
|
|
471
472
|
*/
|
|
472
473
|
export function clearReplayOutput(outDir) {
|
|
473
|
-
|
|
474
|
+
// Only a page a check wrote: a file of the same name the project keeps there is its own.
|
|
475
|
+
const page = path.join(outDir, REPLAY_FILE);
|
|
476
|
+
let text = null;
|
|
477
|
+
try {
|
|
478
|
+
text = fs.readFileSync(page, "utf8");
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
if (err.code !== "ENOENT")
|
|
482
|
+
throw err;
|
|
483
|
+
}
|
|
484
|
+
if (text !== null && isGeneratedReplay(text))
|
|
485
|
+
fs.rmSync(page);
|
|
474
486
|
const videos = path.join(outDir, REPLAY_VIDEOS_DIRNAME);
|
|
475
487
|
if (fs.existsSync(videos)) {
|
|
476
488
|
for (const file of fs.readdirSync(videos, { withFileTypes: true }))
|
|
@@ -510,25 +522,30 @@ function recordReplay(o) {
|
|
|
510
522
|
const outDir = o.options.outDir ?? defaultCheckDir(o.options.projectDir);
|
|
511
523
|
/** Where each frame the page names is copied from. */
|
|
512
524
|
const sources = new Map();
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
525
|
+
/** A shot with its frame's path moved to where it is copied beside the page. */
|
|
526
|
+
const beside = (shot, memoryDir) => {
|
|
527
|
+
if (!shot?.frame)
|
|
528
|
+
return shot?.pastCap ? { pastCap: true } : {};
|
|
529
|
+
const to = replayFramePath(shot.frame);
|
|
530
|
+
if (!to)
|
|
531
|
+
return {};
|
|
532
|
+
sources.set(to, path.join(memoryDir, ...shot.frame.split("/")));
|
|
533
|
+
return { frame: to };
|
|
518
534
|
};
|
|
519
|
-
//
|
|
535
|
+
// What the crawl kept for each visit, by the path it asked for, in the order the visits ran.
|
|
520
536
|
const own = replaySessionKey();
|
|
521
537
|
const crawled = new Map();
|
|
522
538
|
for (const e of o.log) {
|
|
523
|
-
if (e.action !== "crawl" ||
|
|
539
|
+
if (e.action !== "crawl" || e.target === undefined || (e.session ?? own) !== own)
|
|
524
540
|
continue;
|
|
525
|
-
|
|
541
|
+
const shot = e.frame ? { frame: e.frame } : e.framePastCap ? { pastCap: true } : {};
|
|
542
|
+
crawled.set(e.target, [...(crawled.get(e.target) ?? []), shot]);
|
|
526
543
|
}
|
|
527
544
|
const ownSession = {
|
|
528
545
|
role: o.ownRole,
|
|
529
546
|
own: true,
|
|
530
547
|
// A MemoryStore keeps its files, frames included, in the .scenescout folder of the directory it is given.
|
|
531
|
-
visits: o.visited.map((route) => visitOf(route, beside(crawled.get(route.path)?.shift(), path.join(o.scratch, MEMORY_DIRNAME))
|
|
548
|
+
visits: o.visited.map((route) => visitOf(route, beside(crawled.get(route.path)?.shift(), path.join(o.scratch, MEMORY_DIRNAME)))),
|
|
532
549
|
journeys: [],
|
|
533
550
|
};
|
|
534
551
|
const roles = [ownSession];
|
|
@@ -544,7 +561,7 @@ function recordReplay(o) {
|
|
|
544
561
|
}
|
|
545
562
|
role.journeys.push(journey);
|
|
546
563
|
}
|
|
547
|
-
const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0 }));
|
|
564
|
+
const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0, ...(o.frameCap !== undefined ? { frameCap: o.frameCap } : {}) }));
|
|
548
565
|
// A frame that cannot be copied is left off the page rather than shown broken.
|
|
549
566
|
const copied = new Set();
|
|
550
567
|
let failed = false;
|
package/dist/cli.js
CHANGED
|
@@ -111,9 +111,8 @@ Usage:
|
|
|
111
111
|
--record [on|off]: keep a frame after each route visit and each flow step
|
|
112
112
|
and write replay.html beside the report, role → journey → step (default:
|
|
113
113
|
SCENESCOUT_RECORD, else off; the frames go in replay-frames/);
|
|
114
|
-
--video [on|off]: record a WebM of each saved flow,
|
|
115
|
-
|
|
116
|
-
(default off))
|
|
114
|
+
--video [on|off]: record a WebM of each saved flow, and only of the flows,
|
|
115
|
+
into replay-videos/, played on replay.html beside its steps (default off))
|
|
117
116
|
Exit code: 0 passed, 1 failed the gate, 2 could not run.
|
|
118
117
|
scenescout ci <url> An exploratory run with no person present: a model reached through its API
|
|
119
118
|
drives the tools by the SceneScout method and the run ends in the report.
|
package/dist/engine/browser.js
CHANGED
|
@@ -460,6 +460,8 @@ export class BrowserEngine {
|
|
|
460
460
|
* checked, a recording shows it.
|
|
461
461
|
*/
|
|
462
462
|
recording = false;
|
|
463
|
+
/** Most frames this recorded session keeps (attach's maxFrames). */
|
|
464
|
+
maxFrames = RECORD_MAX_FRAMES;
|
|
463
465
|
framesKept = 0;
|
|
464
466
|
/** How many frames could not be written. The first one says so in the log; the rest are counted. */
|
|
465
467
|
framesFailed = 0;
|
|
@@ -592,8 +594,13 @@ export class BrowserEngine {
|
|
|
592
594
|
* page looked like: `...(await this.frameFor("crawl"))`.
|
|
593
595
|
*/
|
|
594
596
|
async frameFor(action) {
|
|
597
|
+
const pastCap = this.framePastCap();
|
|
595
598
|
const frame = await this.recordFrame(action);
|
|
596
|
-
return frame ? { frame } : {};
|
|
599
|
+
return frame ? { frame } : pastCap ? { framePastCap: true } : {};
|
|
600
|
+
}
|
|
601
|
+
/** On a recorded session, whether the next step gets no frame because the session already keeps as many as it may. */
|
|
602
|
+
framePastCap() {
|
|
603
|
+
return this.recording && this.framesKept >= this.maxFrames;
|
|
597
604
|
}
|
|
598
605
|
/**
|
|
599
606
|
* The frame for the step just taken, as a path relative to the memory
|
|
@@ -603,7 +610,7 @@ export class BrowserEngine {
|
|
|
603
610
|
*/
|
|
604
611
|
async recordFrame(action) {
|
|
605
612
|
const dir = this.memory?.dir;
|
|
606
|
-
if (!this.recording || !dir || this.framesKept >=
|
|
613
|
+
if (!this.recording || !dir || this.framesKept >= this.maxFrames)
|
|
607
614
|
return undefined;
|
|
608
615
|
const jpeg = await this.liveShot(RECORD_SHOT_TIMEOUT_MS);
|
|
609
616
|
if (!jpeg)
|
|
@@ -630,8 +637,8 @@ export class BrowserEngine {
|
|
|
630
637
|
return undefined;
|
|
631
638
|
}
|
|
632
639
|
this.framesKept += 1;
|
|
633
|
-
if (this.framesKept ===
|
|
634
|
-
this.logAction({ action: "record:full", target: `${
|
|
640
|
+
if (this.framesKept === this.maxFrames) {
|
|
641
|
+
this.logAction({ action: "record:full", target: `${this.maxFrames} frames kept; later steps have none`, url: this.page?.url() ?? "" });
|
|
635
642
|
}
|
|
636
643
|
return rel;
|
|
637
644
|
}
|
|
@@ -1104,6 +1111,7 @@ export class BrowserEngine {
|
|
|
1104
1111
|
this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
|
|
1105
1112
|
this.setPace(opts.paceMs);
|
|
1106
1113
|
this.recording = opts.record === true;
|
|
1114
|
+
this.maxFrames = opts.maxFrames ?? RECORD_MAX_FRAMES;
|
|
1107
1115
|
// A re-attached engine starts a new recording: numbering from where the
|
|
1108
1116
|
// last one stopped would run into the cap with frames it never took.
|
|
1109
1117
|
this.framesKept = 0;
|
|
@@ -1184,7 +1192,6 @@ export class BrowserEngine {
|
|
|
1184
1192
|
viewport: opts.viewport ?? { width: 1280, height: 900 },
|
|
1185
1193
|
...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
|
|
1186
1194
|
serviceWorkers: serviceWorkerPolicy(this.engineName),
|
|
1187
|
-
...(opts.videoDir ? { recordVideo: { dir: opts.videoDir } } : {}),
|
|
1188
1195
|
});
|
|
1189
1196
|
const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
|
|
1190
1197
|
if (restoreSession)
|
|
@@ -1510,9 +1517,6 @@ export class BrowserEngine {
|
|
|
1510
1517
|
// oracles attached); close foreign-origin popups so exploration cannot
|
|
1511
1518
|
// silently escape the app under test.
|
|
1512
1519
|
this.context.on("page", (newPage) => {
|
|
1513
|
-
// A page the engine opened for itself (onOwnPage) is not a popup to adopt or close.
|
|
1514
|
-
if (this.openingOwnPage)
|
|
1515
|
-
return;
|
|
1516
1520
|
newPage
|
|
1517
1521
|
.waitForLoadState("domcontentloaded", { timeout: this.limits.backNavMs })
|
|
1518
1522
|
.then(() => {
|
|
@@ -1675,65 +1679,40 @@ export class BrowserEngine {
|
|
|
1675
1679
|
nativeDialogAt = 0;
|
|
1676
1680
|
/** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
|
|
1677
1681
|
nativeQuestionAt = 0;
|
|
1678
|
-
/**
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1682
|
+
/**
|
|
1683
|
+
* Run `work` while the session's page is filmed into `videoTo` (a WebM,
|
|
1684
|
+
* Playwright's page screencast), so the video holds that work and nothing
|
|
1685
|
+
* the session did before or after it. The page, its tab and so its
|
|
1686
|
+
* sessionStorage are the session's own: the work runs exactly as it would
|
|
1687
|
+
* unfilmed. The video is null when it could not be started or saved
|
|
1688
|
+
* (`videoError` says why); the work's own result and errors are untouched.
|
|
1689
|
+
*/
|
|
1690
|
+
async filming(videoTo, work) {
|
|
1691
|
+
const page = this.requirePage();
|
|
1692
|
+
const why = (err) => (err instanceof Error ? err.message.split("\n")[0] : String(err));
|
|
1693
|
+
let videoError;
|
|
1694
|
+
let filming = false;
|
|
1687
1695
|
try {
|
|
1688
|
-
|
|
1696
|
+
await fs.promises.mkdir(path.dirname(videoTo), { recursive: true });
|
|
1697
|
+
await page.screencast.start({ path: videoTo, ...(page.viewportSize() ? { size: page.viewportSize() } : {}) });
|
|
1698
|
+
filming = true;
|
|
1689
1699
|
}
|
|
1690
|
-
|
|
1691
|
-
|
|
1700
|
+
catch (err) {
|
|
1701
|
+
videoError = `the video could not be started: ${why(err)}`;
|
|
1692
1702
|
}
|
|
1693
|
-
this.oracles.attach(page);
|
|
1694
|
-
this.wireDialogHandler(page);
|
|
1695
|
-
this.wireEmbedMoves(page);
|
|
1696
|
-
this.page = page;
|
|
1697
|
-
this.refs.clear();
|
|
1698
|
-
this.snapshotUrl = "";
|
|
1699
|
-
this.forgetSnapshots();
|
|
1700
|
-
return page;
|
|
1701
|
-
}
|
|
1702
|
-
/**
|
|
1703
|
-
* Run `work` on a page of its own in this session's context, then close it.
|
|
1704
|
-
* The context is the session's, so its cookies and storage carry over; on a
|
|
1705
|
-
* session attached with `videoDir`, the page's video is this run's alone,
|
|
1706
|
-
* and is saved to `videoTo`. Every page the work left open is closed with
|
|
1707
|
-
* it, and a fresh page is driven afterwards. The video is null when the
|
|
1708
|
-
* session records none or it could not be saved (`videoError` says why).
|
|
1709
|
-
*/
|
|
1710
|
-
async onOwnPage(work, videoTo) {
|
|
1711
|
-
const own = await this.driveNewPage();
|
|
1712
1703
|
let value;
|
|
1713
1704
|
try {
|
|
1714
1705
|
value = await work();
|
|
1715
1706
|
}
|
|
1716
1707
|
finally {
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map((p) => p.close().catch(() => { }))), 10_000);
|
|
1721
|
-
this.page = null;
|
|
1722
|
-
if (context)
|
|
1723
|
-
await this.driveNewPage();
|
|
1724
|
-
}
|
|
1725
|
-
const recording = own.video();
|
|
1726
|
-
if (!recording || !videoTo)
|
|
1727
|
-
return { value, video: null };
|
|
1728
|
-
try {
|
|
1729
|
-
await fs.promises.mkdir(path.dirname(videoTo), { recursive: true });
|
|
1730
|
-
// saveAs waits until the page is closed and the video written.
|
|
1731
|
-
await recording.saveAs(videoTo);
|
|
1732
|
-
return { value, video: videoTo };
|
|
1733
|
-
}
|
|
1734
|
-
catch (err) {
|
|
1735
|
-
return { value, video: null, videoError: err instanceof Error ? err.message.split("\n")[0] : String(err) };
|
|
1708
|
+
// The page the filming began on, even if the work moved the session to a popup.
|
|
1709
|
+
if (filming)
|
|
1710
|
+
await page.screencast.stop().catch((err) => (videoError = `the video could not be saved: ${why(err)}`));
|
|
1736
1711
|
}
|
|
1712
|
+
if (videoError)
|
|
1713
|
+
return { value, video: null, videoError };
|
|
1714
|
+
const saved = await fs.promises.stat(videoTo).then((st) => st.size > 0, () => false);
|
|
1715
|
+
return saved ? { value, video: videoTo } : { value, video: null, videoError: "no video was written" };
|
|
1737
1716
|
}
|
|
1738
1717
|
requirePage() {
|
|
1739
1718
|
if (!this.page || !this.memory) {
|
|
@@ -4469,7 +4448,8 @@ export class BrowserEngine {
|
|
|
4469
4448
|
async inspectRoute(page, elements, url) {
|
|
4470
4449
|
const { geometry, brokenImages } = await this.measureLayout(page, elements, url);
|
|
4471
4450
|
try {
|
|
4472
|
-
|
|
4451
|
+
// The crawl has just kept this page's frame; a second of the same page would only use up the session's cap.
|
|
4452
|
+
const { defects, sampled } = await this.auditPage({ frame: false });
|
|
4473
4453
|
// Nothing styled to read is not a clean page: contrast, focus and target size went unmeasured.
|
|
4474
4454
|
return sampled > 0
|
|
4475
4455
|
? { geometry, brokenImages, design: defects }
|
|
@@ -5342,8 +5322,11 @@ export class BrowserEngine {
|
|
|
5342
5322
|
const did = describeStep(step);
|
|
5343
5323
|
const { failure, refusal } = step.action === "repeat" ? await runRepeat(step) : await runStep(step);
|
|
5344
5324
|
// Taken whatever the step's result: the frame of a step that broke is the evidence of how it broke.
|
|
5345
|
-
if (this.recording)
|
|
5346
|
-
|
|
5325
|
+
if (this.recording) {
|
|
5326
|
+
const pastCap = this.framePastCap();
|
|
5327
|
+
const frame = await this.recordFrame(`flow-${step.action}`);
|
|
5328
|
+
frames.push(frame ? { frame } : pastCap ? { pastCap: true } : {});
|
|
5329
|
+
}
|
|
5347
5330
|
if (refusal)
|
|
5348
5331
|
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
5349
5332
|
if (failure)
|
|
@@ -5402,7 +5385,7 @@ export class BrowserEngine {
|
|
|
5402
5385
|
return `URL: ${url}\n` + report;
|
|
5403
5386
|
}
|
|
5404
5387
|
/** The design audit's measurements, as data as well as prose; scout_design_audit and a check's crawl share it. */
|
|
5405
|
-
async auditPage() {
|
|
5388
|
+
async auditPage(opts = {}) {
|
|
5406
5389
|
const page = this.requirePage();
|
|
5407
5390
|
await this.settle();
|
|
5408
5391
|
const payload = (await page.evaluate(DESIGN_COLLECT_SCRIPT));
|
|
@@ -5424,7 +5407,12 @@ export class BrowserEngine {
|
|
|
5424
5407
|
this.memory?.setPageScore(route, { ...score, at: new Date().toISOString(), url: page.url() });
|
|
5425
5408
|
this.memory?.markRouteFact(route, { audited: true });
|
|
5426
5409
|
}
|
|
5427
|
-
this.logAction({
|
|
5410
|
+
this.logAction({
|
|
5411
|
+
action: "design-audit",
|
|
5412
|
+
url: page.url(),
|
|
5413
|
+
result: score ? `score:${score.overall}` : undefined,
|
|
5414
|
+
...(opts.frame === false ? {} : await this.frameFor("design-audit")),
|
|
5415
|
+
});
|
|
5428
5416
|
return { url: page.url(), report, defects, sampled: payload.records.length };
|
|
5429
5417
|
}
|
|
5430
5418
|
/**
|
|
@@ -17,22 +17,34 @@ import { describeStep } from "./flow.js";
|
|
|
17
17
|
import { escapeHtml, plainSegment, RECORD_MAX_FRAMES } from "./replay.js";
|
|
18
18
|
/** The file the page is written to, beside report.md. */
|
|
19
19
|
export const REPLAY_FILE = "replay.html";
|
|
20
|
+
/** The page's generator mark: an earlier run's page is removed only when it carries it, never a file of the same name. */
|
|
21
|
+
export const REPLAY_GENERATOR = "scenescout-check-replay";
|
|
22
|
+
/** Whether a replay.html is one a check wrote. */
|
|
23
|
+
export function isGeneratedReplay(html) {
|
|
24
|
+
return html.includes(`<meta name="generator" content="${REPLAY_GENERATOR}">`);
|
|
25
|
+
}
|
|
20
26
|
/** The folder beside it that holds the frames the page shows. */
|
|
21
27
|
export const REPLAY_FRAMES_DIRNAME = "replay-frames";
|
|
22
28
|
/** The folder beside it that holds one video per journey (--video). */
|
|
23
29
|
export const REPLAY_VIDEOS_DIRNAME = "replay-videos";
|
|
30
|
+
/** A shot's fields as an item carries them. */
|
|
31
|
+
function shotFields(shot) {
|
|
32
|
+
if (shot?.frame)
|
|
33
|
+
return { frame: shot.frame };
|
|
34
|
+
return shot?.pastCap ? { pastCap: true } : {};
|
|
35
|
+
}
|
|
24
36
|
/**
|
|
25
37
|
* Each step of a replayed flow with its caption, its result and its frame.
|
|
26
38
|
* The replay stops at the first step that breaks, so every step before it
|
|
27
39
|
* passed, it is the failing one, and those after it never ran (and have no
|
|
28
|
-
* frame). `frames` holds one entry per executed step,
|
|
40
|
+
* frame). `frames` holds one entry per executed step: its frame, or why it has none.
|
|
29
41
|
*/
|
|
30
42
|
export function journeySteps(steps, outcome, frames = []) {
|
|
31
43
|
const broke = outcome.status === "passed" ? null : outcome.step;
|
|
32
44
|
return steps.map((step, i) => {
|
|
33
45
|
const n = i + 1;
|
|
34
|
-
const
|
|
35
|
-
const base = { n, caption: describeStep(step), ...
|
|
46
|
+
const shot = broke === null || n <= broke ? shotFields(frames[i]) : {};
|
|
47
|
+
const base = { n, caption: describeStep(step), ...shot };
|
|
36
48
|
if (broke === null || n < broke)
|
|
37
49
|
return { ...base, result: "passed" };
|
|
38
50
|
if (n > broke)
|
|
@@ -53,26 +65,30 @@ export function journeyOf(flow, outcome, frames) {
|
|
|
53
65
|
};
|
|
54
66
|
}
|
|
55
67
|
/** One visited route, from what the crawl measured. */
|
|
56
|
-
export function visitOf(route,
|
|
68
|
+
export function visitOf(route, shot) {
|
|
57
69
|
const result = route.loadError !== undefined ? "not-loaded" : route.loginRedirect ? "sign-in" : route.status !== null && route.status >= 400 ? "http-error" : "loaded";
|
|
58
70
|
return {
|
|
59
71
|
path: route.path,
|
|
60
72
|
status: route.status,
|
|
61
73
|
result,
|
|
62
74
|
...(route.loadError !== undefined ? { reason: route.loadError } : {}),
|
|
63
|
-
...(
|
|
75
|
+
...shotFields(shot),
|
|
64
76
|
};
|
|
65
77
|
}
|
|
66
78
|
/**
|
|
67
79
|
* At most `max` frames per role, the first ones kept: the same cap a recorded
|
|
68
80
|
* session keeps (RECORD_MAX_FRAMES), held here too so the page never shows
|
|
69
|
-
* more than the run was allowed to take, whatever it is handed.
|
|
81
|
+
* more than the run was allowed to take, whatever it is handed. A step
|
|
82
|
+
* dropped here is marked past the cap like one the session did not take, and
|
|
83
|
+
* `framesLeftOut` counts both, so the page says why each has no frame.
|
|
70
84
|
*/
|
|
71
|
-
export function capFrames(replay, max = RECORD_MAX_FRAMES) {
|
|
72
|
-
let leftOut =
|
|
85
|
+
export function capFrames(replay, max = replay.frameCap ?? RECORD_MAX_FRAMES) {
|
|
86
|
+
let leftOut = 0;
|
|
73
87
|
const roles = replay.roles.map((role) => {
|
|
74
88
|
let kept = 0;
|
|
75
89
|
const take = (item) => {
|
|
90
|
+
if (item.pastCap)
|
|
91
|
+
leftOut += 1;
|
|
76
92
|
if (!item.frame)
|
|
77
93
|
return item;
|
|
78
94
|
if (kept < max) {
|
|
@@ -81,7 +97,7 @@ export function capFrames(replay, max = RECORD_MAX_FRAMES) {
|
|
|
81
97
|
}
|
|
82
98
|
leftOut += 1;
|
|
83
99
|
const { frame: _dropped, ...rest } = item;
|
|
84
|
-
return rest;
|
|
100
|
+
return { ...rest, pastCap: true };
|
|
85
101
|
};
|
|
86
102
|
return {
|
|
87
103
|
...role,
|
|
@@ -89,7 +105,7 @@ export function capFrames(replay, max = RECORD_MAX_FRAMES) {
|
|
|
89
105
|
journeys: role.journeys.map((j) => ({ ...j, steps: j.steps.map(take) })),
|
|
90
106
|
};
|
|
91
107
|
});
|
|
92
|
-
return { ...replay, roles, framesLeftOut: leftOut };
|
|
108
|
+
return { ...replay, roles, framesLeftOut: leftOut, frameCap: max };
|
|
93
109
|
}
|
|
94
110
|
/** The report's redaction (memory.ts redactRoute) on every path, caption and reason the page shows. */
|
|
95
111
|
export function redactReplay(replay) {
|
|
@@ -167,24 +183,31 @@ const VISIT_WORD = {
|
|
|
167
183
|
function stamp(iso) {
|
|
168
184
|
return iso.length >= 19 ? `${iso.slice(0, 10)} ${iso.slice(11, 19)} UTC` : iso;
|
|
169
185
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
186
|
+
/**
|
|
187
|
+
* A frame, or a line saying why there is none. No script: the note sits in the
|
|
188
|
+
* same grid cell under the picture, so a picture that loads covers it and one
|
|
189
|
+
* that is missing (a copy sent without replay-frames/) leaves it showing.
|
|
190
|
+
*/
|
|
191
|
+
function frameHtml(item, alt, cap) {
|
|
192
|
+
if (!item.frame)
|
|
193
|
+
return item.pastCap
|
|
194
|
+
? `<p class="noframe">No frame: the session had already kept ${cap}, the most one session keeps.</p>`
|
|
195
|
+
: `<p class="noframe">No frame</p>`;
|
|
196
|
+
const src = escapeHtml(item.frame);
|
|
197
|
+
return (`<a class="frame" href="${src}" target="_blank" rel="noreferrer" data-testid="replay-frame-open">` +
|
|
198
|
+
`<span class="gone-note">If no picture shows here, this frame is not beside this file. Frames live in the <code>${REPLAY_FRAMES_DIRNAME}/</code> folder, which travels with it.</span>` +
|
|
199
|
+
`<img loading="lazy" src="${src}" alt="${escapeHtml(alt)}"></a>`);
|
|
177
200
|
}
|
|
178
|
-
function stepHtml(s, anchor) {
|
|
201
|
+
function stepHtml(s, anchor, cap) {
|
|
179
202
|
const first = s.result === "failed" || s.result === "refused";
|
|
180
203
|
const detail = [s.reason, s.path ? `on ${s.path}` : ""].filter(Boolean).join(" ");
|
|
181
204
|
return (`<li class="step ${s.result}${first ? " first-failing" : ""}"${first ? ` id="${anchor}"` : ""} data-result="${s.result}">` +
|
|
182
205
|
`<div class="caption"><span class="n">${s.n}</span> <span class="what">${escapeHtml(s.caption)}</span> <span class="result r-${s.result}">${RESULT_WORD[s.result]}</span></div>` +
|
|
183
206
|
(detail ? `<p class="why">${escapeHtml(detail)}</p>` : "") +
|
|
184
|
-
(s.result === "not-run" ? "" : frameHtml(s
|
|
207
|
+
(s.result === "not-run" ? "" : frameHtml(s, `The page after step ${s.n}: ${s.caption}`, cap)) +
|
|
185
208
|
`</li>`);
|
|
186
209
|
}
|
|
187
|
-
function journeyHtml(j, id) {
|
|
210
|
+
function journeyHtml(j, id, cap) {
|
|
188
211
|
const ok = j.status === "passed";
|
|
189
212
|
const anchor = `${id}-first-failing`;
|
|
190
213
|
const badge = ok ? `<span class="badge pass">passed</span>` : `<span class="badge fail">${j.status}</span>`;
|
|
@@ -196,25 +219,27 @@ function journeyHtml(j, id) {
|
|
|
196
219
|
? `<figure class="video"><video controls preload="metadata" src="${escapeHtml(j.video)}" data-testid="replay-journey-video"></video>` +
|
|
197
220
|
`<figcaption>The whole journey as it ran. <a href="${escapeHtml(j.video)}" target="_blank" rel="noreferrer" data-testid="replay-video-open">Open the video</a></figcaption></figure>`
|
|
198
221
|
: "") +
|
|
199
|
-
`<ol class="steps">${j.steps.map((s) => stepHtml(s, anchor)).join("")}</ol></details>`);
|
|
222
|
+
`<ol class="steps">${j.steps.map((s) => stepHtml(s, anchor, cap)).join("")}</ol></details>`);
|
|
200
223
|
}
|
|
201
|
-
function visitsHtml(visits) {
|
|
224
|
+
function visitsHtml(visits, cap) {
|
|
202
225
|
if (visits.length === 0)
|
|
203
226
|
return "";
|
|
204
227
|
const items = visits
|
|
205
228
|
.map((v) => `<li class="visit ${v.result}"><div class="caption"><span class="what">visit ${escapeHtml(v.path)}</span> <span class="result v-${v.result}">${v.status ?? "—"} · ${VISIT_WORD[v.result]}</span></div>` +
|
|
206
229
|
(v.reason ? `<p class="why">${escapeHtml(v.reason)}</p>` : "") +
|
|
207
|
-
frameHtml(v
|
|
230
|
+
frameHtml(v, `The page at ${v.path}`, cap) +
|
|
208
231
|
`</li>`)
|
|
209
232
|
.join("");
|
|
210
233
|
return `<details class="visits"><summary data-testid="replay-visits-toggle">Routes visited <span class="count">${visits.length}</span></summary><ol class="steps">${items}</ol></details>`;
|
|
211
234
|
}
|
|
212
|
-
function roleHtml(r, i) {
|
|
235
|
+
function roleHtml(r, i, cap) {
|
|
213
236
|
const failing = r.journeys.filter((j) => j.status !== "passed").length;
|
|
214
237
|
const tally = `${r.journeys.length} journey${r.journeys.length === 1 ? "" : "s"}${failing ? `, ${failing} not passed` : ""}`;
|
|
215
238
|
return (`<section class="role" id="role-${i}"><h2>${escapeHtml(r.role)} <span class="count">${r.own ? "the check's own session · " : ""}${tally}</span></h2>` +
|
|
216
|
-
visitsHtml(r.visits) +
|
|
217
|
-
(r.journeys.length > 0
|
|
239
|
+
visitsHtml(r.visits, cap) +
|
|
240
|
+
(r.journeys.length > 0
|
|
241
|
+
? r.journeys.map((j, k) => journeyHtml(j, `role-${i}-journey-${k}`, cap)).join("")
|
|
242
|
+
: `<p class="none">No journey ran as this role.</p>`) +
|
|
218
243
|
`</section>`);
|
|
219
244
|
}
|
|
220
245
|
const STYLE = `
|
|
@@ -248,17 +273,17 @@ ol.steps { list-style:none; margin:10px 0 0; padding:0; }
|
|
|
248
273
|
.result { margin-left:auto; font-size:12px; font-weight:700; }
|
|
249
274
|
.r-passed, .v-loaded { color:var(--pass); } .r-failed, .r-refused, .v-http-error, .v-not-loaded, .v-sign-in { color:var(--fail); } .r-not-run { color:var(--muted); }
|
|
250
275
|
.why { margin:4px 0; font-size:13px; overflow-wrap:anywhere; }
|
|
251
|
-
a.frame { display:
|
|
252
|
-
a.frame
|
|
253
|
-
.
|
|
254
|
-
|
|
276
|
+
a.frame { display:grid; margin:6px 0 2px; max-width:min(100%,720px); color:var(--muted); font-size:12px; }
|
|
277
|
+
a.frame > * { grid-area:1 / 1; }
|
|
278
|
+
a.frame img { position:relative; max-width:100%; max-height:400px; object-fit:cover; object-position:top; border:1px solid var(--line); border-radius:6px; display:block; background:var(--panel); }
|
|
279
|
+
.gone-note { align-self:end; padding:2.6em 12px 12px; border:1px dashed var(--line); border-radius:6px; }
|
|
255
280
|
.noframe, .none { color:var(--muted); font-size:12px; font-style:italic; margin:4px 0; }
|
|
256
281
|
.note { color:var(--muted); font-size:13px; }
|
|
257
282
|
figure.video { margin:10px 0; max-width:min(100%,720px); }
|
|
258
283
|
figure.video video { width:100%; border:1px solid var(--line); border-radius:6px; display:block; background:#000; }
|
|
259
284
|
figure.video figcaption { color:var(--muted); font-size:12px; margin-top:4px; }
|
|
260
285
|
`;
|
|
261
|
-
/** The whole page: one HTML file with no external assets
|
|
286
|
+
/** The whole page: one HTML file with no scripts, no event handlers and no external assets; its frames and videos sit beside it. */
|
|
262
287
|
export function buildCheckReplayHtml(replay, meta) {
|
|
263
288
|
const verdict = meta.couldNotRun > 0 ? "could not run every flow" : meta.passed ? "passed" : "failed";
|
|
264
289
|
const badge = `<span class="badge ${meta.passed && meta.couldNotRun === 0 ? "pass" : "fail"}" data-testid="replay-verdict">${verdict}</span>`;
|
|
@@ -269,6 +294,7 @@ export function buildCheckReplayHtml(replay, meta) {
|
|
|
269
294
|
<head>
|
|
270
295
|
<meta charset="utf-8">
|
|
271
296
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
297
|
+
<meta name="generator" content="${REPLAY_GENERATOR}">
|
|
272
298
|
<title>SceneScout check replay — ${escapeHtml(meta.origin)}</title>
|
|
273
299
|
<style>${STYLE}</style>
|
|
274
300
|
</head>
|
|
@@ -278,8 +304,10 @@ export function buildCheckReplayHtml(replay, meta) {
|
|
|
278
304
|
<dl>${pair("App", meta.origin)}${pair("Started", stamp(meta.startedAt))}${pair("Ended", stamp(meta.endedAt))}${meta.commit ? pair("Commit", meta.commit) : ""}${pair("SceneScout", `v${meta.version}`)}${pair("Frames", String(frames))}${replayVideos(replay).length > 0 ? pair("Videos", String(replayVideos(replay).length)) : ""}</dl>
|
|
279
305
|
</header>
|
|
280
306
|
<main>
|
|
281
|
-
<p class="note">Each role, then each journey it walked, step by step, with the page as it was after the step${frames === 0 ? " when the check was recorded (--record)" : ""}. Typed values are never shown, and secrets in addresses are redacted as in the report.${replay.framesLeftOut > 0
|
|
282
|
-
${replay.
|
|
307
|
+
<p class="note">Each role, then each journey it walked, step by step, with the page as it was after the step${frames === 0 ? " when the check was recorded (--record)" : ""}. Typed values are never shown, and secrets in addresses are redacted as in the report.${replay.framesLeftOut > 0
|
|
308
|
+
? ` ${replay.framesLeftOut} step(s) and visit(s) have no frame because their session had already kept ${replay.frameCap ?? RECORD_MAX_FRAMES}, the most one session keeps.`
|
|
309
|
+
: ""}</p>
|
|
310
|
+
${replay.roles.map((r, i) => roleHtml(r, i, replay.frameCap ?? RECORD_MAX_FRAMES)).join("\n") || '<p class="none">Nothing was recorded.</p>'}
|
|
283
311
|
</main>
|
|
284
312
|
</body>
|
|
285
313
|
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.20.
|
|
3
|
+
"version": "3.20.1",
|
|
4
4
|
"description": "SceneScout — exploratory UI testing for AI coding agents. An MCP server that gives any agent (Claude Code, Cursor, VS Code Copilot, Codex, Gemini CLI and others) a structured view of a running web app, always-on oracles, a network-level write policy, memory across runs and a gap-checked report.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "brunoboto96",
|