scenescout 3.20.0 → 3.20.2
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 +12 -0
- package/dist/check-run.js +58 -20
- package/dist/cli.js +7 -4
- package/dist/engine/browser.js +40 -70
- package/dist/engine/check-replay.js +61 -33
- package/dist/engine/film.js +41 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# scenescout
|
|
2
2
|
|
|
3
|
+
## 3.20.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- f8f7ad2: A recorded `scenescout check` (`--record` or `--video`) no longer overwrites a `replay.html` in the output folder that it did not write: it stops before it starts, with exit code 2 and a message naming the file. With `--video`, a video that fails to start no longer stops every later flow from being filmed.
|
|
8
|
+
|
|
9
|
+
## 3.20.1
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
3
15
|
## 3.20.0
|
|
4
16
|
|
|
5
17
|
### 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 }))
|
|
@@ -500,6 +512,27 @@ export function clearReplayOutput(outDir) {
|
|
|
500
512
|
if (fs.readdirSync(root).length === 0)
|
|
501
513
|
fs.rmdirSync(root);
|
|
502
514
|
}
|
|
515
|
+
/**
|
|
516
|
+
* Why a recorded check (--record or --video) must not start, or null: a
|
|
517
|
+
* replay.html in the output folder that a check did not write is the
|
|
518
|
+
* project's own, and is neither removed nor overwritten. Asked before the
|
|
519
|
+
* browser starts, so the run is not spent first.
|
|
520
|
+
*/
|
|
521
|
+
export function replayPageConflict(outDir) {
|
|
522
|
+
const page = path.join(outDir, REPLAY_FILE);
|
|
523
|
+
let text;
|
|
524
|
+
try {
|
|
525
|
+
text = fs.readFileSync(page, "utf8");
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (err.code === "ENOENT")
|
|
529
|
+
return null;
|
|
530
|
+
throw err;
|
|
531
|
+
}
|
|
532
|
+
if (isGeneratedReplay(text))
|
|
533
|
+
return null;
|
|
534
|
+
return `${page} is not a page SceneScout wrote, so a recorded check will not overwrite it. Move or rename it, or pass --out to write the check somewhere else`;
|
|
535
|
+
}
|
|
503
536
|
/**
|
|
504
537
|
* The replay page's model for a recorded check, with its frames copied from
|
|
505
538
|
* the browsers' scratch folders to replay-frames/ beside the report: routes
|
|
@@ -510,25 +543,30 @@ function recordReplay(o) {
|
|
|
510
543
|
const outDir = o.options.outDir ?? defaultCheckDir(o.options.projectDir);
|
|
511
544
|
/** Where each frame the page names is copied from. */
|
|
512
545
|
const sources = new Map();
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
if (
|
|
516
|
-
|
|
517
|
-
|
|
546
|
+
/** A shot with its frame's path moved to where it is copied beside the page. */
|
|
547
|
+
const beside = (shot, memoryDir) => {
|
|
548
|
+
if (!shot?.frame)
|
|
549
|
+
return shot?.pastCap ? { pastCap: true } : {};
|
|
550
|
+
const to = replayFramePath(shot.frame);
|
|
551
|
+
if (!to)
|
|
552
|
+
return {};
|
|
553
|
+
sources.set(to, path.join(memoryDir, ...shot.frame.split("/")));
|
|
554
|
+
return { frame: to };
|
|
518
555
|
};
|
|
519
|
-
//
|
|
556
|
+
// What the crawl kept for each visit, by the path it asked for, in the order the visits ran.
|
|
520
557
|
const own = replaySessionKey();
|
|
521
558
|
const crawled = new Map();
|
|
522
559
|
for (const e of o.log) {
|
|
523
|
-
if (e.action !== "crawl" ||
|
|
560
|
+
if (e.action !== "crawl" || e.target === undefined || (e.session ?? own) !== own)
|
|
524
561
|
continue;
|
|
525
|
-
|
|
562
|
+
const shot = e.frame ? { frame: e.frame } : e.framePastCap ? { pastCap: true } : {};
|
|
563
|
+
crawled.set(e.target, [...(crawled.get(e.target) ?? []), shot]);
|
|
526
564
|
}
|
|
527
565
|
const ownSession = {
|
|
528
566
|
role: o.ownRole,
|
|
529
567
|
own: true,
|
|
530
568
|
// 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))
|
|
569
|
+
visits: o.visited.map((route) => visitOf(route, beside(crawled.get(route.path)?.shift(), path.join(o.scratch, MEMORY_DIRNAME)))),
|
|
532
570
|
journeys: [],
|
|
533
571
|
};
|
|
534
572
|
const roles = [ownSession];
|
|
@@ -544,7 +582,7 @@ function recordReplay(o) {
|
|
|
544
582
|
}
|
|
545
583
|
role.journeys.push(journey);
|
|
546
584
|
}
|
|
547
|
-
const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0 }));
|
|
585
|
+
const replay = redactReplay(capFrames({ startedAt: o.startedAt, roles, framesLeftOut: 0, ...(o.frameCap !== undefined ? { frameCap: o.frameCap } : {}) }));
|
|
548
586
|
// A frame that cannot be copied is left off the page rather than shown broken.
|
|
549
587
|
const copied = new Set();
|
|
550
588
|
let failed = false;
|
package/dist/cli.js
CHANGED
|
@@ -21,7 +21,7 @@ import { APPROX_DISK_MB, defaultAttachNote, defaultEngine, launchTarget, parseBr
|
|
|
21
21
|
import { CLIENT_LABELS, firstMessageHint, manualFor, parseClients, registerWithClient, vscodeBinary } from "./clients.js";
|
|
22
22
|
import { CLAUDE_CODE_NOT_NEEDED, CLI_NAME, desktopExtensionRoots, diagnose, doctorAllGood, findDesktopExtension, installClosing, ensureCommand, findOnUserPath, installSkill, isEphemeralRoot, launchCommand, manualRegisterCommand, planCommand, registerMcp, resolveClaudeDir, spawnRunner, } from "./installer.js";
|
|
23
23
|
import { downloadBrowsers, presentBrowsers } from "./installer.js";
|
|
24
|
-
import { baselinesDirOf, clearReplayOutput, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
24
|
+
import { baselinesDirOf, clearReplayOutput, replayPageConflict, defaultCheckDir, readCheckInputs, runCheck } from "./check-run.js";
|
|
25
25
|
import { buildCheckReplayHtml, commitOf, REPLAY_FILE, replayFrames, replayVideos } from "./engine/check-replay.js";
|
|
26
26
|
import { recordChoice } from "./engine/capture.js";
|
|
27
27
|
import { httpClient, httpJudgeAsk, runCi } from "./ci-run.js";
|
|
@@ -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.
|
|
@@ -584,6 +583,10 @@ async function check(args) {
|
|
|
584
583
|
try {
|
|
585
584
|
// --record, else SCENESCOUT_RECORD, else off.
|
|
586
585
|
options = { ...options, record: recordChoice(options.record, process.env) };
|
|
586
|
+
// A replay.html the project keeps there is its own: a recorded check refuses to start rather than overwrite it.
|
|
587
|
+
const conflict = options.record || options.video ? replayPageConflict(outDir) : null;
|
|
588
|
+
if (conflict)
|
|
589
|
+
throw new Error(conflict);
|
|
587
590
|
// A replay page an earlier run left must never be read, or uploaded, as this run's.
|
|
588
591
|
clearReplayOutput(outDir);
|
|
589
592
|
inputs = readCheckInputs(options);
|
package/dist/engine/browser.js
CHANGED
|
@@ -16,6 +16,7 @@ import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js"
|
|
|
16
16
|
import { formatJourney, journeyTime, measureJourney } from "./journey.js";
|
|
17
17
|
import { describeStep, elementStateMatches, FLOW_AFTER_LAST_STEP_MS, isAction, matchRequest, notActionable, parseTarget, repeatFailure, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
|
|
18
18
|
import { framePath, RECORD_MAX_FRAMES } from "./replay.js";
|
|
19
|
+
import { film } from "./film.js";
|
|
19
20
|
import { InFlightRequests, keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
|
|
20
21
|
import { crawledRoute, crawlLine, isFileMediaType, isNonPageResource, mainStateFlag, mediaTypeOf } from "./crawl.js";
|
|
21
22
|
import { authToRemember, BODY_FETCH_MAX, buildRequestScript, formatPageRequests, formatReplay, PageRequests, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, resolveTarget, staleCredentialNote, toReplayResult, wantsView, } from "./request.js";
|
|
@@ -460,6 +461,8 @@ export class BrowserEngine {
|
|
|
460
461
|
* checked, a recording shows it.
|
|
461
462
|
*/
|
|
462
463
|
recording = false;
|
|
464
|
+
/** Most frames this recorded session keeps (attach's maxFrames). */
|
|
465
|
+
maxFrames = RECORD_MAX_FRAMES;
|
|
463
466
|
framesKept = 0;
|
|
464
467
|
/** How many frames could not be written. The first one says so in the log; the rest are counted. */
|
|
465
468
|
framesFailed = 0;
|
|
@@ -592,8 +595,13 @@ export class BrowserEngine {
|
|
|
592
595
|
* page looked like: `...(await this.frameFor("crawl"))`.
|
|
593
596
|
*/
|
|
594
597
|
async frameFor(action) {
|
|
598
|
+
const pastCap = this.framePastCap();
|
|
595
599
|
const frame = await this.recordFrame(action);
|
|
596
|
-
return frame ? { frame } : {};
|
|
600
|
+
return frame ? { frame } : pastCap ? { framePastCap: true } : {};
|
|
601
|
+
}
|
|
602
|
+
/** On a recorded session, whether the next step gets no frame because the session already keeps as many as it may. */
|
|
603
|
+
framePastCap() {
|
|
604
|
+
return this.recording && this.framesKept >= this.maxFrames;
|
|
597
605
|
}
|
|
598
606
|
/**
|
|
599
607
|
* The frame for the step just taken, as a path relative to the memory
|
|
@@ -603,7 +611,7 @@ export class BrowserEngine {
|
|
|
603
611
|
*/
|
|
604
612
|
async recordFrame(action) {
|
|
605
613
|
const dir = this.memory?.dir;
|
|
606
|
-
if (!this.recording || !dir || this.framesKept >=
|
|
614
|
+
if (!this.recording || !dir || this.framesKept >= this.maxFrames)
|
|
607
615
|
return undefined;
|
|
608
616
|
const jpeg = await this.liveShot(RECORD_SHOT_TIMEOUT_MS);
|
|
609
617
|
if (!jpeg)
|
|
@@ -630,8 +638,8 @@ export class BrowserEngine {
|
|
|
630
638
|
return undefined;
|
|
631
639
|
}
|
|
632
640
|
this.framesKept += 1;
|
|
633
|
-
if (this.framesKept ===
|
|
634
|
-
this.logAction({ action: "record:full", target: `${
|
|
641
|
+
if (this.framesKept === this.maxFrames) {
|
|
642
|
+
this.logAction({ action: "record:full", target: `${this.maxFrames} frames kept; later steps have none`, url: this.page?.url() ?? "" });
|
|
635
643
|
}
|
|
636
644
|
return rel;
|
|
637
645
|
}
|
|
@@ -1104,6 +1112,7 @@ export class BrowserEngine {
|
|
|
1104
1112
|
this.setTask(opts.task ?? "Attaching and taking stock", opts.task !== undefined);
|
|
1105
1113
|
this.setPace(opts.paceMs);
|
|
1106
1114
|
this.recording = opts.record === true;
|
|
1115
|
+
this.maxFrames = opts.maxFrames ?? RECORD_MAX_FRAMES;
|
|
1107
1116
|
// A re-attached engine starts a new recording: numbering from where the
|
|
1108
1117
|
// last one stopped would run into the cap with frames it never took.
|
|
1109
1118
|
this.framesKept = 0;
|
|
@@ -1184,7 +1193,6 @@ export class BrowserEngine {
|
|
|
1184
1193
|
viewport: opts.viewport ?? { width: 1280, height: 900 },
|
|
1185
1194
|
...(opts.deviceScaleFactor !== undefined ? { deviceScaleFactor: opts.deviceScaleFactor } : {}),
|
|
1186
1195
|
serviceWorkers: serviceWorkerPolicy(this.engineName),
|
|
1187
|
-
...(opts.videoDir ? { recordVideo: { dir: opts.videoDir } } : {}),
|
|
1188
1196
|
});
|
|
1189
1197
|
const restoreSession = sessionStorageInitScript(profile?.sessionStorage ?? []);
|
|
1190
1198
|
if (restoreSession)
|
|
@@ -1510,9 +1518,6 @@ export class BrowserEngine {
|
|
|
1510
1518
|
// oracles attached); close foreign-origin popups so exploration cannot
|
|
1511
1519
|
// silently escape the app under test.
|
|
1512
1520
|
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
1521
|
newPage
|
|
1517
1522
|
.waitForLoadState("domcontentloaded", { timeout: this.limits.backNavMs })
|
|
1518
1523
|
.then(() => {
|
|
@@ -1675,65 +1680,21 @@ export class BrowserEngine {
|
|
|
1675
1680
|
nativeDialogAt = 0;
|
|
1676
1681
|
/** When a native dialog that asks something (confirm, prompt, a leave confirmation) last opened; an alert only tells. */
|
|
1677
1682
|
nativeQuestionAt = 0;
|
|
1678
|
-
/** True while onOwnPage opens its page, so the popup handler leaves it alone. */
|
|
1679
|
-
openingOwnPage = false;
|
|
1680
|
-
/** Open a page in this session's context and drive it from now on, wired as attach wires its first page. */
|
|
1681
|
-
async driveNewPage() {
|
|
1682
|
-
const context = this.context;
|
|
1683
|
-
if (!context)
|
|
1684
|
-
throw new Error("Not attached. Call scout_attach first with the app URL and project path.");
|
|
1685
|
-
this.openingOwnPage = true;
|
|
1686
|
-
let page;
|
|
1687
|
-
try {
|
|
1688
|
-
page = await context.newPage();
|
|
1689
|
-
}
|
|
1690
|
-
finally {
|
|
1691
|
-
this.openingOwnPage = false;
|
|
1692
|
-
}
|
|
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
1683
|
/**
|
|
1703
|
-
* Run `work`
|
|
1704
|
-
*
|
|
1705
|
-
* session
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
1708
|
-
*
|
|
1684
|
+
* Run `work` while the session's page is filmed into `videoTo` (a WebM,
|
|
1685
|
+
* Playwright's page screencast), so the video holds that work and nothing
|
|
1686
|
+
* the session did before or after it. The page, its tab and so its
|
|
1687
|
+
* sessionStorage are the session's own: the work runs exactly as it would
|
|
1688
|
+
* unfilmed. The video is null when it could not be started or saved
|
|
1689
|
+
* (`videoError` says why); the work's own result and errors are untouched.
|
|
1709
1690
|
*/
|
|
1710
|
-
async
|
|
1711
|
-
const
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
const context = this.context;
|
|
1718
|
-
// The work may have adopted a popup, or been left on its own page: everything open now is the work's.
|
|
1719
|
-
if (context)
|
|
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) };
|
|
1736
|
-
}
|
|
1691
|
+
async filming(videoTo, work) {
|
|
1692
|
+
const page = this.requirePage();
|
|
1693
|
+
// The page the filming began on is the one stopped, even if the work moved the session to a popup.
|
|
1694
|
+
return film(page.screencast, videoTo, page.viewportSize(), work, {
|
|
1695
|
+
prepare: (file) => fs.promises.mkdir(path.dirname(file), { recursive: true }).then(() => undefined),
|
|
1696
|
+
written: (file) => fs.promises.stat(file).then((st) => st.size > 0, () => false),
|
|
1697
|
+
});
|
|
1737
1698
|
}
|
|
1738
1699
|
requirePage() {
|
|
1739
1700
|
if (!this.page || !this.memory) {
|
|
@@ -4469,7 +4430,8 @@ export class BrowserEngine {
|
|
|
4469
4430
|
async inspectRoute(page, elements, url) {
|
|
4470
4431
|
const { geometry, brokenImages } = await this.measureLayout(page, elements, url);
|
|
4471
4432
|
try {
|
|
4472
|
-
|
|
4433
|
+
// The crawl has just kept this page's frame; a second of the same page would only use up the session's cap.
|
|
4434
|
+
const { defects, sampled } = await this.auditPage({ frame: false });
|
|
4473
4435
|
// Nothing styled to read is not a clean page: contrast, focus and target size went unmeasured.
|
|
4474
4436
|
return sampled > 0
|
|
4475
4437
|
? { geometry, brokenImages, design: defects }
|
|
@@ -5342,8 +5304,11 @@ export class BrowserEngine {
|
|
|
5342
5304
|
const did = describeStep(step);
|
|
5343
5305
|
const { failure, refusal } = step.action === "repeat" ? await runRepeat(step) : await runStep(step);
|
|
5344
5306
|
// 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
|
-
|
|
5307
|
+
if (this.recording) {
|
|
5308
|
+
const pastCap = this.framePastCap();
|
|
5309
|
+
const frame = await this.recordFrame(`flow-${step.action}`);
|
|
5310
|
+
frames.push(frame ? { frame } : pastCap ? { pastCap: true } : {});
|
|
5311
|
+
}
|
|
5347
5312
|
if (refusal)
|
|
5348
5313
|
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
5349
5314
|
if (failure)
|
|
@@ -5402,7 +5367,7 @@ export class BrowserEngine {
|
|
|
5402
5367
|
return `URL: ${url}\n` + report;
|
|
5403
5368
|
}
|
|
5404
5369
|
/** The design audit's measurements, as data as well as prose; scout_design_audit and a check's crawl share it. */
|
|
5405
|
-
async auditPage() {
|
|
5370
|
+
async auditPage(opts = {}) {
|
|
5406
5371
|
const page = this.requirePage();
|
|
5407
5372
|
await this.settle();
|
|
5408
5373
|
const payload = (await page.evaluate(DESIGN_COLLECT_SCRIPT));
|
|
@@ -5424,7 +5389,12 @@ export class BrowserEngine {
|
|
|
5424
5389
|
this.memory?.setPageScore(route, { ...score, at: new Date().toISOString(), url: page.url() });
|
|
5425
5390
|
this.memory?.markRouteFact(route, { audited: true });
|
|
5426
5391
|
}
|
|
5427
|
-
this.logAction({
|
|
5392
|
+
this.logAction({
|
|
5393
|
+
action: "design-audit",
|
|
5394
|
+
url: page.url(),
|
|
5395
|
+
result: score ? `score:${score.overall}` : undefined,
|
|
5396
|
+
...(opts.frame === false ? {} : await this.frameFor("design-audit")),
|
|
5397
|
+
});
|
|
5428
5398
|
return { url: page.url(), report, defects, sampled: payload.records.length };
|
|
5429
5399
|
}
|
|
5430
5400
|
/**
|
|
@@ -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>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filming one piece of work into a video file (`scenescout check --video`):
|
|
3
|
+
* start, run the work, stop, and say whether a video was saved. The browser
|
|
4
|
+
* hands in its page's screencast; nothing here needs Playwright, so the
|
|
5
|
+
* failure paths are table-tested.
|
|
6
|
+
*/
|
|
7
|
+
const why = (err) => (err instanceof Error ? err.message.split("\n")[0] : String(err));
|
|
8
|
+
/**
|
|
9
|
+
* Run `work` while `screencast` films it into `videoTo`. The work's own result
|
|
10
|
+
* and errors are never touched by the filming: a video that cannot be started
|
|
11
|
+
* or saved is reported in `videoError`, and the work runs regardless.
|
|
12
|
+
*
|
|
13
|
+
* A start that fails is followed by a stop, its error ignored: a screencast
|
|
14
|
+
* left half-started would refuse every later start ("already started"), so
|
|
15
|
+
* one failure would cost the video of every flow after it.
|
|
16
|
+
*/
|
|
17
|
+
export async function film(screencast, videoTo, size, work, io) {
|
|
18
|
+
let videoError;
|
|
19
|
+
let filming = false;
|
|
20
|
+
try {
|
|
21
|
+
await io.prepare(videoTo);
|
|
22
|
+
await screencast.start({ path: videoTo, ...(size ? { size } : {}) });
|
|
23
|
+
filming = true;
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
videoError = `the video could not be started: ${why(err)}`;
|
|
27
|
+
// Whatever the failed start left running is stopped, so the next flow can start afresh; there is nothing to save.
|
|
28
|
+
await screencast.stop().catch(() => undefined);
|
|
29
|
+
}
|
|
30
|
+
let value;
|
|
31
|
+
try {
|
|
32
|
+
value = await work();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
if (filming)
|
|
36
|
+
await screencast.stop().catch((err) => (videoError = `the video could not be saved: ${why(err)}`));
|
|
37
|
+
}
|
|
38
|
+
if (videoError)
|
|
39
|
+
return { value, video: null, videoError };
|
|
40
|
+
return (await io.written(videoTo)) ? { value, video: videoTo } : { value, video: null, videoError: "no video was written" };
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scenescout",
|
|
3
|
-
"version": "3.20.
|
|
3
|
+
"version": "3.20.2",
|
|
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",
|