scenescout 3.17.0 → 3.19.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 +37 -0
- package/README.md +9 -4
- package/dist/check-run.js +78 -15
- package/dist/cli.js +6 -4
- package/dist/engine/baseline.js +43 -0
- package/dist/engine/browser.js +318 -144
- package/dist/engine/check.js +116 -12
- package/dist/engine/collector.js +62 -21
- package/dist/engine/crawl.js +37 -0
- package/dist/engine/design.js +77 -3
- package/dist/engine/export.js +200 -1
- package/dist/engine/fingerprint.js +16 -0
- package/dist/engine/flow.js +171 -4
- package/dist/engine/memory.js +74 -20
- package/dist/engine/policy.js +31 -0
- package/dist/engine/probes.js +75 -16
- package/dist/export-run.js +231 -55
- package/dist/first-run.js +7 -1
- package/dist/intake.js +161 -0
- package/dist/mcp-server.js +41 -0
- package/dist/playbook.js +1 -1
- package/package.json +2 -2
- package/skills/scenescout/SKILL.md +2 -2
package/dist/engine/browser.js
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
import { chromium, firefox, webkit, } from "playwright";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
-
import { elementKey, fingerprintState, isNonPageRoute,
|
|
5
|
-
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, reachedRoutes, redactSecrets, TASK_SET } from "./memory.js";
|
|
4
|
+
import { elementKey, fingerprintState, isNonPageRoute, keyAliasSteps, normalizePath, ordinalKeys, refsSurviveUrlChange, routeBase, } from "./fingerprint.js";
|
|
5
|
+
import { AUTH_LOSS_PREFIX, JOURNEY_END, JOURNEY_START, MemoryStore, NAME_RULE, reachedRoutes, redactSecrets, TASK_SET, } from "./memory.js";
|
|
6
6
|
import { normalizeTask } from "./task.js";
|
|
7
7
|
import { ACTED_CONTROL_SRC, CLAIM_SCAN_SCRIPT, findContradictions, INFRASTRUCTURE_WRITE_RE, isBackgroundRequest, OPEN_DIALOGS_SCRIPT, quietAnswer, saidSince, } from "./claims.js";
|
|
8
8
|
import { POSTMESSAGE_BINDING, describeTokenPost, postMessageCaptureScript, tokenHits, tokenPostKey } from "./postmessage.js";
|
|
9
9
|
import { describeInjection, newInjections, probeQueries, probeScript, probeShape, rememberProbe } from "./injection.js";
|
|
10
10
|
import { AuthLossTracker } from "./authloss.js";
|
|
11
11
|
import { captureClip, cutByViewport } from "./capture.js";
|
|
12
|
-
import { STOP_ANIMATIONS_SCRIPT } from "./baseline.js";
|
|
12
|
+
import { NEXT_FRAME_SCRIPT, NEXT_FRAME_WAIT_MS, STOP_ANIMATIONS_SCRIPT, steadyPicture, unsteadyNote } from "./baseline.js";
|
|
13
13
|
import { COLLECT_INTERACTABLES_SCRIPT, COLLECTOR_CAP, NAME_SRC, POLICY_TEXT_SRC, VISIBLE_SRC, XPATH_OF_SRC, affordanceFlags, cutSummary, matchPrevious, geometryIssues, BROKEN_IMAGES_SCRIPT, brokenImageIssues, frameLines, hasVisibleFrame, frameElementKey, frameLabel, masksForeignName, MASKED_NAME, capForeignName, stripForeignHref, frameToPageRect, displayName, missingName, placeholderOnly, placeholderEvidence, describeControl, labelFlag, stateFlags, stateChange, trackedElements, inertKeys, MAIN_REGION_SCRIPT, mainRegionLine, mainRegionTag, mainState, describeCover, } from "./collector.js";
|
|
14
14
|
import { OracleMonitor, formatViolations, httpErrorDetail, planStopsAt, requestKey } from "./oracles.js";
|
|
15
15
|
import { extractCreatedIds, isOwnedResource, normalizeId } from "./ownership.js";
|
|
16
16
|
import { formatJourney, journeyTime, measureJourney } from "./journey.js";
|
|
17
|
-
import { describeStep, FLOW_AFTER_LAST_STEP_MS, isAction, matchRequest, parseTarget, splitRefusals, TARGET_HELP, urlMatches, } from "./flow.js";
|
|
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
19
|
import { InFlightRequests, keepWatchingUrl, normalizePace, SETTLE_TICK_MS, shouldKeepWaiting } from "./settle.js";
|
|
20
|
-
import { crawledRoute, crawlLine, mainStateFlag } from "./crawl.js";
|
|
20
|
+
import { crawledRoute, crawlLine, isFileMediaType, isNonPageResource, mainStateFlag, mediaTypeOf } from "./crawl.js";
|
|
21
21
|
import { authToRemember, BODY_FETCH_MAX, buildRequestScript, formatPageRequests, formatReplay, PageRequests, replaySignature, requestHeaders, resolveMethod, resolveRequestUrl, resolveTarget, staleCredentialNote, toReplayResult, wantsView, } from "./request.js";
|
|
22
22
|
import { defaultEngine, focusAdvanceKey, REMOVE_SHARED_WORKER_SCRIPT, screencastSupport, serviceWorkerPolicy, sharedWorkersAllowed, unloadWriteInterception, closeWaitsForLeavingWrites, } from "../browsers.js";
|
|
23
23
|
import { revealedLines } from "./hover.js";
|
|
@@ -28,7 +28,7 @@ import { performScroll, probeFocusIndicators, probeOverlays, scrollContainer } f
|
|
|
28
28
|
import { boundedTeardown } from "./teardown.js";
|
|
29
29
|
import { BROWSER_MARKER, reapOrphanBrowsers } from "./reaper.js";
|
|
30
30
|
import { planUploadOptions, resolveDiskUpload } from "./uploads.js";
|
|
31
|
-
import { answersWithRefusal, destructiveRefusal, destructiveLabelOf, pickIsDestructive, isDestructive, isDestructiveWire, blockSignature, BlockNotices, dialogNote, dialogResponse, ESCAPE_REFUSAL, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, MAX_READ_POSTS, READ_POSTS_ENV, readPostAllowed, matchReadPost, readPostEntries, readPostsSetting, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
|
|
31
|
+
import { answersWithRefusal, destructiveRefusal, destructiveLabelOf, pickIsDestructive, isDestructive, isDestructiveWire, blockSignature, BlockNotices, dialogNote, dialogResponse, ESCAPE_REFUSAL, allowsWrite, policyRefusal, foreignFrameOrigin, foreignWrite, withForeignFrameSandbox, offAppPageWrite, embedOfRequest, hostileForEmbed, trustedEmbedOrigins, MAX_TRUSTED_EMBEDS, MAX_READ_POSTS, READ_POSTS_ENV, readPostAllowed, matchReadPost, refusedPostPage, readPostEntries, readPostsSetting, trustsForeignWrite, embedProbeRefusal, EmbedMoveTracker, sandboxedRedirectPage, allowsForeignWriteOnSignIn, isAuthExempt, WriteRule, } from "./policy.js";
|
|
32
32
|
import { scanProject } from "../scan.js";
|
|
33
33
|
import { analyzeDesign, DESIGN_COLLECT_SCRIPT } from "./design.js";
|
|
34
34
|
import { acceptMatches, generatedUpload } from "./fixtures.js";
|
|
@@ -263,6 +263,8 @@ function actionabilityDiagnostic(message) {
|
|
|
263
263
|
const SCREENCAST_PAGELESS_TICKS = 20;
|
|
264
264
|
/** A frame for the record is worth a moment, not a stall: the action has already happened. */
|
|
265
265
|
const RECORD_SHOT_TIMEOUT_MS = 2500;
|
|
266
|
+
/** How long a repeat step waits for its condition after each round of its actions, before running them again. */
|
|
267
|
+
const REPEAT_CHECK_MS = 1000;
|
|
266
268
|
export class BrowserEngine {
|
|
267
269
|
browser = null;
|
|
268
270
|
/** How long an action and a page load may take; set at attach (limits.ts). */
|
|
@@ -1406,7 +1408,7 @@ export class BrowserEngine {
|
|
|
1406
1408
|
const refuse = (why) => {
|
|
1407
1409
|
const answered = answersWithRefusal(req.resourceType());
|
|
1408
1410
|
if (rule === "observe" && method === "POST" && !why && answered && !BENIGN_MUTATION_RE.test(url))
|
|
1409
|
-
this.noteObserveRefusedPost(url, req.postData());
|
|
1411
|
+
this.noteObserveRefusedPost(url, req.postData(), this.sentByMainFrame(req) ? req.headers()["referer"] : undefined);
|
|
1410
1412
|
this.noteBlocked({ at: Date.now(), sig: `${method} ${url.slice(0, 140)}`, answered, why, type: req.resourceType() });
|
|
1411
1413
|
this.logAction({ action: "write-policy:blocked", target: `${method} ${pathname}${why ? ` (${why})` : ""}`, url: this.page?.url() ?? "" });
|
|
1412
1414
|
this.refusedByPolicy.add(req);
|
|
@@ -1826,16 +1828,16 @@ export class BrowserEngine {
|
|
|
1826
1828
|
// A live region listed for what it says is known by its role, not its
|
|
1827
1829
|
// text, so a new message reads as the same region saying something else.
|
|
1828
1830
|
const baseKeyOf = (el, name, tag) => frameElementKey(el.liveOnly ? `live:${el.role}` : elementKey({ ...el, name }), tag);
|
|
1831
|
+
// Each element's base key under every name rule, rule 1 first, so each step between two rules gets its own aliases.
|
|
1829
1832
|
const bases = all.map(({ raw: el, tag }) => ({
|
|
1830
|
-
|
|
1831
|
-
prior: baseKeyOf(el, el.priorName ?? el.name, tag),
|
|
1833
|
+
byRule: Array.from({ length: NAME_RULE }, (_, i) => baseKeyOf(el, i < NAME_RULE - 1 ? (el.priorNames?.[i] ?? el.name) : el.name, tag)),
|
|
1832
1834
|
tracked: !el.liveOnly,
|
|
1833
1835
|
}));
|
|
1834
|
-
const keys = ordinalKeys(bases.map((b) => b.
|
|
1835
|
-
const aliases =
|
|
1836
|
+
const keys = ordinalKeys(bases.map((b) => b.byRule[NAME_RULE - 1]));
|
|
1837
|
+
const aliases = keyAliasSteps(bases);
|
|
1836
1838
|
const built = all.map(({ raw: el, frame, tag }, i) => {
|
|
1837
1839
|
const key = keys[i];
|
|
1838
|
-
const { ownText: _ownText, centre: _centre, cut: _cut,
|
|
1840
|
+
const { ownText: _ownText, centre: _centre, cut: _cut, priorNames: _priorNames, ...listed } = el;
|
|
1839
1841
|
const full = {
|
|
1840
1842
|
...listed,
|
|
1841
1843
|
...(tag ? { frame: tag } : {}),
|
|
@@ -2332,18 +2334,29 @@ export class BrowserEngine {
|
|
|
2332
2334
|
}
|
|
2333
2335
|
/**
|
|
2334
2336
|
* Remember in project memory, for the gap ledger, a script's POST that
|
|
2335
|
-
* observe refused
|
|
2336
|
-
* destructive, nor one to an endpoint
|
|
2337
|
-
* a mutation): naming it would change
|
|
2337
|
+
* observe refused, charged to the page that sent it (policy.ts
|
|
2338
|
+
* refusedPostPage). Not one that looks destructive, nor one to an endpoint
|
|
2339
|
+
* already named as a read (its body was a mutation): naming it would change
|
|
2340
|
+
* nothing.
|
|
2338
2341
|
*/
|
|
2339
|
-
noteObserveRefusedPost(url, body) {
|
|
2340
|
-
const pageUrl = this.page?.url();
|
|
2342
|
+
noteObserveRefusedPost(url, body, referer) {
|
|
2343
|
+
const pageUrl = refusedPostPage(this.baseUrl, referer, this.page?.url());
|
|
2341
2344
|
if (!pageUrl || !this.memory || isDestructiveWire(pathnameOf(url), body) || matchReadPost(this.readPosts, this.baseUrl, url))
|
|
2342
2345
|
return;
|
|
2343
2346
|
const endpoint = this.refusedPostEndpoint(url);
|
|
2344
2347
|
if (endpoint)
|
|
2345
2348
|
this.memory.noteObserveRefusedPost(normalizePath(pageUrl), endpoint);
|
|
2346
2349
|
}
|
|
2350
|
+
/** Whether the session's page sent `req` from its top document, not from a frame or a service worker. */
|
|
2351
|
+
sentByMainFrame(req) {
|
|
2352
|
+
try {
|
|
2353
|
+
return !!this.page && req.frame() === this.page.mainFrame();
|
|
2354
|
+
}
|
|
2355
|
+
catch {
|
|
2356
|
+
// A service worker's request has no frame.
|
|
2357
|
+
return false;
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2347
2360
|
/** A POST to this endpoint went out, so the pages observe refused it on are no longer a gap for it. */
|
|
2348
2361
|
clearRefusedPost(url) {
|
|
2349
2362
|
const endpoint = this.refusedPostEndpoint(url);
|
|
@@ -4332,7 +4345,9 @@ export class BrowserEngine {
|
|
|
4332
4345
|
/** The full route contract: scanned filesystem routes ∪ link-discovered route classes. */
|
|
4333
4346
|
allKnownRoutes() {
|
|
4334
4347
|
const discovered = this.memory ? Object.keys(this.memory.discoveredRoutes) : [];
|
|
4335
|
-
|
|
4348
|
+
// A route that answered with a feed or a file is not a page, whatever its path looks like.
|
|
4349
|
+
const resources = new Set(Object.keys(this.memory?.resourceRoutes ?? {}).map(normalizePath));
|
|
4350
|
+
return [...new Set([...this.knownRoutes, ...discovered])].filter((r) => !isNonPageRoute(r) && !resources.has(normalizePath(r)));
|
|
4336
4351
|
}
|
|
4337
4352
|
/**
|
|
4338
4353
|
* Contract routes neither visited nor attempted by THIS role.
|
|
@@ -4450,29 +4465,89 @@ export class BrowserEngine {
|
|
|
4450
4465
|
this.actionStartedAt = Date.now();
|
|
4451
4466
|
this.oracles.drain(false); // discard pre-route leftovers WITHOUT marking their signatures as reported
|
|
4452
4467
|
let status = "ERR";
|
|
4468
|
+
// The main document's response, as the browser received it. Kept apart from goto's own result: a browser that
|
|
4469
|
+
// treats a PDF, a feed or an attachment as a download throws from goto, and this is then the only record of it.
|
|
4470
|
+
const answered = { last: null };
|
|
4471
|
+
const asked = new URL(url).href;
|
|
4472
|
+
const onResponse = (r) => {
|
|
4473
|
+
if (r.frame() !== page.mainFrame() || !r.request().isNavigationRequest())
|
|
4474
|
+
return;
|
|
4475
|
+
// A late event from the page before is not this route's: its redirect chain must start at the URL asked for.
|
|
4476
|
+
let first = r.request();
|
|
4477
|
+
for (let from = first.redirectedFrom(); from; from = from.redirectedFrom())
|
|
4478
|
+
first = from;
|
|
4479
|
+
if (first.url() !== asked)
|
|
4480
|
+
return;
|
|
4481
|
+
answered.last = { url: r.url(), status: r.status(), contentType: mediaTypeOf(r.headers()["content-type"]) };
|
|
4482
|
+
};
|
|
4483
|
+
page.on("response", onResponse);
|
|
4484
|
+
let resource = null;
|
|
4453
4485
|
try {
|
|
4454
4486
|
const resp = await page.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs });
|
|
4455
4487
|
status = resp?.status() ?? "no-response";
|
|
4488
|
+
const contentType = mediaTypeOf(resp?.headers()["content-type"]);
|
|
4489
|
+
if (resp && isNonPageResource({ status: resp.status(), contentType }))
|
|
4490
|
+
resource = { url: page.url(), status: resp.status(), contentType: contentType };
|
|
4456
4491
|
}
|
|
4457
4492
|
catch (err) {
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
this.
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4493
|
+
// A browser that took the answer as a download failed the navigation, but the route did answer, with its status:
|
|
4494
|
+
// a feed that is missing is the route's 404, not a page that did not load.
|
|
4495
|
+
const seen = answered.last;
|
|
4496
|
+
if (seen && isFileMediaType(seen.contentType))
|
|
4497
|
+
resource = { url: seen.url, status: seen.status, contentType: seen.contentType };
|
|
4498
|
+
else {
|
|
4499
|
+
const explained = explainTimeout(err, "nav", this.limits.crawlNavMs);
|
|
4500
|
+
const reason = explained instanceof Error ? explained.message.split("\n")[0] : String(explained);
|
|
4501
|
+
// Not retried by later crawls in this process (a check would otherwise try it
|
|
4502
|
+
// on every discovery round). Deliberately not written to memory: one outage
|
|
4503
|
+
// must not count a route as covered in every later run's gap ledger.
|
|
4504
|
+
if (!opts.measureOnly)
|
|
4505
|
+
this.loadFailedRoutes.add(normalizePath(url));
|
|
4506
|
+
await this.errorPageCommitted(page);
|
|
4507
|
+
summary.push(`${path} — LOAD FAILED`);
|
|
4508
|
+
// The page a re-attach went back to never loaded, so nothing says whether it worked.
|
|
4509
|
+
if (this.authLoss.reattaching)
|
|
4510
|
+
this.authLoss.abortReattach(`the page it went back to, ${path}, did not load (${reason})`);
|
|
4511
|
+
problems.push(`${path}: ${reason}`);
|
|
4512
|
+
this.crawlHealth.push({
|
|
4513
|
+
path,
|
|
4514
|
+
url,
|
|
4515
|
+
status: null,
|
|
4516
|
+
loadError: reason,
|
|
4517
|
+
loginRedirect: false,
|
|
4518
|
+
elements: 0,
|
|
4519
|
+
unnamed: [],
|
|
4520
|
+
placeholderOnly: [],
|
|
4521
|
+
violations: [],
|
|
4522
|
+
geometry: [],
|
|
4523
|
+
brokenImages: [],
|
|
4524
|
+
design: [],
|
|
4525
|
+
});
|
|
4526
|
+
continue;
|
|
4527
|
+
}
|
|
4528
|
+
}
|
|
4529
|
+
finally {
|
|
4530
|
+
page.off("response", onResponse);
|
|
4531
|
+
}
|
|
4532
|
+
// A feed, a file or data: recorded as what it is and left there. It has no controls to collect or audit, and one
|
|
4533
|
+
// that answered leaves the route contract, so it is neither a dead end nor a gap.
|
|
4534
|
+
if (resource) {
|
|
4535
|
+
if (!opts.measureOnly) {
|
|
4536
|
+
// Only a file that answered leaves the contract; a failing one is attempted with its status, as a failing page is.
|
|
4537
|
+
if (isNonPageResource(resource))
|
|
4538
|
+
memory.markResource(normalizePath(url), resource.contentType);
|
|
4539
|
+
else
|
|
4540
|
+
memory.markAttempted(normalizePath(url), `status:${resource.status}`, this.role);
|
|
4541
|
+
}
|
|
4542
|
+
this.oracles.drain(false);
|
|
4543
|
+
summary.push(`${path} — ${resource.status} · not a page (${resource.contentType})`);
|
|
4544
|
+
if (!isNonPageResource(resource))
|
|
4545
|
+
problems.push(`${path} → HTTP ${resource.status} (${resource.contentType})`);
|
|
4471
4546
|
this.crawlHealth.push({
|
|
4472
4547
|
path,
|
|
4473
|
-
url,
|
|
4474
|
-
status:
|
|
4475
|
-
|
|
4548
|
+
url: resource.url,
|
|
4549
|
+
status: resource.status,
|
|
4550
|
+
contentType: resource.contentType,
|
|
4476
4551
|
loginRedirect: false,
|
|
4477
4552
|
elements: 0,
|
|
4478
4553
|
unnamed: [],
|
|
@@ -4494,6 +4569,9 @@ export class BrowserEngine {
|
|
|
4494
4569
|
memory.visitState(fp, finalUrl, route, trackedElements(elements).map((el) => el.key), inertKeys(elements), this.sessionKey, aliases);
|
|
4495
4570
|
for (const f of forms)
|
|
4496
4571
|
memory.recordForm(fp, f.key, f.guarded, this.sessionKey);
|
|
4572
|
+
// A route remembered as a feed or a file that now answers as a page (a route class whose ids serve both, or
|
|
4573
|
+
// one that served a plain-text maintenance reply once) is a page again, so it rejoins the contract.
|
|
4574
|
+
memory.clearResource(normalizePath(url));
|
|
4497
4575
|
memory.recordRoleAccess(this.role, route, "reached");
|
|
4498
4576
|
}
|
|
4499
4577
|
// If we landed somewhere else (auth wall, canonical redirect), the
|
|
@@ -4914,6 +4992,46 @@ export class BrowserEngine {
|
|
|
4914
4992
|
return page.getByRole(target.role, target.name === undefined ? {} : { name: target.name });
|
|
4915
4993
|
}
|
|
4916
4994
|
}
|
|
4995
|
+
/**
|
|
4996
|
+
* Wait up to the action limit for a visible target to accept `action`, and
|
|
4997
|
+
* say why it never did (flow.ts notActionable), or null once it can. Without
|
|
4998
|
+
* this a disabled control failed as a bare action timeout that never said the
|
|
4999
|
+
* control was there but disabled.
|
|
5000
|
+
*/
|
|
5001
|
+
async waitUntilActionable(loc, action) {
|
|
5002
|
+
const read = async () => notActionable(action, {
|
|
5003
|
+
enabled: await loc.isEnabled({ timeout: 1000 }).catch(() => true),
|
|
5004
|
+
editable: action === "type" ? await loc.isEditable({ timeout: 1000 }).catch(() => null) : null,
|
|
5005
|
+
});
|
|
5006
|
+
const until = Date.now() + this.limits.actionMs;
|
|
5007
|
+
let reason = await read();
|
|
5008
|
+
while (reason && Date.now() < until) {
|
|
5009
|
+
await loc
|
|
5010
|
+
.page()
|
|
5011
|
+
.waitForTimeout(100)
|
|
5012
|
+
.catch(() => { });
|
|
5013
|
+
reason = await read();
|
|
5014
|
+
}
|
|
5015
|
+
return reason;
|
|
5016
|
+
}
|
|
5017
|
+
/**
|
|
5018
|
+
* What an expect-element step asks of its target, read once: whether any
|
|
5019
|
+
* match is visible, and, for the first visible match (or the first match when
|
|
5020
|
+
* none is), whether it is enabled and checked. Null when nothing matches.
|
|
5021
|
+
* The verdict is flow.ts's elementStateMatches, so the rules stay testable.
|
|
5022
|
+
*/
|
|
5023
|
+
static async elementFacts(matches) {
|
|
5024
|
+
const total = await matches.count().catch(() => 0);
|
|
5025
|
+
if (total === 0)
|
|
5026
|
+
return null;
|
|
5027
|
+
const visible = matches.filter({ visible: true });
|
|
5028
|
+
const anyVisible = (await visible.count().catch(() => 0)) > 0;
|
|
5029
|
+
const el = anyVisible ? visible.first() : matches.first();
|
|
5030
|
+
const enabled = await el.isEnabled({ timeout: 1000 }).catch(() => null);
|
|
5031
|
+
// isChecked throws for an element that is not a checkbox, radio or [role=checkbox]: it has no checked state.
|
|
5032
|
+
const checked = await el.isChecked({ timeout: 1000 }).catch(() => null);
|
|
5033
|
+
return { visible: anyVisible, enabled, checked };
|
|
5034
|
+
}
|
|
4917
5035
|
/**
|
|
4918
5036
|
* Replay one saved flow for `scenescout check`. Acts and reports only: the
|
|
4919
5037
|
* schema, the matching rules and what an outcome means live in flow.ts and
|
|
@@ -4969,8 +5087,8 @@ export class BrowserEngine {
|
|
|
4969
5087
|
if (v.detail !== except)
|
|
4970
5088
|
violations.push({ path, violation: { kind: v.kind, severity: v.severity, detail: v.detail, url: v.url, ...(v.embed ? { embed: v.embed } : {}) } });
|
|
4971
5089
|
};
|
|
4972
|
-
const poll = async (done) => {
|
|
4973
|
-
const until = Date.now() +
|
|
5090
|
+
const poll = async (done, ms = this.limits.actionMs) => {
|
|
5091
|
+
const until = Date.now() + ms;
|
|
4974
5092
|
for (;;) {
|
|
4975
5093
|
if (done())
|
|
4976
5094
|
return true;
|
|
@@ -4979,119 +5097,170 @@ export class BrowserEngine {
|
|
|
4979
5097
|
await (this.page ?? page).waitForTimeout(100).catch(() => { });
|
|
4980
5098
|
}
|
|
4981
5099
|
};
|
|
4982
|
-
|
|
4983
|
-
|
|
4984
|
-
|
|
4985
|
-
|
|
4986
|
-
|
|
4987
|
-
|
|
4988
|
-
|
|
4989
|
-
|
|
4990
|
-
|
|
4991
|
-
|
|
4992
|
-
|
|
4993
|
-
|
|
4994
|
-
|
|
4995
|
-
|
|
4996
|
-
|
|
4997
|
-
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5001
|
-
|
|
5002
|
-
|
|
5100
|
+
/**
|
|
5101
|
+
* Run one step that acts or asserts once, with the bookkeeping every step gets: the requests it caused, the
|
|
5102
|
+
* writes the policy refused while it ran, and the oracles' violations. `actionMs` is how long it may wait; a
|
|
5103
|
+
* repeat's condition is checked with a shorter wait, since the next repeat is what it waits for.
|
|
5104
|
+
*/
|
|
5105
|
+
const runStep = async (step, actionMs = this.limits.actionMs) => {
|
|
5106
|
+
if (isAction(step))
|
|
5107
|
+
seen = [];
|
|
5108
|
+
let failure = null;
|
|
5109
|
+
let refusal = null;
|
|
5110
|
+
this.actionStartedAt = Date.now();
|
|
5111
|
+
if (step.action === "click" || step.action === "type" || step.action === "select" || step.action === "press")
|
|
5112
|
+
await this.beginInput();
|
|
5113
|
+
try {
|
|
5114
|
+
const current = this.requirePage();
|
|
5115
|
+
if (step.action === "navigate") {
|
|
5116
|
+
const resolved = resolveTarget(this.baseUrl, step.target);
|
|
5117
|
+
if (!("url" in resolved))
|
|
5118
|
+
throw new Error(resolved.problem);
|
|
5119
|
+
const url = resolved.url;
|
|
5120
|
+
const resp = await current
|
|
5121
|
+
.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs })
|
|
5122
|
+
.catch((err) => Promise.reject(explainTimeout(err, "nav", this.limits.crawlNavMs)));
|
|
5123
|
+
await this.settle();
|
|
5124
|
+
const status = resp?.status() ?? null;
|
|
5125
|
+
if (status !== null && status >= 400) {
|
|
5126
|
+
failure = `the page answered HTTP ${status}`;
|
|
5127
|
+
// The page's own status is this step's failure; the oracle's record of the same response is not a second issue.
|
|
5128
|
+
// The oracle stores its details redacted (redactViolation), so the comparison is redacted too.
|
|
5129
|
+
collect(redactSecrets(httpErrorDetail("GET", current.url().replace(/#.*$/, ""), status)));
|
|
5130
|
+
}
|
|
5131
|
+
}
|
|
5132
|
+
else if (step.action === "upload") {
|
|
5133
|
+
const loc = step.target ? BrowserEngine.locatorFor(current, parseTarget(step.target)).first() : null;
|
|
5134
|
+
const outcome = await this.performUpload(loc, { ...(step.fixture ? { fixture: step.fixture } : {}), ...(step.name ? { name: step.name } : {}) });
|
|
5135
|
+
if (outcome.refused)
|
|
5136
|
+
failure = outcome.refused.split("\n")[0];
|
|
5137
|
+
else
|
|
5138
|
+
await this.settle();
|
|
5139
|
+
}
|
|
5140
|
+
else if (step.action === "press") {
|
|
5141
|
+
refusal = await this.vetFocusedActivation(step.value);
|
|
5142
|
+
if (!refusal) {
|
|
5143
|
+
await current.keyboard.press(step.value);
|
|
5003
5144
|
await this.settle();
|
|
5004
|
-
const status = resp?.status() ?? null;
|
|
5005
|
-
if (status !== null && status >= 400) {
|
|
5006
|
-
failure = `the page answered HTTP ${status}`;
|
|
5007
|
-
// The page's own status is this step's failure; the oracle's record of the same response is not a second issue.
|
|
5008
|
-
// The oracle stores its details redacted (redactViolation), so the comparison is redacted too.
|
|
5009
|
-
collect(redactSecrets(httpErrorDetail("GET", current.url().replace(/#.*$/, ""), status)));
|
|
5010
|
-
}
|
|
5011
5145
|
}
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5146
|
+
}
|
|
5147
|
+
else if (step.action === "click" || step.action === "type" || step.action === "select") {
|
|
5148
|
+
const loc = BrowserEngine.locatorFor(current, parseTarget(step.target)).first();
|
|
5149
|
+
const found = await loc
|
|
5150
|
+
.waitFor({ state: "visible", timeout: actionMs })
|
|
5151
|
+
.then(() => true)
|
|
5152
|
+
.catch(() => false);
|
|
5153
|
+
const blocked = found ? await this.waitUntilActionable(loc, step.action) : null;
|
|
5154
|
+
if (!found) {
|
|
5155
|
+
failure = `nothing visible matches ${step.target} within ${actionMs / 1000}s — ${limitHint("action", actionMs)}`;
|
|
5018
5156
|
}
|
|
5019
|
-
else if (
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5026
|
-
|
|
5157
|
+
else if (blocked) {
|
|
5158
|
+
// The state first, since it is what is true; the hint still applies when the app enables it late.
|
|
5159
|
+
failure = `${step.target} ${blocked} after ${actionMs / 1000}s — ${limitHint("action", actionMs)}`;
|
|
5160
|
+
}
|
|
5161
|
+
else {
|
|
5162
|
+
const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
|
|
5163
|
+
(await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
|
|
5164
|
+
"").trim();
|
|
5165
|
+
// A dropdown is named by all its options; a select step is judged by the option it picks (destructiveLabelOf).
|
|
5166
|
+
const isSelect = step.action === "select" && (await loc.evaluate((n) => n.tagName.toLowerCase() === "select").catch(() => false));
|
|
5167
|
+
const testid = await loc.getAttribute("data-testid").catch(() => null);
|
|
5168
|
+
if (this.readOnly &&
|
|
5169
|
+
step.action !== "type" &&
|
|
5170
|
+
(isSelect
|
|
5171
|
+
? pickIsDestructive(step.value, testid, await BrowserEngine.chosenOptionLabel(loc, step.value))
|
|
5172
|
+
: isDestructive(label, step.action === "select" ? step.value : undefined))) {
|
|
5173
|
+
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
5174
|
+
}
|
|
5175
|
+
else if (step.action === "click") {
|
|
5176
|
+
await loc.click({ timeout: actionMs });
|
|
5177
|
+
}
|
|
5178
|
+
else if (step.action === "select") {
|
|
5179
|
+
await loc.selectOption(step.value, { timeout: actionMs });
|
|
5027
5180
|
}
|
|
5028
5181
|
else {
|
|
5029
|
-
|
|
5030
|
-
|
|
5031
|
-
|
|
5032
|
-
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
? pickIsDestructive(step.value, testid, await BrowserEngine.chosenOptionLabel(loc, step.value))
|
|
5039
|
-
: isDestructive(label, step.action === "select" ? step.value : undefined))) {
|
|
5040
|
-
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
5041
|
-
}
|
|
5042
|
-
else if (step.action === "click") {
|
|
5043
|
-
await loc.click({ timeout: this.limits.actionMs });
|
|
5044
|
-
}
|
|
5045
|
-
else if (step.action === "select") {
|
|
5046
|
-
await loc.selectOption(step.value, { timeout: this.limits.actionMs });
|
|
5047
|
-
}
|
|
5048
|
-
else {
|
|
5049
|
-
await this.noteProbe(step.value, step.target);
|
|
5050
|
-
await this.fillOrAppend(loc, step.value, step.replace ?? false);
|
|
5051
|
-
if (step.pressEnter) {
|
|
5052
|
-
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
5053
|
-
const submitLabel = ((await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "").trim();
|
|
5054
|
-
if (this.readOnly && isDestructive(submitLabel))
|
|
5055
|
-
refusal = destructiveRefusal(submitLabel, this.mode);
|
|
5056
|
-
else
|
|
5057
|
-
await loc.press("Enter", { timeout: this.limits.actionMs });
|
|
5058
|
-
}
|
|
5182
|
+
await this.noteProbe(step.value, step.target);
|
|
5183
|
+
await this.fillOrAppend(loc, step.value, step.replace ?? false);
|
|
5184
|
+
if (step.pressEnter) {
|
|
5185
|
+
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
5186
|
+
const submitLabel = ((await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "").trim();
|
|
5187
|
+
if (this.readOnly && isDestructive(submitLabel))
|
|
5188
|
+
refusal = destructiveRefusal(submitLabel, this.mode);
|
|
5189
|
+
else
|
|
5190
|
+
await loc.press("Enter", { timeout: actionMs });
|
|
5059
5191
|
}
|
|
5060
|
-
if (!refusal)
|
|
5061
|
-
await this.settle();
|
|
5062
5192
|
}
|
|
5193
|
+
if (!refusal)
|
|
5194
|
+
await this.settle();
|
|
5063
5195
|
}
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
if (!last.ok)
|
|
5082
|
-
failure = last.reason;
|
|
5196
|
+
}
|
|
5197
|
+
else if (step.action === "expect-text") {
|
|
5198
|
+
const visible = current.getByText(step.text, { exact: false }).filter({ visible: true }).first();
|
|
5199
|
+
const ok = await visible
|
|
5200
|
+
.waitFor({ state: "visible", timeout: actionMs })
|
|
5201
|
+
.then(() => true)
|
|
5202
|
+
.catch(() => false);
|
|
5203
|
+
if (!ok)
|
|
5204
|
+
failure = `no visible text ${JSON.stringify(step.text)} within ${actionMs / 1000}s — ${limitHint("action", actionMs)}`;
|
|
5205
|
+
}
|
|
5206
|
+
else if (step.action === "expect-element") {
|
|
5207
|
+
const matches = BrowserEngine.locatorFor(current, parseTarget(step.target));
|
|
5208
|
+
const until = Date.now() + actionMs;
|
|
5209
|
+
let verdict = elementStateMatches(step.state, await BrowserEngine.elementFacts(matches));
|
|
5210
|
+
while (!verdict.ok && Date.now() < until) {
|
|
5211
|
+
await current.waitForTimeout(100).catch(() => { });
|
|
5212
|
+
verdict = elementStateMatches(step.state, await BrowserEngine.elementFacts(matches));
|
|
5083
5213
|
}
|
|
5214
|
+
if (!verdict.ok)
|
|
5215
|
+
failure = `${step.target} ${verdict.actual}, expected ${step.state}, after ${actionMs / 1000}s`;
|
|
5084
5216
|
}
|
|
5085
|
-
|
|
5086
|
-
|
|
5217
|
+
else if (step.action === "expect-url") {
|
|
5218
|
+
const ok = await poll(() => urlMatches(step.pattern, this.page?.url() ?? ""), actionMs);
|
|
5219
|
+
if (!ok)
|
|
5220
|
+
failure = `the page is at ${here()}, which does not match /${step.pattern}/`;
|
|
5087
5221
|
}
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5094
|
-
|
|
5222
|
+
else {
|
|
5223
|
+
let last = { ok: false, reason: "" };
|
|
5224
|
+
await poll(() => (last = matchRequest(step, seen)).ok, actionMs);
|
|
5225
|
+
if (!last.ok)
|
|
5226
|
+
failure = last.reason;
|
|
5227
|
+
}
|
|
5228
|
+
}
|
|
5229
|
+
catch (err) {
|
|
5230
|
+
failure = firstLineOf(explainTimeout(err, "action", actionMs));
|
|
5231
|
+
}
|
|
5232
|
+
await this.scanForInjections().catch(() => { });
|
|
5233
|
+
await this.scanForContradictions().catch(() => { });
|
|
5234
|
+
// What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
|
|
5235
|
+
const blocked = ownRefusals();
|
|
5236
|
+
if (!refusal && blocked.length > 0)
|
|
5237
|
+
refusal = `the ${this.writeRule.at()} write policy refused ${blocked.join(", ")}`;
|
|
5238
|
+
collect();
|
|
5239
|
+
return { failure, refusal };
|
|
5240
|
+
};
|
|
5241
|
+
/** A repeat step: its condition first, then its actions and the condition again, at most `max` times. */
|
|
5242
|
+
const runRepeat = async (step) => {
|
|
5243
|
+
for (let k = 0;; k++) {
|
|
5244
|
+
// A short wait every time, the first look included: an instant one misses what is already on the page.
|
|
5245
|
+
const check = await runStep(step.until, Math.min(REPEAT_CHECK_MS, this.limits.actionMs));
|
|
5246
|
+
if (check.refusal)
|
|
5247
|
+
return check;
|
|
5248
|
+
if (!check.failure)
|
|
5249
|
+
return { failure: null, refusal: null };
|
|
5250
|
+
if (k === step.max)
|
|
5251
|
+
return { failure: repeatFailure(step.until, step.max, check.failure), refusal: null };
|
|
5252
|
+
for (const inner of step.steps) {
|
|
5253
|
+
const r = await runStep(inner);
|
|
5254
|
+
if (r.refusal || r.failure)
|
|
5255
|
+
return { failure: r.failure && `repeat ${k + 1} of at most ${step.max}, ${describeStep(inner)}: ${r.failure}`, refusal: r.refusal };
|
|
5256
|
+
}
|
|
5257
|
+
}
|
|
5258
|
+
};
|
|
5259
|
+
try {
|
|
5260
|
+
for (const [i, step] of steps.entries()) {
|
|
5261
|
+
const n = i + 1;
|
|
5262
|
+
const did = describeStep(step);
|
|
5263
|
+
const { failure, refusal } = step.action === "repeat" ? await runRepeat(step) : await runStep(step);
|
|
5095
5264
|
if (refusal)
|
|
5096
5265
|
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
5097
5266
|
if (failure)
|
|
@@ -5307,7 +5476,9 @@ export class BrowserEngine {
|
|
|
5307
5476
|
* stopped, then either the viewport from the top (no target) or one element,
|
|
5308
5477
|
* found the way a saved flow finds its target, with `how.margin` around it.
|
|
5309
5478
|
* The screenshot is taken with `how`'s animation and caret settings, at one
|
|
5310
|
-
* picture pixel per CSS pixel
|
|
5479
|
+
* picture pixel per CSS pixel, and again a frame later until two in a row
|
|
5480
|
+
* are the same (steadyPicture); `unsteady` says so when the page never held
|
|
5481
|
+
* still within the action limit. Reads only. Throws a sentence when it cannot
|
|
5311
5482
|
* take the picture. `cut` says when an element reaches outside the window.
|
|
5312
5483
|
* What is compared and what it means is baseline.ts, which also holds the
|
|
5313
5484
|
* settings; the rectangle is capture.ts. Call endBaselineCaptures when done.
|
|
@@ -5346,11 +5517,10 @@ export class BrowserEngine {
|
|
|
5346
5517
|
const viewport = page.viewportSize() ?? (await page.evaluate("({ width: innerWidth, height: innerHeight })"));
|
|
5347
5518
|
const deviceScaleFactor = Number(await page.evaluate("window.devicePixelRatio"));
|
|
5348
5519
|
const settings = { type: "png", animations: how.animations, caret: how.caret, scale: "css", timeout: this.limits.actionMs };
|
|
5349
|
-
let
|
|
5520
|
+
let clip;
|
|
5350
5521
|
let cut = null;
|
|
5351
5522
|
if (!target) {
|
|
5352
5523
|
await page.evaluate("window.scrollTo(0, 0)");
|
|
5353
|
-
png = await action(page.screenshot(settings));
|
|
5354
5524
|
}
|
|
5355
5525
|
else {
|
|
5356
5526
|
const locator = BrowserEngine.locatorFor(page, target).first();
|
|
@@ -5361,14 +5531,18 @@ export class BrowserEngine {
|
|
|
5361
5531
|
const box = await action(locator.boundingBox({ timeout: this.limits.actionMs }));
|
|
5362
5532
|
if (!box)
|
|
5363
5533
|
throw new Error("it is not displayed");
|
|
5364
|
-
|
|
5534
|
+
clip = captureClip(box, how.margin, viewport) ?? undefined;
|
|
5365
5535
|
if (!clip)
|
|
5366
5536
|
throw new Error("it is outside the viewport");
|
|
5367
5537
|
cut = cutByViewport(box, viewport);
|
|
5368
|
-
png = await action(page.screenshot({ ...settings, clip }));
|
|
5369
5538
|
}
|
|
5539
|
+
// Two pictures in a row the same, a frame apart: the page has drawn the stop and stopped changing (steadyPicture).
|
|
5540
|
+
const { png, steady } = await steadyPicture(() => action(page.screenshot(clip ? { ...settings, clip } : settings)),
|
|
5541
|
+
// Bounded here too, since a page whose main thread is stuck never runs the script's own fallback. A wait that
|
|
5542
|
+
// fails (the page navigated) is not the picture's error: the next take reports what is wrong with the page.
|
|
5543
|
+
() => BrowserEngine.settleWithin(page.evaluate(NEXT_FRAME_SCRIPT), NEXT_FRAME_WAIT_MS), this.limits.actionMs);
|
|
5370
5544
|
this.logAction({ action: "capture", target: target ? `baseline ${pathOnApp}` : `baseline ${pathOnApp} (page)`, url: page.url() });
|
|
5371
|
-
return { png, viewport, deviceScaleFactor, cut };
|
|
5545
|
+
return { png, viewport, deviceScaleFactor, cut, unsteady: steady ? null : unsteadyNote(this.limits.actionMs) };
|
|
5372
5546
|
}
|
|
5373
5547
|
/** Stop telling the page to reduce motion, so what runs after the baselines (saved flows) sees the page as a user does. */
|
|
5374
5548
|
async endBaselineCaptures() {
|