scenescout 3.18.0 → 3.19.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 +26 -0
- package/README.md +3 -1
- package/dist/check-run.js +64 -8
- package/dist/cli.js +6 -4
- package/dist/engine/baseline.js +43 -0
- package/dist/engine/browser.js +215 -112
- package/dist/engine/check.js +24 -5
- package/dist/engine/dedup.js +11 -0
- package/dist/engine/design.js +4 -0
- package/dist/engine/export.js +200 -1
- package/dist/engine/flow.js +171 -4
- package/dist/export-run.js +231 -55
- package/package.json +2 -2
- package/skills/scenescout/SKILL.md +1 -1
package/dist/engine/browser.js
CHANGED
|
@@ -9,12 +9,12 @@ import { POSTMESSAGE_BINDING, describeTokenPost, postMessageCaptureScript, token
|
|
|
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
20
|
import { crawledRoute, crawlLine, isFileMediaType, isNonPageResource, mainStateFlag, mediaTypeOf } from "./crawl.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). */
|
|
@@ -4990,6 +4992,46 @@ export class BrowserEngine {
|
|
|
4990
4992
|
return page.getByRole(target.role, target.name === undefined ? {} : { name: target.name });
|
|
4991
4993
|
}
|
|
4992
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
|
+
}
|
|
4993
5035
|
/**
|
|
4994
5036
|
* Replay one saved flow for `scenescout check`. Acts and reports only: the
|
|
4995
5037
|
* schema, the matching rules and what an outcome means live in flow.ts and
|
|
@@ -5005,6 +5047,10 @@ export class BrowserEngine {
|
|
|
5005
5047
|
// The write policy judges by writeRule, so the flow's rule holds from here until the flow has handed back (finally).
|
|
5006
5048
|
const crawlMode = this.mode;
|
|
5007
5049
|
this.setMode(mode);
|
|
5050
|
+
// A flow's navigate means leave, and so does handing back: a page that asks to confirm leaving (an unsaved-changes
|
|
5051
|
+
// guard on a form a failed step left half filled) is left, or it would cancel the next flow's first navigate.
|
|
5052
|
+
const leaveBefore = this.leaveChoice;
|
|
5053
|
+
this.leaveChoice = true;
|
|
5008
5054
|
const context = this.context;
|
|
5009
5055
|
const violations = [];
|
|
5010
5056
|
const here = () => {
|
|
@@ -5045,8 +5091,8 @@ export class BrowserEngine {
|
|
|
5045
5091
|
if (v.detail !== except)
|
|
5046
5092
|
violations.push({ path, violation: { kind: v.kind, severity: v.severity, detail: v.detail, url: v.url, ...(v.embed ? { embed: v.embed } : {}) } });
|
|
5047
5093
|
};
|
|
5048
|
-
const poll = async (done) => {
|
|
5049
|
-
const until = Date.now() +
|
|
5094
|
+
const poll = async (done, ms = this.limits.actionMs) => {
|
|
5095
|
+
const until = Date.now() + ms;
|
|
5050
5096
|
for (;;) {
|
|
5051
5097
|
if (done())
|
|
5052
5098
|
return true;
|
|
@@ -5055,119 +5101,170 @@ export class BrowserEngine {
|
|
|
5055
5101
|
await (this.page ?? page).waitForTimeout(100).catch(() => { });
|
|
5056
5102
|
}
|
|
5057
5103
|
};
|
|
5058
|
-
|
|
5059
|
-
|
|
5060
|
-
|
|
5061
|
-
|
|
5062
|
-
|
|
5063
|
-
|
|
5064
|
-
|
|
5065
|
-
|
|
5066
|
-
|
|
5067
|
-
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
5074
|
-
|
|
5075
|
-
|
|
5076
|
-
|
|
5077
|
-
|
|
5078
|
-
|
|
5104
|
+
/**
|
|
5105
|
+
* Run one step that acts or asserts once, with the bookkeeping every step gets: the requests it caused, the
|
|
5106
|
+
* writes the policy refused while it ran, and the oracles' violations. `actionMs` is how long it may wait; a
|
|
5107
|
+
* repeat's condition is checked with a shorter wait, since the next repeat is what it waits for.
|
|
5108
|
+
*/
|
|
5109
|
+
const runStep = async (step, actionMs = this.limits.actionMs) => {
|
|
5110
|
+
if (isAction(step))
|
|
5111
|
+
seen = [];
|
|
5112
|
+
let failure = null;
|
|
5113
|
+
let refusal = null;
|
|
5114
|
+
this.actionStartedAt = Date.now();
|
|
5115
|
+
if (step.action === "click" || step.action === "type" || step.action === "select" || step.action === "press")
|
|
5116
|
+
await this.beginInput();
|
|
5117
|
+
try {
|
|
5118
|
+
const current = this.requirePage();
|
|
5119
|
+
if (step.action === "navigate") {
|
|
5120
|
+
const resolved = resolveTarget(this.baseUrl, step.target);
|
|
5121
|
+
if (!("url" in resolved))
|
|
5122
|
+
throw new Error(resolved.problem);
|
|
5123
|
+
const url = resolved.url;
|
|
5124
|
+
const resp = await current
|
|
5125
|
+
.goto(url, { waitUntil: "domcontentloaded", timeout: this.limits.crawlNavMs })
|
|
5126
|
+
.catch((err) => Promise.reject(explainTimeout(err, "nav", this.limits.crawlNavMs)));
|
|
5127
|
+
await this.settle();
|
|
5128
|
+
const status = resp?.status() ?? null;
|
|
5129
|
+
if (status !== null && status >= 400) {
|
|
5130
|
+
failure = `the page answered HTTP ${status}`;
|
|
5131
|
+
// The page's own status is this step's failure; the oracle's record of the same response is not a second issue.
|
|
5132
|
+
// The oracle stores its details redacted (redactViolation), so the comparison is redacted too.
|
|
5133
|
+
collect(redactSecrets(httpErrorDetail("GET", current.url().replace(/#.*$/, ""), status)));
|
|
5134
|
+
}
|
|
5135
|
+
}
|
|
5136
|
+
else if (step.action === "upload") {
|
|
5137
|
+
const loc = step.target ? BrowserEngine.locatorFor(current, parseTarget(step.target)).first() : null;
|
|
5138
|
+
const outcome = await this.performUpload(loc, { ...(step.fixture ? { fixture: step.fixture } : {}), ...(step.name ? { name: step.name } : {}) });
|
|
5139
|
+
if (outcome.refused)
|
|
5140
|
+
failure = outcome.refused.split("\n")[0];
|
|
5141
|
+
else
|
|
5142
|
+
await this.settle();
|
|
5143
|
+
}
|
|
5144
|
+
else if (step.action === "press") {
|
|
5145
|
+
refusal = await this.vetFocusedActivation(step.value);
|
|
5146
|
+
if (!refusal) {
|
|
5147
|
+
await current.keyboard.press(step.value);
|
|
5079
5148
|
await this.settle();
|
|
5080
|
-
const status = resp?.status() ?? null;
|
|
5081
|
-
if (status !== null && status >= 400) {
|
|
5082
|
-
failure = `the page answered HTTP ${status}`;
|
|
5083
|
-
// The page's own status is this step's failure; the oracle's record of the same response is not a second issue.
|
|
5084
|
-
// The oracle stores its details redacted (redactViolation), so the comparison is redacted too.
|
|
5085
|
-
collect(redactSecrets(httpErrorDetail("GET", current.url().replace(/#.*$/, ""), status)));
|
|
5086
|
-
}
|
|
5087
5149
|
}
|
|
5088
|
-
|
|
5089
|
-
|
|
5090
|
-
|
|
5091
|
-
|
|
5092
|
-
|
|
5093
|
-
|
|
5150
|
+
}
|
|
5151
|
+
else if (step.action === "click" || step.action === "type" || step.action === "select") {
|
|
5152
|
+
const loc = BrowserEngine.locatorFor(current, parseTarget(step.target)).first();
|
|
5153
|
+
const found = await loc
|
|
5154
|
+
.waitFor({ state: "visible", timeout: actionMs })
|
|
5155
|
+
.then(() => true)
|
|
5156
|
+
.catch(() => false);
|
|
5157
|
+
const blocked = found ? await this.waitUntilActionable(loc, step.action) : null;
|
|
5158
|
+
if (!found) {
|
|
5159
|
+
failure = `nothing visible matches ${step.target} within ${actionMs / 1000}s — ${limitHint("action", actionMs)}`;
|
|
5160
|
+
}
|
|
5161
|
+
else if (blocked) {
|
|
5162
|
+
// The state first, since it is what is true; the hint still applies when the app enables it late.
|
|
5163
|
+
failure = `${step.target} ${blocked} after ${actionMs / 1000}s — ${limitHint("action", actionMs)}`;
|
|
5094
5164
|
}
|
|
5095
|
-
else
|
|
5096
|
-
const
|
|
5097
|
-
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5165
|
+
else {
|
|
5166
|
+
const label = ((await loc.getAttribute("aria-label").catch(() => null)) ??
|
|
5167
|
+
(await loc.textContent({ timeout: 1000 }).catch(() => null)) ??
|
|
5168
|
+
"").trim();
|
|
5169
|
+
// A dropdown is named by all its options; a select step is judged by the option it picks (destructiveLabelOf).
|
|
5170
|
+
const isSelect = step.action === "select" && (await loc.evaluate((n) => n.tagName.toLowerCase() === "select").catch(() => false));
|
|
5171
|
+
const testid = await loc.getAttribute("data-testid").catch(() => null);
|
|
5172
|
+
if (this.readOnly &&
|
|
5173
|
+
step.action !== "type" &&
|
|
5174
|
+
(isSelect
|
|
5175
|
+
? pickIsDestructive(step.value, testid, await BrowserEngine.chosenOptionLabel(loc, step.value))
|
|
5176
|
+
: isDestructive(label, step.action === "select" ? step.value : undefined))) {
|
|
5177
|
+
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
5178
|
+
}
|
|
5179
|
+
else if (step.action === "click") {
|
|
5180
|
+
await loc.click({ timeout: actionMs });
|
|
5181
|
+
}
|
|
5182
|
+
else if (step.action === "select") {
|
|
5183
|
+
await loc.selectOption(step.value, { timeout: actionMs });
|
|
5103
5184
|
}
|
|
5104
5185
|
else {
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5114
|
-
? pickIsDestructive(step.value, testid, await BrowserEngine.chosenOptionLabel(loc, step.value))
|
|
5115
|
-
: isDestructive(label, step.action === "select" ? step.value : undefined))) {
|
|
5116
|
-
refusal = destructiveRefusal(label || step.target, this.mode);
|
|
5117
|
-
}
|
|
5118
|
-
else if (step.action === "click") {
|
|
5119
|
-
await loc.click({ timeout: this.limits.actionMs });
|
|
5120
|
-
}
|
|
5121
|
-
else if (step.action === "select") {
|
|
5122
|
-
await loc.selectOption(step.value, { timeout: this.limits.actionMs });
|
|
5123
|
-
}
|
|
5124
|
-
else {
|
|
5125
|
-
await this.noteProbe(step.value, step.target);
|
|
5126
|
-
await this.fillOrAppend(loc, step.value, step.replace ?? false);
|
|
5127
|
-
if (step.pressEnter) {
|
|
5128
|
-
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
5129
|
-
const submitLabel = ((await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "").trim();
|
|
5130
|
-
if (this.readOnly && isDestructive(submitLabel))
|
|
5131
|
-
refusal = destructiveRefusal(submitLabel, this.mode);
|
|
5132
|
-
else
|
|
5133
|
-
await loc.press("Enter", { timeout: this.limits.actionMs });
|
|
5134
|
-
}
|
|
5186
|
+
await this.noteProbe(step.value, step.target);
|
|
5187
|
+
await this.fillOrAppend(loc, step.value, step.replace ?? false);
|
|
5188
|
+
if (step.pressEnter) {
|
|
5189
|
+
const submit = loc.locator("xpath=ancestor::form[1]").locator('[type="submit"], button:not([type="button"]):not([type="reset"])').first();
|
|
5190
|
+
const submitLabel = ((await submit.textContent({ timeout: 1000 }).catch(() => "")) ?? "").trim();
|
|
5191
|
+
if (this.readOnly && isDestructive(submitLabel))
|
|
5192
|
+
refusal = destructiveRefusal(submitLabel, this.mode);
|
|
5193
|
+
else
|
|
5194
|
+
await loc.press("Enter", { timeout: actionMs });
|
|
5135
5195
|
}
|
|
5136
|
-
if (!refusal)
|
|
5137
|
-
await this.settle();
|
|
5138
5196
|
}
|
|
5197
|
+
if (!refusal)
|
|
5198
|
+
await this.settle();
|
|
5139
5199
|
}
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
if (!last.ok)
|
|
5158
|
-
failure = last.reason;
|
|
5200
|
+
}
|
|
5201
|
+
else if (step.action === "expect-text") {
|
|
5202
|
+
const visible = current.getByText(step.text, { exact: false }).filter({ visible: true }).first();
|
|
5203
|
+
const ok = await visible
|
|
5204
|
+
.waitFor({ state: "visible", timeout: actionMs })
|
|
5205
|
+
.then(() => true)
|
|
5206
|
+
.catch(() => false);
|
|
5207
|
+
if (!ok)
|
|
5208
|
+
failure = `no visible text ${JSON.stringify(step.text)} within ${actionMs / 1000}s — ${limitHint("action", actionMs)}`;
|
|
5209
|
+
}
|
|
5210
|
+
else if (step.action === "expect-element") {
|
|
5211
|
+
const matches = BrowserEngine.locatorFor(current, parseTarget(step.target));
|
|
5212
|
+
const until = Date.now() + actionMs;
|
|
5213
|
+
let verdict = elementStateMatches(step.state, await BrowserEngine.elementFacts(matches));
|
|
5214
|
+
while (!verdict.ok && Date.now() < until) {
|
|
5215
|
+
await current.waitForTimeout(100).catch(() => { });
|
|
5216
|
+
verdict = elementStateMatches(step.state, await BrowserEngine.elementFacts(matches));
|
|
5159
5217
|
}
|
|
5218
|
+
if (!verdict.ok)
|
|
5219
|
+
failure = `${step.target} ${verdict.actual}, expected ${step.state}, after ${actionMs / 1000}s`;
|
|
5160
5220
|
}
|
|
5161
|
-
|
|
5162
|
-
|
|
5221
|
+
else if (step.action === "expect-url") {
|
|
5222
|
+
const ok = await poll(() => urlMatches(step.pattern, this.page?.url() ?? ""), actionMs);
|
|
5223
|
+
if (!ok)
|
|
5224
|
+
failure = `the page is at ${here()}, which does not match /${step.pattern}/`;
|
|
5163
5225
|
}
|
|
5164
|
-
|
|
5165
|
-
|
|
5166
|
-
|
|
5167
|
-
|
|
5168
|
-
|
|
5169
|
-
|
|
5170
|
-
|
|
5226
|
+
else {
|
|
5227
|
+
let last = { ok: false, reason: "" };
|
|
5228
|
+
await poll(() => (last = matchRequest(step, seen)).ok, actionMs);
|
|
5229
|
+
if (!last.ok)
|
|
5230
|
+
failure = last.reason;
|
|
5231
|
+
}
|
|
5232
|
+
}
|
|
5233
|
+
catch (err) {
|
|
5234
|
+
failure = firstLineOf(explainTimeout(err, "action", actionMs));
|
|
5235
|
+
}
|
|
5236
|
+
await this.scanForInjections().catch(() => { });
|
|
5237
|
+
await this.scanForContradictions().catch(() => { });
|
|
5238
|
+
// What the write policy refused of the app's own requests while this step ran is the step's, whatever caused it.
|
|
5239
|
+
const blocked = ownRefusals();
|
|
5240
|
+
if (!refusal && blocked.length > 0)
|
|
5241
|
+
refusal = `the ${this.writeRule.at()} write policy refused ${blocked.join(", ")}`;
|
|
5242
|
+
collect();
|
|
5243
|
+
return { failure, refusal };
|
|
5244
|
+
};
|
|
5245
|
+
/** A repeat step: its condition first, then its actions and the condition again, at most `max` times. */
|
|
5246
|
+
const runRepeat = async (step) => {
|
|
5247
|
+
for (let k = 0;; k++) {
|
|
5248
|
+
// A short wait every time, the first look included: an instant one misses what is already on the page.
|
|
5249
|
+
const check = await runStep(step.until, Math.min(REPEAT_CHECK_MS, this.limits.actionMs));
|
|
5250
|
+
if (check.refusal)
|
|
5251
|
+
return check;
|
|
5252
|
+
if (!check.failure)
|
|
5253
|
+
return { failure: null, refusal: null };
|
|
5254
|
+
if (k === step.max)
|
|
5255
|
+
return { failure: repeatFailure(step.until, step.max, check.failure), refusal: null };
|
|
5256
|
+
for (const inner of step.steps) {
|
|
5257
|
+
const r = await runStep(inner);
|
|
5258
|
+
if (r.refusal || r.failure)
|
|
5259
|
+
return { failure: r.failure && `repeat ${k + 1} of at most ${step.max}, ${describeStep(inner)}: ${r.failure}`, refusal: r.refusal };
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
};
|
|
5263
|
+
try {
|
|
5264
|
+
for (const [i, step] of steps.entries()) {
|
|
5265
|
+
const n = i + 1;
|
|
5266
|
+
const did = describeStep(step);
|
|
5267
|
+
const { failure, refusal } = step.action === "repeat" ? await runRepeat(step) : await runStep(step);
|
|
5171
5268
|
if (refusal)
|
|
5172
5269
|
return done({ status: "refused", step: n, did, reason: refusal.split("\n")[0], path: here() });
|
|
5173
5270
|
if (failure)
|
|
@@ -5207,6 +5304,7 @@ export class BrowserEngine {
|
|
|
5207
5304
|
// Pages the session no longer drives are closed only now, at about:blank and under the held rule, and each only
|
|
5208
5305
|
// once what it sent as it was left has been judged (leftSettled).
|
|
5209
5306
|
await BrowserEngine.settleWithin(Promise.allSettled(context.pages().map(async (p) => (p === this.page ? undefined : (await this.leftSettled(p), await p.close().catch(() => { }))))), 5000);
|
|
5307
|
+
this.leaveChoice = leaveBefore;
|
|
5210
5308
|
page.off("websocket", onSocket);
|
|
5211
5309
|
context.off("response", onResponse);
|
|
5212
5310
|
this.refs.clear();
|
|
@@ -5383,7 +5481,9 @@ export class BrowserEngine {
|
|
|
5383
5481
|
* stopped, then either the viewport from the top (no target) or one element,
|
|
5384
5482
|
* found the way a saved flow finds its target, with `how.margin` around it.
|
|
5385
5483
|
* The screenshot is taken with `how`'s animation and caret settings, at one
|
|
5386
|
-
* picture pixel per CSS pixel
|
|
5484
|
+
* picture pixel per CSS pixel, and again a frame later until two in a row
|
|
5485
|
+
* are the same (steadyPicture); `unsteady` says so when the page never held
|
|
5486
|
+
* still within the action limit. Reads only. Throws a sentence when it cannot
|
|
5387
5487
|
* take the picture. `cut` says when an element reaches outside the window.
|
|
5388
5488
|
* What is compared and what it means is baseline.ts, which also holds the
|
|
5389
5489
|
* settings; the rectangle is capture.ts. Call endBaselineCaptures when done.
|
|
@@ -5422,11 +5522,10 @@ export class BrowserEngine {
|
|
|
5422
5522
|
const viewport = page.viewportSize() ?? (await page.evaluate("({ width: innerWidth, height: innerHeight })"));
|
|
5423
5523
|
const deviceScaleFactor = Number(await page.evaluate("window.devicePixelRatio"));
|
|
5424
5524
|
const settings = { type: "png", animations: how.animations, caret: how.caret, scale: "css", timeout: this.limits.actionMs };
|
|
5425
|
-
let
|
|
5525
|
+
let clip;
|
|
5426
5526
|
let cut = null;
|
|
5427
5527
|
if (!target) {
|
|
5428
5528
|
await page.evaluate("window.scrollTo(0, 0)");
|
|
5429
|
-
png = await action(page.screenshot(settings));
|
|
5430
5529
|
}
|
|
5431
5530
|
else {
|
|
5432
5531
|
const locator = BrowserEngine.locatorFor(page, target).first();
|
|
@@ -5437,14 +5536,18 @@ export class BrowserEngine {
|
|
|
5437
5536
|
const box = await action(locator.boundingBox({ timeout: this.limits.actionMs }));
|
|
5438
5537
|
if (!box)
|
|
5439
5538
|
throw new Error("it is not displayed");
|
|
5440
|
-
|
|
5539
|
+
clip = captureClip(box, how.margin, viewport) ?? undefined;
|
|
5441
5540
|
if (!clip)
|
|
5442
5541
|
throw new Error("it is outside the viewport");
|
|
5443
5542
|
cut = cutByViewport(box, viewport);
|
|
5444
|
-
png = await action(page.screenshot({ ...settings, clip }));
|
|
5445
5543
|
}
|
|
5544
|
+
// Two pictures in a row the same, a frame apart: the page has drawn the stop and stopped changing (steadyPicture).
|
|
5545
|
+
const { png, steady } = await steadyPicture(() => action(page.screenshot(clip ? { ...settings, clip } : settings)),
|
|
5546
|
+
// Bounded here too, since a page whose main thread is stuck never runs the script's own fallback. A wait that
|
|
5547
|
+
// fails (the page navigated) is not the picture's error: the next take reports what is wrong with the page.
|
|
5548
|
+
() => BrowserEngine.settleWithin(page.evaluate(NEXT_FRAME_SCRIPT), NEXT_FRAME_WAIT_MS), this.limits.actionMs);
|
|
5446
5549
|
this.logAction({ action: "capture", target: target ? `baseline ${pathOnApp}` : `baseline ${pathOnApp} (page)`, url: page.url() });
|
|
5447
|
-
return { png, viewport, deviceScaleFactor, cut };
|
|
5550
|
+
return { png, viewport, deviceScaleFactor, cut, unsteady: steady ? null : unsteadyNote(this.limits.actionMs) };
|
|
5448
5551
|
}
|
|
5449
5552
|
/** Stop telling the page to reduce motion, so what runs after the baselines (saved flows) sees the page as a user does. */
|
|
5450
5553
|
async endBaselineCaptures() {
|
package/dist/engine/check.js
CHANGED
|
@@ -359,15 +359,32 @@ export function checkFindings(routes, origin, ignore = [], flows = [], baselines
|
|
|
359
359
|
export function issuesFromRoutes(routes, origin, ignore = [], flows = [], ignorePaths = [], signedIn = true) {
|
|
360
360
|
return checkFindings(routes, origin, ignore, flows, null, ignorePaths, signedIn).issues;
|
|
361
361
|
}
|
|
362
|
+
/**
|
|
363
|
+
* Fewest controls a start page must show for a check that measured nothing
|
|
364
|
+
* else to count as having measured the app (unmeasuredReason).
|
|
365
|
+
*/
|
|
366
|
+
export const START_PAGE_MIN_CONTROLS = 2;
|
|
362
367
|
/**
|
|
363
368
|
* Why a check has nothing to give a verdict on, or null when it measured the
|
|
364
369
|
* app. Neither case is a pass: a gate that goes green on a login page or a
|
|
365
370
|
* dead server would pass every pull request while the app was down.
|
|
366
371
|
*/
|
|
367
|
-
export function unmeasuredReason(routes, startIsFirst) {
|
|
372
|
+
export function unmeasuredReason(routes, startIsFirst, unvisited = []) {
|
|
368
373
|
const loaded = routes.filter((r) => r.loadError === undefined);
|
|
369
374
|
if (loaded.length === 0)
|
|
370
375
|
return "no page loaded. Is the app running at that URL?";
|
|
376
|
+
// A start page measured before the app drew it (a client-rendered shell still loading, a blank page) links nowhere,
|
|
377
|
+
// so the crawl ends there and every rule is silent: a pass that checked nothing. A real one-page app has controls.
|
|
378
|
+
const start = routes[0];
|
|
379
|
+
// `unvisited` empty: the page linked nowhere. A crawl that --max-routes cut short did find more, and measured what it was asked to.
|
|
380
|
+
if (startIsFirst &&
|
|
381
|
+
unvisited.length === 0 &&
|
|
382
|
+
loaded.length === 1 &&
|
|
383
|
+
start === loaded[0] &&
|
|
384
|
+
!start.loginRedirect &&
|
|
385
|
+
start.elements < START_PAGE_MIN_CONTROLS) {
|
|
386
|
+
return `only the start page was measured, and it showed ${start.elements} control(s) and linked nowhere, so the app may not have finished drawing. Check again, raise --nav-timeout-ms if the app is slow to render, pass --project so routes come from the source, or name them with --paths.`;
|
|
387
|
+
}
|
|
371
388
|
// Without --paths the first route is the start page. When it bounces, what follows is the sign-in
|
|
372
389
|
// page's own links (sign-up, forgot password): pages that load, but not the app.
|
|
373
390
|
// With --paths there is no start page, only a list, and one walled path among public ones is an auth-redirect issue.
|
|
@@ -861,7 +878,7 @@ const BASELINE_MARK = {
|
|
|
861
878
|
/** One target's line in the report. */
|
|
862
879
|
function baselineLine(r, run) {
|
|
863
880
|
const what = `${BASELINE_MARK[r.status]} ${code(r.element)} on ${code(r.path)}`;
|
|
864
|
-
const notes = [r.platformNote, r.partial]
|
|
881
|
+
const notes = [r.platformNote, r.partial, r.unsteady]
|
|
865
882
|
.filter(Boolean)
|
|
866
883
|
.map((n) => ` _(${cell(n)})_`)
|
|
867
884
|
.join("");
|
|
@@ -1035,11 +1052,12 @@ export function formatCheck(result) {
|
|
|
1035
1052
|
lines.push("", `## Flows (${result.flows.length})`, "");
|
|
1036
1053
|
for (const f of result.flows) {
|
|
1037
1054
|
const o = f.outcome;
|
|
1055
|
+
const as = f.role === undefined ? "" : ` as ${code(f.role)}`;
|
|
1038
1056
|
lines.push(o.status === "passed"
|
|
1039
|
-
? `- ✓ ${cell(f.name)} (${code(f.file)}): ${f.steps} step(s) passed`
|
|
1057
|
+
? `- ✓ ${cell(f.name)} (${code(f.file)})${as}: ${f.steps} step(s) passed`
|
|
1040
1058
|
: o.status === "refused"
|
|
1041
|
-
? `- ⊘ ${cell(f.name)} (${code(f.file)}): could not run — step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)} _(--flow-writes ${result.settings.flowWrites})_`
|
|
1042
|
-
: `- ✗ ${cell(f.name)} (${code(f.file)}): step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)}`);
|
|
1059
|
+
? `- ⊘ ${cell(f.name)} (${code(f.file)})${as}: could not run — step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)} _(--flow-writes ${result.settings.flowWrites})_`
|
|
1060
|
+
: `- ✗ ${cell(f.name)} (${code(f.file)})${as}: step ${o.step} of ${f.steps}, ${cell(o.did)}: ${cell(o.reason)}`);
|
|
1043
1061
|
if (f.refusedBackground.length > 0)
|
|
1044
1062
|
lines.push(` - refused background requests (beacons and pings, not charged to a step): ${f.refusedBackground.map(code).join(", ")}`);
|
|
1045
1063
|
if (f.websockets.length > 0)
|
|
@@ -1113,6 +1131,7 @@ export function toSummaryJson(result, toolVersion) {
|
|
|
1113
1131
|
flows: result.flows.map((f) => ({
|
|
1114
1132
|
name: f.name,
|
|
1115
1133
|
file: f.file,
|
|
1134
|
+
...(f.role === undefined ? {} : { role: f.role }),
|
|
1116
1135
|
steps: f.steps,
|
|
1117
1136
|
status: f.outcome.status,
|
|
1118
1137
|
...(f.outcome.status === "passed" ? {} : { step: f.outcome.step, did: f.outcome.did, reason: f.outcome.reason, path: f.outcome.path }),
|
package/dist/engine/dedup.js
CHANGED
|
@@ -83,6 +83,17 @@ export function buildPairs(archives, keys) {
|
|
|
83
83
|
}
|
|
84
84
|
return out;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The archives dated on or after `since` (YYYY-MM-DD), so a decider can be
|
|
88
|
+
* scored out of sample: on pairs from runs made after it was tuned, none of
|
|
89
|
+
* whose findings it has seen. Filtered before pairing, so a pair never joins
|
|
90
|
+
* a new finding to an old one.
|
|
91
|
+
*/
|
|
92
|
+
export function archivesSince(archives, since) {
|
|
93
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(since))
|
|
94
|
+
throw new Error(`The date must be YYYY-MM-DD, not ${JSON.stringify(since)}.`);
|
|
95
|
+
return archives.filter((a) => a.date >= since);
|
|
96
|
+
}
|
|
86
97
|
/**
|
|
87
98
|
* At most `cap` pairs, chosen by a hash of their texts: the same pairs every
|
|
88
99
|
* time for the same archives, whatever order they were built in, and no
|
package/dist/engine/design.js
CHANGED
|
@@ -412,6 +412,10 @@ function contrastFailures(records) {
|
|
|
412
412
|
for (const r of records) {
|
|
413
413
|
if (!r.text || r.bg === "image" || r.bg === "unknown" || r.color === "unknown")
|
|
414
414
|
continue;
|
|
415
|
+
// Text whose own colour is fully transparent is not painted: a selectable text layer laid over a rendered
|
|
416
|
+
// document (a PDF viewer's), or text hidden for screen readers. Its 1:1 ratio is not what anyone sees.
|
|
417
|
+
if (parseRgb(r.color)?.[3] === 0)
|
|
418
|
+
continue;
|
|
415
419
|
const ratio = contrastRatio(r.color, r.bg);
|
|
416
420
|
if (ratio === null)
|
|
417
421
|
continue;
|