scenescout 3.14.1 → 3.16.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 +94 -0
- package/README.md +90 -18
- package/dist/browsers.js +28 -0
- package/dist/check-run.js +211 -24
- package/dist/ci-run.js +374 -62
- package/dist/cli.js +221 -47
- package/dist/commands.js +42 -4
- package/dist/engine/baseline.js +377 -0
- package/dist/engine/brief.js +16 -7
- package/dist/engine/browser.js +1660 -386
- package/dist/engine/calibration.js +61 -30
- package/dist/engine/capture.js +164 -0
- package/dist/engine/check.js +258 -68
- package/dist/engine/ci-lanes.js +215 -0
- package/dist/engine/ci.js +233 -18
- package/dist/engine/claims.js +234 -12
- package/dist/engine/collector.js +851 -78
- package/dist/engine/crawl.js +49 -0
- package/dist/engine/dedup.js +329 -9
- package/dist/engine/design.js +355 -43
- package/dist/engine/export.js +877 -0
- package/dist/engine/fingerprint.js +92 -4
- package/dist/engine/flow.js +18 -6
- package/dist/engine/forms.js +198 -18
- package/dist/engine/journey.js +29 -1
- package/dist/engine/lane.js +13 -3
- package/dist/engine/launch.js +45 -6
- package/dist/engine/limits.js +7 -0
- package/dist/engine/live-page.js +49 -2
- package/dist/engine/live.js +4 -1
- package/dist/engine/memory.js +668 -70
- package/dist/engine/open.js +118 -0
- package/dist/engine/oracles.js +167 -2
- package/dist/engine/plain.js +268 -0
- package/dist/engine/png.js +127 -0
- package/dist/engine/policy.js +379 -9
- package/dist/engine/probes.js +3 -2
- package/dist/engine/profiles.js +45 -9
- package/dist/engine/project-folder.js +191 -0
- package/dist/engine/provider.js +4 -3
- package/dist/engine/refresh.js +338 -25
- package/dist/engine/replay.js +63 -10
- package/dist/engine/report.js +248 -40
- package/dist/engine/request.js +317 -23
- package/dist/engine/sarif.js +120 -0
- package/dist/engine/scripted-login.js +342 -62
- package/dist/engine/settle.js +67 -0
- package/dist/engine/signed-in.js +256 -0
- package/dist/engine/tickets.js +671 -0
- package/dist/engine/unload.js +3 -2
- package/dist/export-run.js +633 -0
- package/dist/first-run.js +581 -0
- package/dist/installer.js +267 -3
- package/dist/intake.js +104 -0
- package/dist/login-run.js +386 -71
- package/dist/mcp-server.js +637 -68
- package/dist/playbook.js +5 -0
- package/package.json +6 -3
- package/skills/scenescout/SKILL.md +51 -18
package/dist/engine/claims.js
CHANGED
|
@@ -86,6 +86,41 @@ const ERROR_RE = /\b(?:error|failed|failure|could ?n[o'’]?t|unable to|went wro
|
|
|
86
86
|
* unreported because the Danger zone said "This cannot be undone."
|
|
87
87
|
*/
|
|
88
88
|
const REFUSAL_RE = /\b(?:can(?:not|[’'`]?t| not)|must be|(?:is|are) already|only (?:an? |the )?\w+(?: \w+)? (?:can|may|be)|may only|can only(?: be)?|(?:nothing|not) (?:was|has been|have been|been) (?:saved|sent|deleted|updated|created|changed|submitted)|(?:was|were|has|have|is|are)(?: not|n[’']t)(?: been)? (?:saved|sent|deleted|updated|created|changed|submitted))\b/i;
|
|
89
|
+
/**
|
|
90
|
+
* Words that name a refusal outright. An admission when the page ANNOUNCES them
|
|
91
|
+
* or puts them up in answer to the action, as when a toast echoes the server's
|
|
92
|
+
* "was refused" message. Not page-wide: "Rejected" and "Blocked" are ordinary
|
|
93
|
+
* status badges and filter tabs on record pages, and counted wherever they
|
|
94
|
+
* stood they would excuse every lie on such a page.
|
|
95
|
+
*/
|
|
96
|
+
const REFUSED_RE = /\b(?:refused|rejected|blocked)\b/i;
|
|
97
|
+
/**
|
|
98
|
+
* Addresses of a page's own infrastructure writes: a token refresh, telemetry,
|
|
99
|
+
* an error monitor, a heartbeat. Refusing one says nothing about whether the
|
|
100
|
+
* change the user made was kept, so it is never paired with a success claim.
|
|
101
|
+
*/
|
|
102
|
+
export const INFRASTRUCTURE_WRITE_RE = /\/auth\/(refresh|token|session)|refresh[-_]?token|\/telemetry|\/analytics|\/heartbeat|\/sentry|\/envelope\b|client[-_]?errors?\b|\/collect\b|\/logs?\b|\/metrics\b/i;
|
|
103
|
+
/**
|
|
104
|
+
* Whether a request is one the action being judged did not send (see
|
|
105
|
+
* `WatchedRequest.background`).
|
|
106
|
+
*
|
|
107
|
+
* Once a click has started loading a new document, what follows is not the
|
|
108
|
+
* click's own write. Requests the old page sends as it is left (a preference
|
|
109
|
+
* save on pagehide) belong to that page, and requests the new page sends as it
|
|
110
|
+
* loads (an error monitor, a visit beacon) are sent with no input on it yet.
|
|
111
|
+
* Neither is answered by anything on screen, and the new page's text is all
|
|
112
|
+
* new against the old page's, so paired they read every static "Completed" as
|
|
113
|
+
* a false success. The click's own writes come before the navigation starts:
|
|
114
|
+
* a script's save awaited before it moves the page, or the navigation itself
|
|
115
|
+
* when it is a native form post.
|
|
116
|
+
*/
|
|
117
|
+
export function isBackgroundRequest(t) {
|
|
118
|
+
if (t.inputSince === null || t.inputSince < t.actionStartedAt || t.started < t.inputSince)
|
|
119
|
+
return true;
|
|
120
|
+
if (t.isDocumentLoad)
|
|
121
|
+
return false;
|
|
122
|
+
return t.documentLoadSince !== null && t.started > t.documentLoadSince;
|
|
123
|
+
}
|
|
89
124
|
/** Whether a piece of announced text explains a refusal. */
|
|
90
125
|
export function isRefusalNotice(text) {
|
|
91
126
|
const trimmed = text.trim();
|
|
@@ -110,6 +145,15 @@ export function classify(text) {
|
|
|
110
145
|
return "empty";
|
|
111
146
|
return null;
|
|
112
147
|
}
|
|
148
|
+
/** What counts as an open dialog: a native one or an ARIA one. */
|
|
149
|
+
const DIALOG_SEL = 'dialog[open], [role="dialog"], [role="alertdialog"]';
|
|
150
|
+
/** Page-side count of the open dialogs alone, for a caller that needs nothing else the claim scan reads. */
|
|
151
|
+
export const OPEN_DIALOGS_SCRIPT = `(() => {
|
|
152
|
+
const visible = ${VISIBLE_SRC};
|
|
153
|
+
let dialogs = 0;
|
|
154
|
+
for (const d of document.querySelectorAll(${JSON.stringify(DIALOG_SEL)})) if (visible(d)) dialogs += 1;
|
|
155
|
+
return dialogs;
|
|
156
|
+
})()`;
|
|
113
157
|
function shortUrl(url) {
|
|
114
158
|
try {
|
|
115
159
|
const u = new URL(url);
|
|
@@ -128,25 +172,42 @@ function standIn(req) {
|
|
|
128
172
|
? ` The refusal was the write policy's stand-in (the server never received the request); what failed is the page's handling of a refusal, which a real one would meet the same way.`
|
|
129
173
|
: "";
|
|
130
174
|
}
|
|
175
|
+
/** Whether an announced sentence admits the failure: an error, a refusal named outright, or one explained in the app's own words. */
|
|
176
|
+
function admitsInAnnouncement(text) {
|
|
177
|
+
return classify(text) === "error" || REFUSED_RE.test(text) || isRefusalNotice(text);
|
|
178
|
+
}
|
|
131
179
|
/**
|
|
132
180
|
* The contradictions this action produced, if any.
|
|
133
181
|
*
|
|
134
182
|
* Both rules are silent whenever the page admits the failure, and both require
|
|
135
183
|
* a genuinely refused request — the page half never fires alone.
|
|
184
|
+
*
|
|
185
|
+
* `before` is what the page said when the action began, when the action was
|
|
186
|
+
* the user's (a click, a keypress, typing). A success claim already on screen
|
|
187
|
+
* then is not the page's answer to this action's write: a status badge reading
|
|
188
|
+
* "Published", a heading, a row from earlier. Only what the action put up can
|
|
189
|
+
* contradict what the action's write met. Without it, every text counts.
|
|
136
190
|
*/
|
|
137
|
-
export function findContradictions(requests, page) {
|
|
191
|
+
export function findContradictions(requests, page, before, acted) {
|
|
138
192
|
const refused = requests.filter(isRefused);
|
|
139
193
|
if (refused.length === 0)
|
|
140
194
|
return [];
|
|
141
195
|
const claims = page.texts.map(classify);
|
|
196
|
+
const earlier = before ? new Set([...before.texts, ...(before.announced ?? [])]) : null;
|
|
197
|
+
const isNew = (text) => earlier === null || !earlier.has(text);
|
|
142
198
|
// An app that says what went wrong has behaved correctly, and nothing below
|
|
143
199
|
// applies. This is checked before anything else so that a page carrying both
|
|
144
200
|
// an error banner and a stale empty state is not reported.
|
|
145
201
|
if (claims.includes("error"))
|
|
146
202
|
return [];
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
203
|
+
// An admission where the page announces its responses: an error, a refusal
|
|
204
|
+
// named outright (an app echoing the server's "was refused"), or one
|
|
205
|
+
// explained in the app's own words. Help text with the same wording
|
|
206
|
+
// elsewhere excuses nothing.
|
|
207
|
+
if ((page.announced ?? []).some(admitsInAnnouncement))
|
|
208
|
+
return [];
|
|
209
|
+
// A refusal named outright in text the action put up, announced or not.
|
|
210
|
+
if (earlier !== null && page.texts.some((t) => isNew(t) && t.length <= CLAIM_TEXT_MAX && REFUSED_RE.test(t)))
|
|
150
211
|
return [];
|
|
151
212
|
const out = [];
|
|
152
213
|
const reads = refused.filter((r) => !WRITING_METHODS.has(r.method.toUpperCase()));
|
|
@@ -162,18 +223,171 @@ export function findContradictions(requests, page) {
|
|
|
162
223
|
evidence: `refused-empty ${say(worst)}`,
|
|
163
224
|
});
|
|
164
225
|
}
|
|
165
|
-
|
|
166
|
-
|
|
226
|
+
// Only writes this action sent, and not the page's own infrastructure.
|
|
227
|
+
const isActionWrite = (r) => WRITING_METHODS.has(r.method.toUpperCase()) && !r.background && !INFRASTRUCTURE_WRITE_RE.test(r.url);
|
|
228
|
+
const writes = refused.filter(isActionWrite);
|
|
229
|
+
// Writes of the same action that went through. The message may be about
|
|
230
|
+
// one of them, so a success claim beside them is a PARTIAL false success:
|
|
231
|
+
// part of the user's change was refused and the page said nothing about
|
|
232
|
+
// that part. Still reported, at medium, because silent partial loss is a
|
|
233
|
+
// real defect; all refused stays high.
|
|
234
|
+
const kept = requests.filter((r) => isActionWrite(r) && DATA_RESOURCES.has(r.resourceType) && !r.blockedByPolicy && r.status !== null && r.status < 400);
|
|
235
|
+
const successAt = claims.findIndex((c, i) => c === "success" && isNew(page.texts[i]));
|
|
236
|
+
if (writes.length > 0 && successAt >= 0) {
|
|
237
|
+
const worst = writes[0];
|
|
238
|
+
const message = JSON.stringify(page.texts[successAt].trim().slice(0, 80));
|
|
239
|
+
if (kept.length === 0) {
|
|
240
|
+
out.push({
|
|
241
|
+
kind: "false_success",
|
|
242
|
+
detail: `${say(worst)} was refused, and the page says ${message}. The user is told their change was kept when the server rejected it.${standIn(worst)}`,
|
|
243
|
+
evidence: `false-success ${say(worst)}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
const total = writes.length + kept.length;
|
|
248
|
+
out.push({
|
|
249
|
+
kind: "false_success",
|
|
250
|
+
severity: "medium",
|
|
251
|
+
detail: `partial: ${writes.length} of ${total} writes from this action were refused (${say(worst)}), and the page says ${message} with no word about the refused part. ` +
|
|
252
|
+
`The user is told their change was kept when part of it was rejected.${standIn(worst)}`,
|
|
253
|
+
evidence: `false-success-partial ${say(worst)}`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// No success word, but the control or a counter beside it shows the change
|
|
258
|
+
// as kept: an optimistic UI telling the same falsehood without words.
|
|
259
|
+
// Medium: the page never said "saved", and a reader may still notice a
|
|
260
|
+
// reload putting the old value back.
|
|
261
|
+
const change = acted && writes.length > 0 && kept.length === 0 && successAt < 0 ? keptChange(acted.before, acted.after) : null;
|
|
262
|
+
if (change) {
|
|
167
263
|
const worst = writes[0];
|
|
168
|
-
const message = page.texts[claims.indexOf("success")];
|
|
169
264
|
out.push({
|
|
170
265
|
kind: "false_success",
|
|
171
|
-
|
|
172
|
-
|
|
266
|
+
severity: "medium",
|
|
267
|
+
detail: `${say(worst)} was refused, and the page shows the change as kept (${change}) with no error. The user is told their change was kept when the server rejected it.${standIn(worst)}`,
|
|
268
|
+
evidence: `false-success-kept ${say(worst)}`,
|
|
173
269
|
});
|
|
174
270
|
}
|
|
175
271
|
return out;
|
|
176
272
|
}
|
|
273
|
+
/** A counter: "1/3", "2 of 5", "40%". Not a date ("10/02/2026") nor a part of a longer number. */
|
|
274
|
+
export const COUNTER_RE = /(?<![\d/.,:])(?:\d{1,4} ?(?:\/|of) ?\d{1,4}|\d{1,3} ?%)(?![\d/.,:])/i;
|
|
275
|
+
/** The containers a counter must share with the control to count as its counter. */
|
|
276
|
+
const CONTROL_CONTAINER_SEL = 'dialog, [role="dialog"], [role="alertdialog"], fieldset, form, [role="group"], [role="radiogroup"], [role="toolbar"], [role="region"], section, aside, [role="complementary"]';
|
|
277
|
+
/** Most counters read for one control. */
|
|
278
|
+
export const MAX_COUNTERS = 20;
|
|
279
|
+
/**
|
|
280
|
+
* Page-side reader of an acted-on control: `(${ACTED_CONTROL_SRC})(node)`
|
|
281
|
+
* returns an ActedControl, or null for no node. A label stands for the box it
|
|
282
|
+
* labels. Shipped as a string for the same reason CLAIM_SCAN_SCRIPT is.
|
|
283
|
+
*/
|
|
284
|
+
export const ACTED_CONTROL_SRC = `(node) => {
|
|
285
|
+
if (!node) return null;
|
|
286
|
+
const visible = ${VISIBLE_SRC};
|
|
287
|
+
const box = (n) => n && n.tagName === "INPUT" && (n.type === "checkbox" || n.type === "radio");
|
|
288
|
+
const control = node.control || (box(node) ? node : node.querySelector && node.querySelector("input[type=checkbox], input[type=radio]")) || node;
|
|
289
|
+
const flag = (v) => (v === "true" ? true : v === "false" ? false : null);
|
|
290
|
+
const role = control.getAttribute("role") || (box(control) ? control.type : control.tagName.toLowerCase());
|
|
291
|
+
const counter = ${COUNTER_RE};
|
|
292
|
+
const counters = [];
|
|
293
|
+
const add = (t) => {
|
|
294
|
+
const text = (t || "").replace(/\\s+/g, " ").trim();
|
|
295
|
+
if (text && text.length <= 60 && counter.test(text) && !counters.includes(text) && counters.length < ${MAX_COUNTERS}) counters.push(text);
|
|
296
|
+
};
|
|
297
|
+
const near = control.closest(${JSON.stringify(CONTROL_CONTAINER_SEL)}) || (control.parentElement && control.parentElement.parentElement);
|
|
298
|
+
if (near) {
|
|
299
|
+
const walker = document.createTreeWalker(near, NodeFilter.SHOW_ELEMENT);
|
|
300
|
+
for (let el = near; el && counters.length < ${MAX_COUNTERS}; el = walker.nextNode()) {
|
|
301
|
+
let own = "";
|
|
302
|
+
for (const child of el.childNodes) if (child.nodeType === 3) own += child.nodeValue;
|
|
303
|
+
if (own.trim() && visible(el)) add(own);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
for (const region of document.querySelectorAll("[role~='status'], [role~='alert'], [aria-live]:not([aria-live='off']), output")) {
|
|
307
|
+
if (visible(region)) add(region.innerText);
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
role,
|
|
311
|
+
checked: box(control) ? control.checked : flag(control.getAttribute("aria-checked")),
|
|
312
|
+
pressed: flag(control.getAttribute("aria-pressed")),
|
|
313
|
+
selected: flag(control.getAttribute("aria-selected")),
|
|
314
|
+
counters,
|
|
315
|
+
};
|
|
316
|
+
}`;
|
|
317
|
+
/**
|
|
318
|
+
* How the acted-on control shows its change as kept, or null when it does not.
|
|
319
|
+
*
|
|
320
|
+
* A checked, pressed or selected state that moved is the change itself (a
|
|
321
|
+
* tab's selection is not: choosing a tab is moving around, not a change the
|
|
322
|
+
* user asked to keep). A counter beside it that moved ("0/3" to "1/3
|
|
323
|
+
* completed") counts it as done. A counter that merely appeared does not: a
|
|
324
|
+
* step that opens with "Step 1 of 3" counts nothing yet.
|
|
325
|
+
*/
|
|
326
|
+
export function keptChange(before, after) {
|
|
327
|
+
for (const state of ["checked", "pressed", "selected"]) {
|
|
328
|
+
if (state === "selected" && after.role === "tab")
|
|
329
|
+
continue;
|
|
330
|
+
const was = before[state];
|
|
331
|
+
const now = after[state];
|
|
332
|
+
if (was !== null && now !== null && was !== now)
|
|
333
|
+
return `the control is now ${now ? state : `not ${state}`}`;
|
|
334
|
+
}
|
|
335
|
+
const gone = before.counters.filter((c) => !after.counters.includes(c));
|
|
336
|
+
const fresh = after.counters.filter((c) => !before.counters.includes(c));
|
|
337
|
+
if (gone.length > 0 && fresh.length > 0)
|
|
338
|
+
return `${JSON.stringify(gone[0])} became ${JSON.stringify(fresh[0])}`;
|
|
339
|
+
return null;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Whether a submit-shaped click that sent no request was answered on the page
|
|
343
|
+
* anyway, judged from the page as the click began and as it settled.
|
|
344
|
+
*
|
|
345
|
+
* - A dialog opened: a step (a confirmation), not a submit.
|
|
346
|
+
* - A field became invalid, or the page announced something new that is not
|
|
347
|
+
* a success claim: client-side validation said what was wrong, and no
|
|
348
|
+
* request was expected. A new "Saved!" is the opposite, the very case the
|
|
349
|
+
* silent-submit note is for, so it never counts.
|
|
350
|
+
*
|
|
351
|
+
* Null when either reading is missing or nothing visible answered.
|
|
352
|
+
*/
|
|
353
|
+
export function quietAnswer(before, after) {
|
|
354
|
+
if (!before || !after)
|
|
355
|
+
return null;
|
|
356
|
+
if ((after.dialogs ?? 0) > (before.dialogs ?? 0))
|
|
357
|
+
return "dialog";
|
|
358
|
+
if ((after.invalid ?? 0) > (before.invalid ?? 0))
|
|
359
|
+
return "validation";
|
|
360
|
+
const said = new Set(before.announced ?? []);
|
|
361
|
+
if ((after.announced ?? []).some((text) => !said.has(text) && classify(text) !== "success"))
|
|
362
|
+
return "validation";
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Whether a piece of announced text (an alert's or a live region's name, as
|
|
367
|
+
* the snapshot lists it) was not on the page at `baseline`. The name may be a
|
|
368
|
+
* region's sentences run together and cut short, so it is matched by prefix
|
|
369
|
+
* either way against the baseline's texts and announced sentences. False
|
|
370
|
+
* when there is no baseline: nothing says when the text arrived.
|
|
371
|
+
*/
|
|
372
|
+
export function saidSince(text, baseline) {
|
|
373
|
+
if (!baseline)
|
|
374
|
+
return false;
|
|
375
|
+
const norm = (t) => t.replace(/\s+/g, " ").trim();
|
|
376
|
+
const said = norm(text);
|
|
377
|
+
if (!said)
|
|
378
|
+
return false;
|
|
379
|
+
for (const earlier of [...baseline.texts, ...(baseline.announced ?? [])]) {
|
|
380
|
+
const e = norm(earlier);
|
|
381
|
+
if (!e)
|
|
382
|
+
continue;
|
|
383
|
+
if (e === said)
|
|
384
|
+
return false;
|
|
385
|
+
const shorter = Math.min(e.length, said.length);
|
|
386
|
+
if (shorter >= 20 && (e.startsWith(said) || said.startsWith(e)))
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
177
391
|
/** Most announced regions read from one page. */
|
|
178
392
|
export const MAX_ANNOUNCED = 40;
|
|
179
393
|
/** Most pieces of text read from one page. A page with more than this has nothing useful to say in the extra ones. */
|
|
@@ -200,6 +414,7 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
|
|
|
200
414
|
// is a container of static text (its form's help, its warning), and counting
|
|
201
415
|
// it brought back the help text this set exists to exclude.
|
|
202
416
|
const ANNOUNCES = "[role~='status'], [role~='alert'], [aria-live]:not([aria-live='off']), output";
|
|
417
|
+
const COLUMN_HEADER = "th, [role~='columnheader'], [aria-sort]";
|
|
203
418
|
const seen = new Set();
|
|
204
419
|
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT);
|
|
205
420
|
let el = document.body;
|
|
@@ -207,7 +422,8 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
|
|
|
207
422
|
let own = "";
|
|
208
423
|
for (const node of el.childNodes) if (node.nodeType === 3) own += node.nodeValue;
|
|
209
424
|
own = own.replace(/\\s+/g, " ").trim();
|
|
210
|
-
|
|
425
|
+
// A column header names what a column holds ("Updated on", "Completed"); it claims nothing.
|
|
426
|
+
if (own && own.length <= ${CLAIM_TEXT_MAX} && !seen.has(own) && !el.closest(COLUMN_HEADER) && visible(el)) {
|
|
211
427
|
seen.add(own);
|
|
212
428
|
texts.push(own);
|
|
213
429
|
}
|
|
@@ -243,7 +459,13 @@ export const CLAIM_SCAN_SCRIPT = `(() => {
|
|
|
243
459
|
if (s && s.length <= ${CLAIM_TEXT_MAX} && !announced.includes(s)) announced.push(s);
|
|
244
460
|
}
|
|
245
461
|
}
|
|
462
|
+
// Open dialogs, so a page error raised as one opens can be read as the
|
|
463
|
+
// confirmation a router cancelled a route change for (oracles.ts).
|
|
464
|
+
let dialogs = 0;
|
|
465
|
+
for (const d of document.querySelectorAll(${JSON.stringify(DIALOG_SEL)})) if (visible(d)) dialogs += 1;
|
|
466
|
+
// Fields the page marked invalid, so a submit answered by validation is not called silent.
|
|
467
|
+
let invalid = 0;
|
|
468
|
+
for (const f of document.querySelectorAll('[aria-invalid="true"]')) if (visible(f)) invalid += 1;
|
|
246
469
|
|
|
247
|
-
|
|
248
|
-
return { texts, announced, emptyLists };
|
|
470
|
+
return { texts, announced, emptyLists, dialogs, invalid };
|
|
249
471
|
})()`;
|