@browserstack/mcp-server 2.2.5 → 2.2.6-beta.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/README.md +32 -194
- package/dist/lib/accessibility-routing.d.ts +6 -0
- package/dist/lib/accessibility-routing.js +6 -0
- package/dist/server-factory.js +6 -1
- package/dist/tools/accessibility.js +680 -69
- package/dist/tools/accessiblity-utils/app-report-fetcher.d.ts +412 -0
- package/dist/tools/accessiblity-utils/app-report-fetcher.js +926 -0
- package/dist/tools/accessiblity-utils/paginate.d.ts +27 -0
- package/dist/tools/accessiblity-utils/paginate.js +50 -0
- package/dist/tools/accessiblity-utils/report-fetcher.d.ts +116 -7
- package/dist/tools/accessiblity-utils/report-fetcher.js +268 -29
- package/dist/tools/accessiblity-utils/report-parser.d.ts +41 -1
- package/dist/tools/accessiblity-utils/report-parser.js +142 -43
- package/dist/tools/accessiblity-utils/scanner.d.ts +15 -1
- package/dist/tools/accessiblity-utils/scanner.js +3 -4
- package/dist/tools/appautomate-utils/appium-sdk/config-generator.d.ts +2 -0
- package/dist/tools/appautomate-utils/appium-sdk/config-generator.js +15 -5
- package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +2 -1
- package/dist/tools/appautomate-utils/appium-sdk/constants.js +6 -1
- package/dist/tools/appautomate-utils/appium-sdk/handler.js +12 -1
- package/dist/tools/appautomate-utils/appium-sdk/instructions.d.ts +3 -1
- package/dist/tools/appautomate-utils/appium-sdk/instructions.js +2 -2
- package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.d.ts +4 -0
- package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.js +47 -0
- package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.d.ts +3 -1
- package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.js +19 -6
- package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +7 -2
- package/dist/tools/appautomate-utils/native-execution/appautomate.js +15 -12
- package/dist/tools/appautomate-utils/native-execution/constants.d.ts +2 -1
- package/dist/tools/appautomate-utils/native-execution/constants.js +6 -1
- package/dist/tools/appautomate.js +29 -16
- package/dist/tools/testmanagement-utils/create-testrun.d.ts +1 -1
- package/dist/tools/testmanagement-utils/update-testrun.d.ts +1 -1
- package/dist/tools/tool-handoff.d.ts +3 -2
- package/dist/tools/tool-handoff.js +3 -2
- package/package.json +1 -1
- package/dist/tools/accessiblity-utils/accessibility-rag.d.ts +0 -12
- package/dist/tools/accessiblity-utils/accessibility-rag.js +0 -59
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { AccessibilityScanner } from "./accessiblity-utils/scanner.js";
|
|
3
|
-
import { AccessibilityReportFetcher, } from "./accessiblity-utils/report-fetcher.js";
|
|
2
|
+
import { AccessibilityScanner, } from "./accessiblity-utils/scanner.js";
|
|
3
|
+
import { AccessibilityReportFetcher, EXPORT_WAIT_MS, ExportPendingError, } from "./accessiblity-utils/report-fetcher.js";
|
|
4
|
+
import { compareAppIssues, } from "./accessiblity-utils/app-report-fetcher.js";
|
|
4
5
|
import { AccessibilityAuthConfig, safeAuthConfigData, } from "./accessiblity-utils/auth-config.js";
|
|
5
6
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
6
|
-
import { parseAccessibilityReportFromCSV } from "./accessiblity-utils/report-parser.js";
|
|
7
|
+
import { compareIssues, loadMainIssues, parseAccessibilityReportFromCSV, } from "./accessiblity-utils/report-parser.js";
|
|
7
8
|
import { wrapUntrusted } from "../lib/untrusted-content.js";
|
|
8
|
-
import { queryAccessibilityRAG } from "./accessiblity-utils/accessibility-rag.js";
|
|
9
9
|
import { getBrowserStackAuth } from "../lib/get-auth.js";
|
|
10
10
|
import { elicitCredentialsIfSupported } from "../lib/elicit-credentials.js";
|
|
11
11
|
import logger from "../logger.js";
|
|
@@ -66,25 +66,17 @@ async function initializeReportFetcher(config) {
|
|
|
66
66
|
reportFetcher.setAuth(auth);
|
|
67
67
|
return reportFetcher;
|
|
68
68
|
}
|
|
69
|
-
async function executeAccessibilityRAG(args, server, config) {
|
|
70
|
-
try {
|
|
71
|
-
trackMCP("accessibilityExpert", server.server.getClientVersion(), undefined, config);
|
|
72
|
-
return await queryAccessibilityRAG(args.query, config);
|
|
73
|
-
}
|
|
74
|
-
catch (error) {
|
|
75
|
-
return handleMCPError("accessibilityExpert", server, config, error);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
69
|
/** The one source the arguments name, or why they do not name exactly one. */
|
|
79
70
|
function issueSourceFrom(args) {
|
|
80
71
|
const named = [
|
|
81
72
|
Boolean(args.scanId || args.scanRunId),
|
|
82
73
|
Boolean(args.buildId),
|
|
83
74
|
Boolean(args.workflowReportId),
|
|
75
|
+
Boolean(args.appReportId),
|
|
84
76
|
Boolean(args.reportUrl),
|
|
85
77
|
].filter(Boolean).length;
|
|
86
78
|
if (named !== 1) {
|
|
87
|
-
return "Pass exactly one source: scanId with scanRunId (a Website Scanner run), buildId (an Automated Tests build), workflowReportId (a Workflow Analyzer report) or reportUrl (a report's dashboard link).";
|
|
79
|
+
return "Pass exactly one source: scanId with scanRunId (a Website Scanner run), buildId (an Automated Tests or App Accessibility build), workflowReportId (a Workflow Analyzer report), appReportId (an App Accessibility manual report) or reportUrl (a report's dashboard link).";
|
|
88
80
|
}
|
|
89
81
|
if (args.reportUrl)
|
|
90
82
|
return { kind: "link", reportUrl: args.reportUrl };
|
|
@@ -93,6 +85,9 @@ function issueSourceFrom(args) {
|
|
|
93
85
|
if (args.workflowReportId) {
|
|
94
86
|
return { kind: "workflow", reportId: args.workflowReportId };
|
|
95
87
|
}
|
|
88
|
+
if (args.appReportId) {
|
|
89
|
+
return { kind: "appReport", reportId: args.appReportId };
|
|
90
|
+
}
|
|
96
91
|
if (!args.scanId || !args.scanRunId) {
|
|
97
92
|
return "A Website Scanner run needs both scanId and scanRunId.";
|
|
98
93
|
}
|
|
@@ -104,9 +99,12 @@ function sourceArgs(source) {
|
|
|
104
99
|
case "scan":
|
|
105
100
|
return `scanId: "${source.scanId}", scanRunId: "${source.scanRunId}"`;
|
|
106
101
|
case "build":
|
|
102
|
+
case "appBuild":
|
|
107
103
|
return `buildId: "${source.buildId}"`;
|
|
108
104
|
case "workflow":
|
|
109
105
|
return `workflowReportId: "${source.reportId}"`;
|
|
106
|
+
case "appReport":
|
|
107
|
+
return `appReportId: "${source.reportId}"`;
|
|
110
108
|
}
|
|
111
109
|
}
|
|
112
110
|
/** What a dashboard link resolved to, in words. */
|
|
@@ -118,6 +116,10 @@ function describeSource(source) {
|
|
|
118
116
|
return `Automated Tests build ${source.buildId}`;
|
|
119
117
|
case "workflow":
|
|
120
118
|
return `Workflow Analyzer report ${source.reportId}`;
|
|
119
|
+
case "appBuild":
|
|
120
|
+
return `App Accessibility build ${source.buildId}`;
|
|
121
|
+
case "appReport":
|
|
122
|
+
return `App Accessibility manual report ${source.reportId}`;
|
|
121
123
|
}
|
|
122
124
|
}
|
|
123
125
|
async function executeFetchAccessibilityIssues(args, context, server, config) {
|
|
@@ -126,7 +128,11 @@ async function executeFetchAccessibilityIssues(args, context, server, config) {
|
|
|
126
128
|
const source = issueSourceFrom(args);
|
|
127
129
|
if (typeof source === "string")
|
|
128
130
|
return createErrorResponse(source);
|
|
129
|
-
return await fetchAccessibilityIssues(source, config, args.cursor, context
|
|
131
|
+
return await fetchAccessibilityIssues(source, config, args.cursor, context, {
|
|
132
|
+
compareWith: args.compareWith,
|
|
133
|
+
pageUrl: args.pageUrl,
|
|
134
|
+
exportTaskId: args.exportTaskId,
|
|
135
|
+
});
|
|
130
136
|
}
|
|
131
137
|
catch (error) {
|
|
132
138
|
return handleMCPError("fetchAccessibilityIssues", server, config, error);
|
|
@@ -155,19 +161,21 @@ function excludedIssuesNote(duplicates, unlinked, hidden) {
|
|
|
155
161
|
/** How often, and for how long, a first read waits on a report that is still processing. */
|
|
156
162
|
export const REPORT_STATUS_POLL_MS = 10_000;
|
|
157
163
|
export const REPORT_STATUS_WAIT_MS = 5 * 60 * 1000;
|
|
158
|
-
/** Statuses during which a report's issues are still being added. */
|
|
164
|
+
/** Statuses during which a report's issues are still being added (`rule_processing`: an app build's). */
|
|
159
165
|
const PROCESSING_STATUSES = new Set([
|
|
160
166
|
"queued",
|
|
161
167
|
"running",
|
|
162
168
|
"processing",
|
|
163
169
|
"finalizing",
|
|
170
|
+
"rule_processing",
|
|
171
|
+
"in_progress",
|
|
164
172
|
]);
|
|
165
173
|
/**
|
|
166
|
-
* Of those, the
|
|
167
|
-
* ends within minutes. A report whose scan or tests are still running is read as it is,
|
|
174
|
+
* Of those, the ones worth waiting on: the processing after the run (a web scan's advanced
|
|
175
|
+
* issues, an app build's rules), which ends within minutes. A report whose scan or tests are still running is read as it is,
|
|
168
176
|
* with a note, since that can last as long as the suite does.
|
|
169
177
|
*/
|
|
170
|
-
const WAIT_STATUSES = new Set(["finalizing"]);
|
|
178
|
+
const WAIT_STATUSES = new Set(["finalizing", "rule_processing"]);
|
|
171
179
|
/**
|
|
172
180
|
* Where a report is in its processing before its issues are read. Issues keep being added
|
|
173
181
|
* until it completes; an advanced scan adds its advanced-rule issues while "finalizing", for
|
|
@@ -215,46 +223,587 @@ function processingNote({ status, firstStatus, waitedMs, }) {
|
|
|
215
223
|
}
|
|
216
224
|
return [];
|
|
217
225
|
}
|
|
218
|
-
|
|
226
|
+
/** Progress while a report's CSV export is generated, so clients see the call is alive. */
|
|
227
|
+
function exportProgress(context) {
|
|
228
|
+
return async (waitedMs) => {
|
|
229
|
+
if (!context)
|
|
230
|
+
return;
|
|
231
|
+
await notifyScanProgress(context, `Generating the report's CSV export (large reports take longer): ${formatDuration(waitedMs)} so far.`, Math.min(99, Math.round((waitedMs / EXPORT_WAIT_MS) * 100)));
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/** What to say of an export still being generated: how to continue it, not an error. */
|
|
235
|
+
function exportPendingMessages(source, error, extraArgs) {
|
|
236
|
+
return [
|
|
237
|
+
`This report's CSV export is still being generated (waited ${formatDuration(error.waitedMs)}; large reports can take a few minutes). No issues have been read yet.`,
|
|
238
|
+
`Call fetchAccessibilityIssues again with ${sourceArgs(source)}${extraArgs} and exportTaskId: "${error.taskId}" to continue this export instead of starting a new one.`,
|
|
239
|
+
];
|
|
240
|
+
}
|
|
241
|
+
/** The last line: how to get the next page, or that there is none. */
|
|
242
|
+
function closingMessage(next_page, remaining, source, stillProcessing, extraArgs = "") {
|
|
243
|
+
if (next_page !== null) {
|
|
244
|
+
return `${remaining} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)}${extraArgs} and cursor: ${next_page} to get the next batch.`;
|
|
245
|
+
}
|
|
246
|
+
if (stillProcessing) {
|
|
247
|
+
return `All issues found so far retrieved; the report is still processing.`;
|
|
248
|
+
}
|
|
249
|
+
return `✅ All issues retrieved.`;
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* An app build's name and dashboard link, as data: the name is the user's own (from their
|
|
253
|
+
* CI or browserstack.yml), so it is quoted in an untrusted block rather than in our prose.
|
|
254
|
+
*/
|
|
255
|
+
function appBuildDetails(report) {
|
|
256
|
+
return `Build: ${wrapUntrusted("App Accessibility build", JSON.stringify({
|
|
257
|
+
name: report.build_name,
|
|
258
|
+
...(report.dashboard_link
|
|
259
|
+
? { dashboard_link: report.dashboard_link }
|
|
260
|
+
: {}),
|
|
261
|
+
}, null, 2))}`;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Why a native (Espresso, XCUITest) build recorded no scans. The build request's flag only
|
|
265
|
+
* turns the checks on: the suite itself must carry BrowserStack's instrumentation.
|
|
266
|
+
*/
|
|
267
|
+
const NATIVE_NO_SCAN_CAUSES = {
|
|
268
|
+
espresso: [
|
|
269
|
+
"The test suite was not built with BrowserStack's Gradle plugin and SDK: add classpath 'com.browserstack:gradle-tool:latest.release' to the root build.gradle, id 'com.browserstack.gradle-tool' to the app's plugins, and androidTestImplementation 'com.browserstack:browserstack-java-sdk:latest.release' (1.31.0 or newer), then rebuild the test APK.",
|
|
270
|
+
"Test Observability was off for the build: Espresso's App Accessibility report needs `testObservability: true` in the build request alongside `accessibility: true` (runAppTestsOnBrowserStack sends both).",
|
|
271
|
+
"The app cannot reach BrowserStack from the device: it needs internet permission, and cleartext traffic allowed (usesCleartextTraffic) if the network calls for it.",
|
|
272
|
+
"No tests ran: check the build's sessions and test results on App Automate.",
|
|
273
|
+
],
|
|
274
|
+
xcuitest: [
|
|
275
|
+
"The UI test target does not embed and initialize the BrowserStack Swift SDK: add BrowserstackSwiftSDK.xcframework (or the Swift package) to the test target, and set NSPrincipalClass to BrowserstackSwiftSDK.BrowserstackSwiftSDK in its Info.plist or call BrowserstackSwiftSDK.init() in the test class's init().",
|
|
276
|
+
"The device runs iOS older than 14, or the suite was built with Swift older than 5.4.",
|
|
277
|
+
"No tests ran: check the build's sessions and test results on App Automate.",
|
|
278
|
+
],
|
|
279
|
+
};
|
|
280
|
+
/** Which native framework ran a build, when its details name one. */
|
|
281
|
+
function nativeFramework(framework) {
|
|
282
|
+
if (!framework)
|
|
283
|
+
return undefined;
|
|
284
|
+
if (/espresso/i.test(framework))
|
|
285
|
+
return "espresso";
|
|
286
|
+
if (/xcui|xctest/i.test(framework))
|
|
287
|
+
return "xcuitest";
|
|
288
|
+
return undefined;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Why a finished app build has no scans. Each cause here produced a run that finished
|
|
292
|
+
* green with nothing scanned and no warning; they are listed most likely first, with what
|
|
293
|
+
* the build's own details say ahead of the rest. A native build gets the native causes.
|
|
294
|
+
*/
|
|
295
|
+
function noScansNote(report) {
|
|
296
|
+
const native = nativeFramework(report.framework);
|
|
297
|
+
if (native) {
|
|
298
|
+
const causes = [
|
|
299
|
+
...(report.fup_capped
|
|
300
|
+
? [
|
|
301
|
+
"This build hit the fair-usage cap (fup in the build details), which drops tests or scans.",
|
|
302
|
+
]
|
|
303
|
+
: []),
|
|
304
|
+
...NATIVE_NO_SCAN_CAUSES[native],
|
|
305
|
+
];
|
|
306
|
+
return [
|
|
307
|
+
`No accessibility scans were recorded for the ${native === "espresso" ? "Espresso" : "XCUITest"} build below (status: ${report.status}), so there are no issues to list.`,
|
|
308
|
+
appBuildDetails(report),
|
|
309
|
+
"The usual causes for a native build, most likely first:",
|
|
310
|
+
...causes.map((cause, i) => `${i + 1}. ${cause}`),
|
|
311
|
+
];
|
|
312
|
+
}
|
|
313
|
+
const causes = [
|
|
314
|
+
...(report.fup_capped
|
|
315
|
+
? [
|
|
316
|
+
"This build hit the fair-usage cap (fup in the build details), which drops tests or scans.",
|
|
317
|
+
]
|
|
318
|
+
: []),
|
|
319
|
+
...(report.status === "timeout"
|
|
320
|
+
? [
|
|
321
|
+
'The session timed out during initialization (status "timeout"): check the script and the config.',
|
|
322
|
+
]
|
|
323
|
+
: []),
|
|
324
|
+
"`accessibility: true` is not set in browserstack.yml (or the capabilities); the SDK defaults it to false.",
|
|
325
|
+
'The platform entry in browserstack.yml does not use `osVersion`, unquoted: with `platformVersion` the SDK selects the device but App Accessibility records no scans, and a quoted "14.0" fails its number check and turns the checks off; neither says so. Write `osVersion: 14.0`.',
|
|
326
|
+
'The device runs an OS App Accessibility does not support: Android 11 or newer, or iOS 14 or newer, is needed (an older Android reads "update_os_version").',
|
|
327
|
+
"The driver was created inside the test body: the SDK decides per test, at its start, from the drivers that already exist. Create it in a fixture or setup hook.",
|
|
328
|
+
"The test only read the screen (find_elements, page_source): scans run on interaction commands such as click/tap, send keys, actions and navigation. Interact with each screen you want scanned.",
|
|
329
|
+
...(report.status === "timeout"
|
|
330
|
+
? []
|
|
331
|
+
: [
|
|
332
|
+
'The session timed out during initialization (status "timeout"): check the script and the config.',
|
|
333
|
+
]),
|
|
334
|
+
];
|
|
335
|
+
return [
|
|
336
|
+
`No accessibility scans were recorded for the App Accessibility build below (status: ${report.status}), so there are no issues to list.`,
|
|
337
|
+
appBuildDetails(report),
|
|
338
|
+
"The usual causes, most likely first:",
|
|
339
|
+
...causes.map((cause, i) => `${i + 1}. ${cause}`),
|
|
340
|
+
];
|
|
341
|
+
}
|
|
342
|
+
const SEVERITY_ORDER = ["critical", "serious", "moderate", "minor"];
|
|
343
|
+
/** How many screens the summary names; the rest are counted. */
|
|
344
|
+
const SUMMARY_SCREENS = 10;
|
|
345
|
+
/** An app report's issues at a glance: by severity, by rule, and by screen. */
|
|
346
|
+
function summaryNote(summary, total) {
|
|
347
|
+
if (total === 0)
|
|
348
|
+
return [];
|
|
349
|
+
const rank = (severity) => {
|
|
350
|
+
const i = SEVERITY_ORDER.indexOf(severity);
|
|
351
|
+
return i === -1 ? SEVERITY_ORDER.length : i;
|
|
352
|
+
};
|
|
353
|
+
const severities = Object.entries(summary.by_severity)
|
|
354
|
+
.sort(([a], [b]) => rank(a) - rank(b))
|
|
355
|
+
.map(([severity, n]) => `${n} ${severity}`)
|
|
356
|
+
.join(", ");
|
|
357
|
+
const rules = summary.by_rule
|
|
358
|
+
.map((r) => `${r.issue_type} ${r.count}`)
|
|
359
|
+
.join(", ");
|
|
360
|
+
const shown = summary.by_screen.slice(0, SUMMARY_SCREENS);
|
|
361
|
+
const more = summary.by_screen.length - shown.length;
|
|
362
|
+
const screens = `${shown.map((s) => `${s.screen} ${s.count}`).join(", ")}${more > 0 ? `, and ${more} more screens` : ""}`;
|
|
363
|
+
return [
|
|
364
|
+
`Summary of the ${total} issues: ${severities}.`,
|
|
365
|
+
wrapUntrusted("accessibility issue counts", `By rule: ${rules}.\nBy screen: ${screens}.`),
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
/** How many new and resolved issues a comparison lists per page. */
|
|
369
|
+
const COMPARE_LIST_MAX = 50;
|
|
370
|
+
/** An issue as a comparison lists it: what and where, without the fix text. */
|
|
371
|
+
function compareRow(row) {
|
|
372
|
+
const { issue_type, rule_id, severity, screen, component, element } = row;
|
|
373
|
+
return { issue_type, rule_id, severity, screen, component, element };
|
|
374
|
+
}
|
|
375
|
+
/** One page of a comparison's list: the cursor counts the issues already listed. */
|
|
376
|
+
function issueList(label, rows, cursor) {
|
|
377
|
+
if (cursor > 0 && cursor >= rows.length) {
|
|
378
|
+
return `${label} (${rows.length}): all listed on earlier pages.`;
|
|
379
|
+
}
|
|
380
|
+
const shown = rows.slice(cursor, cursor + COMPARE_LIST_MAX).map(compareRow);
|
|
381
|
+
const range = rows.length > COMPARE_LIST_MAX
|
|
382
|
+
? `, ${cursor + 1} to ${cursor + shown.length}`
|
|
383
|
+
: "";
|
|
384
|
+
return `${label} (${rows.length})${range}: ${wrapUntrusted("accessibility scan results", JSON.stringify(shown, null, 2))}`;
|
|
385
|
+
}
|
|
386
|
+
/** The build to compare with, or why there is none. */
|
|
387
|
+
async function baselineAppBuild(reportFetcher, buildId, compareWith) {
|
|
388
|
+
let baseline;
|
|
389
|
+
if (compareWith === "previous") {
|
|
390
|
+
const previous = await reportFetcher.previousAppBuild(buildId);
|
|
391
|
+
if (!previous) {
|
|
392
|
+
return "This is its project's first build, so there is nothing earlier to compare it with. Pass an earlier build's id or link as compareWith instead.";
|
|
393
|
+
}
|
|
394
|
+
baseline = {
|
|
395
|
+
buildId: previous.buildId,
|
|
396
|
+
sameName: previous.same_name,
|
|
397
|
+
searchedAll: previous.searched_all,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
const named = /^https?:\/\//i.test(compareWith)
|
|
402
|
+
? await reportFetcher.sourceFromReportUrl(compareWith)
|
|
403
|
+
: { kind: "build", buildId: compareWith };
|
|
404
|
+
const source = await reportFetcher.resolveBuild(named);
|
|
405
|
+
if (source.kind !== "appBuild") {
|
|
406
|
+
return `compareWith names ${describeSource(source)}, which is not an App Accessibility build. Pass an earlier app build's id or link, or "previous".`;
|
|
407
|
+
}
|
|
408
|
+
baseline = { buildId: source.buildId };
|
|
409
|
+
}
|
|
410
|
+
if (baseline.buildId === buildId) {
|
|
411
|
+
return `compareWith names this build itself. Pass an earlier build's id or link, or "previous".`;
|
|
412
|
+
}
|
|
413
|
+
return baseline;
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* What changed between an earlier app build and this one: issues new in this build,
|
|
417
|
+
* issues the earlier one had that this one does not, and how many it kept. An issue on a
|
|
418
|
+
* screen this build did not reach also reads as resolved, so a build that scanned other
|
|
419
|
+
* screens or ran other tests is called out.
|
|
420
|
+
*/
|
|
421
|
+
async function compareAppBuilds(reportFetcher, source, compareWith, cursor, preface, processing) {
|
|
422
|
+
const baseline = await baselineAppBuild(reportFetcher, source.buildId, compareWith);
|
|
423
|
+
if (typeof baseline === "string")
|
|
424
|
+
return createErrorResponse(baseline);
|
|
425
|
+
const [named, current] = await Promise.all([
|
|
426
|
+
reportFetcher.appBuildIssues(baseline.buildId),
|
|
427
|
+
reportFetcher.appBuildIssues(source.buildId),
|
|
428
|
+
]);
|
|
429
|
+
// A comparison runs from the earlier build to the later; named the other way round, the
|
|
430
|
+
// new issues would read as resolved and the resolved as new.
|
|
431
|
+
const reversed = named.created_at !== undefined &&
|
|
432
|
+
current.created_at !== undefined &&
|
|
433
|
+
Date.parse(named.created_at) > Date.parse(current.created_at);
|
|
434
|
+
const [before, after] = reversed ? [current, named] : [named, current];
|
|
435
|
+
const { added, first_scanned, resolved, unconfirmed, retained } = compareAppIssues(before.issues, after.issues, {
|
|
436
|
+
sameScreens: before.scan_count === after.scan_count,
|
|
437
|
+
});
|
|
438
|
+
const screensOf = (rows) => new Set(rows.map((r) => r.screen)).size;
|
|
439
|
+
const unreached = screensOf(unconfirmed);
|
|
440
|
+
const unseen = screensOf(first_scanned);
|
|
441
|
+
const setApart = [
|
|
442
|
+
...(unreached > 0
|
|
443
|
+
? [
|
|
444
|
+
unreached === 1
|
|
445
|
+
? `${unconfirmed.length} more issues of the earlier build are on 1 screen no screen of the later build resembles: the later build did not reach it, or every issue on it was fixed. They are not counted as resolved; scan that screen again to confirm.`
|
|
446
|
+
: `${unconfirmed.length} more issues of the earlier build are on ${unreached} screens no screen of the later build resembles: the later build did not reach them, or every issue on them was fixed. They are not counted as resolved; scan those screens again to confirm.`,
|
|
447
|
+
]
|
|
448
|
+
: []),
|
|
449
|
+
...(unseen > 0
|
|
450
|
+
? [
|
|
451
|
+
`${first_scanned.length} more issues of the later build are on ${unseen === 1 ? "1 screen" : `${unseen} screens`} no screen of the earlier build resembles: not scanned before, so they are not counted as new.`,
|
|
452
|
+
]
|
|
453
|
+
: []),
|
|
454
|
+
];
|
|
455
|
+
const which = baseline.sameName === undefined
|
|
456
|
+
? ""
|
|
457
|
+
: baseline.sameName
|
|
458
|
+
? ", the build before it"
|
|
459
|
+
: baseline.searchedAll
|
|
460
|
+
? ", the build just before it in its project (no earlier build has this build's name)"
|
|
461
|
+
: ", the build just before it in its project (none of the newest builds before it has this build's name)";
|
|
462
|
+
const warnings = [];
|
|
463
|
+
if (before.scan_count !== after.scan_count) {
|
|
464
|
+
warnings.push(`⚠️ The builds scanned a different number of screens (${after.scan_count} in the later build, ${before.scan_count} in the earlier one): an issue on a screen the later build did not reach reads as resolved without having been fixed. Check the resolved issues' screens before calling them fixed.`);
|
|
465
|
+
}
|
|
466
|
+
const notRun = before.tests.filter((t) => !after.tests.includes(t));
|
|
467
|
+
if (notRun.length > 0) {
|
|
468
|
+
warnings.push(`⚠️ Some tests had screens scanned in the earlier build but not in the later one; their issues read as resolved without having been checked again. ${wrapUntrusted("test names", JSON.stringify(notRun))}`);
|
|
469
|
+
}
|
|
470
|
+
// Build names are the user's own (from their CI or browserstack.yml), so they are data.
|
|
471
|
+
const builds = wrapUntrusted("App Accessibility builds", JSON.stringify({
|
|
472
|
+
later: { build_number: after.build_number, name: after.build_name },
|
|
473
|
+
earlier: { build_number: before.build_number, name: before.build_name },
|
|
474
|
+
}, null, 2));
|
|
475
|
+
return createSuccessResponse([
|
|
476
|
+
...preface,
|
|
477
|
+
...processingNote(processing),
|
|
478
|
+
...(reversed
|
|
479
|
+
? [
|
|
480
|
+
"compareWith names a build newer than this one, so the comparison runs from this build (the earlier) to that one (the later).",
|
|
481
|
+
]
|
|
482
|
+
: []),
|
|
483
|
+
`Compared App Accessibility build #${after.build_number} with build #${before.build_number}${which}: ${added.length} new, ${resolved.length} resolved, ${retained} still open.`,
|
|
484
|
+
`Builds: ${builds}`,
|
|
485
|
+
...setApart,
|
|
486
|
+
setApart.length > 0
|
|
487
|
+
? `Issues are matched one for one by rule and element: its id, or else its class and its text or label; screens, by the elements on them. New, first-scanned and still-open issues add up to the later build's ${after.issues.length}; resolved, not-confirmed and still-open ones to the earlier build's ${before.issues.length}.`
|
|
488
|
+
: `Issues are matched one for one by rule and element: its id, or else its class and its text or label. New and still-open issues add up to the later build's ${after.issues.length}; resolved and still-open ones to the earlier build's ${before.issues.length}.`,
|
|
489
|
+
...warnings,
|
|
490
|
+
issueList("New issues", added, cursor),
|
|
491
|
+
issueList("Resolved issues", resolved, cursor),
|
|
492
|
+
...(unconfirmed.length > 0
|
|
493
|
+
? [issueList("Not confirmed resolved", unconfirmed, cursor)]
|
|
494
|
+
: []),
|
|
495
|
+
...(first_scanned.length > 0
|
|
496
|
+
? [
|
|
497
|
+
issueList("On screens first scanned in the later build", first_scanned, cursor),
|
|
498
|
+
]
|
|
499
|
+
: []),
|
|
500
|
+
Math.max(added.length, resolved.length, unconfirmed.length, first_scanned.length) >
|
|
501
|
+
cursor + COMPARE_LIST_MAX
|
|
502
|
+
? `More to list: call fetchAccessibilityIssues with buildId: "${source.buildId}", compareWith: "${compareWith}" and cursor: ${cursor + COMPARE_LIST_MAX}.`
|
|
503
|
+
: `Read the later build's open issues with fetchAccessibilityIssues with buildId: "${after.buildId}".`,
|
|
504
|
+
]);
|
|
505
|
+
}
|
|
506
|
+
/** How an app page's issues name what they share, which the page says once. */
|
|
507
|
+
const APP_ISSUES_SHAPE = "Each issue names its rule (rule_id) and screen: the rule's name, description, WCAG criteria and usual fix are under rules, each screen's screenshot under screens, and measured holds what the rule measured on the element.";
|
|
508
|
+
/** An app build's issues come inline from its scanned screens; there is no CSV to link. */
|
|
509
|
+
async function appBuildMessages(reportFetcher, source, cursor, preface, processing, stillProcessing) {
|
|
510
|
+
const report = await reportFetcher.appBuildReport(source.buildId, cursor);
|
|
511
|
+
if (report.scan_count === 0 && !stillProcessing) {
|
|
512
|
+
return [...preface, ...processingNote(processing), ...noScansNote(report)];
|
|
513
|
+
}
|
|
514
|
+
const { records, page_length, total_issues, next_page } = report;
|
|
515
|
+
const screens = `${report.scan_count} screen${report.scan_count === 1 ? "" : "s"} scanned`;
|
|
516
|
+
return [
|
|
517
|
+
...preface,
|
|
518
|
+
...processingNote(processing),
|
|
519
|
+
`Retrieved ${page_length} accessibility issues (Total: ${total_issues}) from the App Accessibility build below (${screens})`,
|
|
520
|
+
appBuildDetails(report),
|
|
521
|
+
...(cursor === 0 ? summaryNote(report.summary, total_issues) : []),
|
|
522
|
+
...(report.screens_read !== undefined
|
|
523
|
+
? [
|
|
524
|
+
`Only the first ${report.screens_read} of the build's ${report.scan_count} scanned screens were read; the total counts the issues on those. The dashboard report has them all.`,
|
|
525
|
+
]
|
|
526
|
+
: []),
|
|
527
|
+
...excludedIssuesNote(null, 0, report.hidden_issues),
|
|
528
|
+
APP_ISSUES_SHAPE,
|
|
529
|
+
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify({ issues: records, rules: report.rules, screens: report.screens }, null, 2))}`,
|
|
530
|
+
closingMessage(next_page, report.remaining, source, stillProcessing),
|
|
531
|
+
];
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* A manual report's issues come inline from its scanned screens: the dashboard's own, with
|
|
535
|
+
* its duplicates left out, when its route takes the access key; else every occurrence the
|
|
536
|
+
* Manual Reports API lists, which does not mark duplicates, so the dashboard's count is
|
|
537
|
+
* stated next to the total rather than reproduced.
|
|
538
|
+
*/
|
|
539
|
+
async function appReportMessages(reportFetcher, source, cursor, preface, processing, stillProcessing) {
|
|
540
|
+
const report = await reportFetcher.appReportIssues(source.reportId, cursor);
|
|
541
|
+
// The report's name is whatever its author typed, so it is quoted as data.
|
|
542
|
+
const details = `Report: ${wrapUntrusted("App Accessibility manual report", JSON.stringify({ name: report.report_name, dashboard_link: report.dashboard_link }, null, 2))}`;
|
|
543
|
+
if (report.scan_count === 0 && !stillProcessing) {
|
|
544
|
+
return [
|
|
545
|
+
...preface,
|
|
546
|
+
...processingNote(processing),
|
|
547
|
+
`No screens were scanned in the App Accessibility manual report below (status: ${report.status}), so there are no issues to list.`,
|
|
548
|
+
details,
|
|
549
|
+
];
|
|
550
|
+
}
|
|
551
|
+
const { records, page_length, total_issues, next_page } = report;
|
|
552
|
+
const where = `${report.scan_count} screen${report.scan_count === 1 ? "" : "s"} scanned${report.device ? ` on ${report.device}` : ""}`;
|
|
553
|
+
const dashboardNote = report.duplicate_occurrences === 0
|
|
554
|
+
? []
|
|
555
|
+
: report.deduplicated
|
|
556
|
+
? [
|
|
557
|
+
`${report.duplicate_occurrences} duplicate occurrences are left out, as the dashboard leaves them out of its count.`,
|
|
558
|
+
]
|
|
559
|
+
: [
|
|
560
|
+
`The dashboard counts ${report.dashboard_count} issues for this report: ${report.duplicate_occurrences} of the occurrences are duplicates it folds, which the API does not mark.`,
|
|
561
|
+
];
|
|
562
|
+
const counted = report.deduplicated ? "issues" : "issue occurrences";
|
|
563
|
+
return [
|
|
564
|
+
...preface,
|
|
565
|
+
...processingNote(processing),
|
|
566
|
+
`Retrieved ${page_length} accessibility ${counted} (Total: ${total_issues}) from the App Accessibility manual report below (${where})`,
|
|
567
|
+
details,
|
|
568
|
+
...(cursor === 0 ? summaryNote(report.summary, total_issues) : []),
|
|
569
|
+
...dashboardNote,
|
|
570
|
+
...excludedIssuesNote(null, 0, report.hidden_issues),
|
|
571
|
+
APP_ISSUES_SHAPE,
|
|
572
|
+
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify({ issues: records, rules: report.rules, screens: report.screens }, null, 2))}`,
|
|
573
|
+
closingMessage(next_page, report.remaining, source, stillProcessing),
|
|
574
|
+
];
|
|
575
|
+
}
|
|
576
|
+
async function fetchAccessibilityIssues(requested, config, cursor = 0, context, { compareWith, pageUrl, exportTaskId, } = {}) {
|
|
219
577
|
const reportFetcher = await initializeReportFetcher(config);
|
|
220
|
-
const
|
|
578
|
+
const named = requested.kind === "link"
|
|
221
579
|
? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
|
|
222
580
|
: requested;
|
|
581
|
+
// A build id names a web build or a mobile app build; the APIs tell which.
|
|
582
|
+
const source = await reportFetcher.resolveBuild(named);
|
|
583
|
+
// Website Scanner runs and App Accessibility builds can be compared, each in its own way.
|
|
584
|
+
if (compareWith !== undefined && source.kind === "scan") {
|
|
585
|
+
return compareScanRuns(requested.kind === "link" ? requested : source, compareWith, config, context);
|
|
586
|
+
}
|
|
587
|
+
if (compareWith !== undefined && source.kind !== "appBuild") {
|
|
588
|
+
return createErrorResponse(`compareWith compares Website Scanner runs and App Accessibility builds; ${describeSource(source)} is neither.`);
|
|
589
|
+
}
|
|
223
590
|
const processing = await waitForProcessing(reportFetcher, source, cursor === 0, context);
|
|
224
591
|
const stillProcessing = processing.status !== undefined &&
|
|
225
592
|
PROCESSING_STATUSES.has(processing.status);
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
593
|
+
const preface = requested.kind === "link"
|
|
594
|
+
? [`Resolved the report link to ${describeSource(source)}.`]
|
|
595
|
+
: [];
|
|
596
|
+
if (source.kind === "appBuild" && compareWith !== undefined) {
|
|
597
|
+
return compareAppBuilds(reportFetcher, source, compareWith, cursor, preface, processing);
|
|
598
|
+
}
|
|
599
|
+
if (source.kind === "appBuild") {
|
|
600
|
+
return createSuccessResponse(await appBuildMessages(reportFetcher, source, cursor, preface, processing, stillProcessing));
|
|
601
|
+
}
|
|
602
|
+
if (source.kind === "appReport") {
|
|
603
|
+
return createSuccessResponse(await appReportMessages(reportFetcher, source, cursor, preface, processing, stillProcessing));
|
|
604
|
+
}
|
|
605
|
+
const pageArg = pageUrl ? `, pageUrl: "${pageUrl}"` : "";
|
|
606
|
+
let reportLink;
|
|
607
|
+
try {
|
|
608
|
+
reportLink = await reportFetcher.getReportLink(source, {
|
|
609
|
+
exportTaskId,
|
|
610
|
+
onWait: exportProgress(context),
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (error instanceof ExportPendingError) {
|
|
615
|
+
return createSuccessResponse(exportPendingMessages(source, error, pageArg));
|
|
616
|
+
}
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
const { records, page_length, total_issues, next_page, remaining, duplicate_issues, unlinked_duplicates, hidden_issues, page, } = await parseAccessibilityReportFromCSV(reportLink, {
|
|
620
|
+
nextPage: cursor,
|
|
621
|
+
pageUrl,
|
|
622
|
+
});
|
|
623
|
+
return createSuccessResponse([
|
|
624
|
+
...preface,
|
|
237
625
|
...processingNote(processing),
|
|
626
|
+
...(page ? pageNote(page) : []),
|
|
238
627
|
`Retrieved ${page_length} accessibility issues (Total: ${total_issues})`,
|
|
239
628
|
...excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
|
|
240
629
|
`You can also download the full report (CSV) from the following link, valid for about 12 hours: ${reportLink}`,
|
|
241
630
|
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
|
|
631
|
+
closingMessage(next_page, remaining, source, stillProcessing, pageArg),
|
|
632
|
+
]);
|
|
633
|
+
}
|
|
634
|
+
/** What one page of a report holds, in the dashboard's page-view terms. */
|
|
635
|
+
function pageNote(page) {
|
|
636
|
+
if (page.occurrences === 0) {
|
|
637
|
+
const similar = page.similar_pages?.length
|
|
638
|
+
? ` Pages in this report with a similar address: ${wrapUntrusted("report page addresses", JSON.stringify(page.similar_pages))}`
|
|
639
|
+
: "";
|
|
640
|
+
return [`No issue in this report is on ${page.url}.${similar}`];
|
|
641
|
+
}
|
|
642
|
+
const severities = Object.entries(page.by_severity)
|
|
643
|
+
.sort(([, a], [, b]) => b - a)
|
|
644
|
+
.map(([severity, n]) => `${n} ${severity}`)
|
|
645
|
+
.join(", ");
|
|
646
|
+
return [
|
|
647
|
+
`${page.occurrences} issue occurrences on ${page.url} (${severities}), as the dashboard's page view counts them: ${page.distinct_issues} distinct issues, ${page.first_found_here} of them first found on this page. Each issue below is listed once, with occurrences_on_page; first_found_on names the page an issue was first found on when it is another one.`,
|
|
242
648
|
];
|
|
243
|
-
|
|
244
|
-
|
|
649
|
+
}
|
|
650
|
+
/** At most this many new and resolved issues are listed in a comparison. */
|
|
651
|
+
const COMPARE_LIST_LIMIT = 50;
|
|
652
|
+
/**
|
|
653
|
+
* A Website Scanner run against an earlier run of the same scan: the run before it
|
|
654
|
+
* ("previous") or a given scanRunId. Issues are matched by rule, element and page; against
|
|
655
|
+
* the previous run, the dashboard's own counts (the run's changesSinceLastRun) are given too.
|
|
656
|
+
*/
|
|
657
|
+
async function compareScanRuns(requested, compareWith, config, context) {
|
|
658
|
+
const reportFetcher = await initializeReportFetcher(config);
|
|
659
|
+
const source = requested.kind === "link"
|
|
660
|
+
? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
|
|
661
|
+
: requested;
|
|
662
|
+
if (source.kind !== "scan") {
|
|
663
|
+
return createErrorResponse('compareWith compares Website Scanner runs: pass the newer run (scanId and scanRunId, or its report link) and compareWith: "previous" or the older run\'s scanRunId.');
|
|
245
664
|
}
|
|
246
|
-
|
|
247
|
-
|
|
665
|
+
const previous = await reportFetcher.previousScanRun(source.scanId, source.scanRunId);
|
|
666
|
+
const baseRunId = compareWith === "previous" ? previous && String(previous.id) : compareWith;
|
|
667
|
+
if (!baseRunId) {
|
|
668
|
+
return createErrorResponse(`Run ${source.scanRunId} is the first run of scan ${source.scanId}; there is no earlier run to compare it with.`);
|
|
248
669
|
}
|
|
249
|
-
|
|
250
|
-
|
|
670
|
+
const base = { ...source, scanRunId: baseRunId };
|
|
671
|
+
const againstPrevious = previous !== undefined && String(previous.id) === baseRunId;
|
|
672
|
+
let links;
|
|
673
|
+
try {
|
|
674
|
+
links = await Promise.all([
|
|
675
|
+
reportFetcher.getReportLink(source, { onWait: exportProgress(context) }),
|
|
676
|
+
reportFetcher.getReportLink(base, {}),
|
|
677
|
+
]);
|
|
251
678
|
}
|
|
252
|
-
|
|
679
|
+
catch (error) {
|
|
680
|
+
if (error instanceof ExportPendingError) {
|
|
681
|
+
return createSuccessResponse([
|
|
682
|
+
`The CSV exports of these runs are still being generated (waited ${formatDuration(error.waitedMs)}). Call fetchAccessibilityIssues again with the same arguments in about a minute; the exports continue where they are.`,
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
const [after, before] = await Promise.all(links.map(loadMainIssues));
|
|
688
|
+
const diff = compareIssues(before, after);
|
|
689
|
+
const changes = againstPrevious
|
|
690
|
+
? await reportFetcher.changesSinceLastRun(source.scanId, source.scanRunId)
|
|
691
|
+
: undefined;
|
|
692
|
+
const baseLabel = againstPrevious
|
|
693
|
+
? `the run before it, run ${baseRunId}${previous?.scanDate ? ` (${previous.scanDate})` : ""}`
|
|
694
|
+
: `run ${baseRunId}`;
|
|
695
|
+
const matched = `${diff.added.length} new, ${diff.resolved.length} resolved, ${diff.retained} still open`;
|
|
696
|
+
const listed = (issues) => wrapUntrusted("accessibility comparison", JSON.stringify(issues.slice(0, COMPARE_LIST_LIMIT), null, 2)) +
|
|
697
|
+
(issues.length > COMPARE_LIST_LIMIT
|
|
698
|
+
? `\n(${issues.length - COMPARE_LIST_LIMIT} more not listed; see the CSV links.)`
|
|
699
|
+
: "");
|
|
700
|
+
return createSuccessResponse([
|
|
701
|
+
...(requested.kind === "link"
|
|
702
|
+
? [`Resolved the report link to ${describeSource(source)}.`]
|
|
703
|
+
: []),
|
|
704
|
+
`Compared Website Scanner scan ${source.scanId}: run ${source.scanRunId} with ${baseLabel}.`,
|
|
705
|
+
changes
|
|
706
|
+
? `The dashboard's comparison: ${changes.newIssues} new, ${changes.resolvedIssues} resolved, ${changes.retainedIssues} still open. Matching the issues by rule, element and page gives ${matched}.`
|
|
707
|
+
: `Matching the issues by rule, element and page: ${matched}.`,
|
|
708
|
+
`New issues: ${listed(diff.added)}`,
|
|
709
|
+
`Resolved issues: ${listed(diff.resolved)}`,
|
|
710
|
+
`CSV of each run, valid for about 12 hours: newer ${links[0]} , older ${links[1]}`,
|
|
711
|
+
]);
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* What App Accessibility has on record, newest first, with the ids the issues are read by.
|
|
715
|
+
* Without a project name, the automated listing shows the projects: a build list needs one.
|
|
716
|
+
*/
|
|
717
|
+
async function listAppAccessibilityReports(args, config) {
|
|
718
|
+
const reportFetcher = await initializeReportFetcher(config);
|
|
719
|
+
const limit = args.limit ?? 10;
|
|
720
|
+
const filtered = (what) => args.name ? ` whose ${what} contains "${args.name}"` : "";
|
|
721
|
+
if (args.kind === "manual") {
|
|
722
|
+
const { total, reports } = await reportFetcher.listAppManualReports(args.name, limit);
|
|
723
|
+
if (reports.length === 0) {
|
|
724
|
+
return createSuccessResponse([
|
|
725
|
+
`No App Accessibility manual reports${filtered("name")}. Drop the name filter to list the newest reports.`,
|
|
726
|
+
]);
|
|
727
|
+
}
|
|
728
|
+
return createSuccessResponse([
|
|
729
|
+
`${total} App Accessibility manual reports (newest first)${filtered("name")}${total > reports.length ? `, showing ${reports.length}` : ""}:`,
|
|
730
|
+
wrapUntrusted("app accessibility reports", JSON.stringify(reports, null, 2)),
|
|
731
|
+
`Read a report's issues with fetchAccessibilityIssues with appReportId: <reportId>; its dashboard_link opens it on the dashboard.`,
|
|
732
|
+
]);
|
|
733
|
+
}
|
|
734
|
+
if (!args.projectName) {
|
|
735
|
+
const { total, projects } = await reportFetcher.listAppProjects(limit);
|
|
736
|
+
return createSuccessResponse([
|
|
737
|
+
`${total} App Accessibility projects (newest first)${total > projects.length ? `, showing ${projects.length}` : ""}:`,
|
|
738
|
+
wrapUntrusted("app accessibility projects", JSON.stringify(projects, null, 2)),
|
|
739
|
+
`A build list needs a project: call listAppAccessibilityReports again with projectName: <name> (exact) to list its builds.`,
|
|
740
|
+
]);
|
|
741
|
+
}
|
|
742
|
+
const { project, total, builds } = await reportFetcher.listAppBuilds(args.projectName, args.name, limit);
|
|
743
|
+
if (builds.length === 0) {
|
|
744
|
+
return createSuccessResponse([
|
|
745
|
+
`No builds of App Accessibility project "${project.name}"${filtered("name")}${args.name
|
|
746
|
+
? ". Drop the name filter to list the project's newest builds"
|
|
747
|
+
: ": nothing has run with accessibility on in it yet"}.`,
|
|
748
|
+
]);
|
|
749
|
+
}
|
|
750
|
+
return createSuccessResponse([
|
|
751
|
+
`${total} builds of App Accessibility project "${project.name}" (newest first)${filtered("name")}${total > builds.length ? `, showing ${builds.length}` : ""}:`,
|
|
752
|
+
wrapUntrusted("app accessibility builds", JSON.stringify(builds, null, 2)),
|
|
753
|
+
`Read a build's issues with fetchAccessibilityIssues with buildId: <buildId>; it waits while a build is still processing. report_status "failed" with scan_count 0 means the run recorded no scans.`,
|
|
754
|
+
]);
|
|
755
|
+
}
|
|
756
|
+
async function executeListAppAccessibilityReports(args, server, config) {
|
|
757
|
+
try {
|
|
758
|
+
trackMCP("listAppAccessibilityReports", server.server.getClientVersion(), undefined, config);
|
|
759
|
+
return await listAppAccessibilityReports(args, config);
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
return handleMCPError("listAppAccessibilityReports", server, config, error);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
/** The WCAG versions and levels Website Scanner checks against, and RGAA 4.1, by name. */
|
|
766
|
+
const WCAG_VERSIONS = {
|
|
767
|
+
wcag2a: "WCAG 2.0 A",
|
|
768
|
+
wcag2aa: "WCAG 2.0 AA",
|
|
769
|
+
wcag2aaa: "WCAG 2.0 AAA",
|
|
770
|
+
wcag21a: "WCAG 2.1 A",
|
|
771
|
+
wcag21aa: "WCAG 2.1 AA",
|
|
772
|
+
wcag21aaa: "WCAG 2.1 AAA",
|
|
773
|
+
wcag22a: "WCAG 2.2 A",
|
|
774
|
+
wcag22aa: "WCAG 2.2 AA",
|
|
775
|
+
wcag22aaa: "WCAG 2.2 AAA",
|
|
776
|
+
rgaa41: "RGAA 4.1",
|
|
777
|
+
};
|
|
778
|
+
/** What the create-scan API uses for a setting the request leaves out. */
|
|
779
|
+
const SCAN_DEFAULTS = {
|
|
780
|
+
wcagVersion: "wcag21aa",
|
|
781
|
+
bestPractices: false,
|
|
782
|
+
needsReview: true,
|
|
783
|
+
advancedRules: true,
|
|
784
|
+
};
|
|
785
|
+
/** The settings a scan ran with, each marked when it was the API's default. */
|
|
786
|
+
function scanSettingsNote(settings) {
|
|
787
|
+
const pick = (key) => settings[key] === undefined
|
|
788
|
+
? { value: SCAN_DEFAULTS[key], note: " (default)" }
|
|
789
|
+
: { value: settings[key], note: "" };
|
|
790
|
+
const wcag = pick("wcagVersion");
|
|
791
|
+
const onOff = (key) => {
|
|
792
|
+
const { value, note } = pick(key);
|
|
793
|
+
return `${value ? "on" : "off"}${note}`;
|
|
794
|
+
};
|
|
795
|
+
const name = WCAG_VERSIONS[wcag.value] ?? wcag.value;
|
|
796
|
+
return `Scan settings: ${name}${wcag.note}, best practices ${onOff("bestPractices")}, needs review ${onOff("needsReview")}, advanced rules ${onOff("advancedRules")}.`;
|
|
253
797
|
}
|
|
254
798
|
async function executeAccessibilityScan(args, context, server, config) {
|
|
255
799
|
try {
|
|
256
800
|
trackMCP("startAccessibilityScan", server.server.getClientVersion(), undefined, config);
|
|
257
|
-
return await runAccessibilityScan(args.name, args.pageURL, context, config, args.authConfigId,
|
|
801
|
+
return await runAccessibilityScan(args.name, args.pageURL, context, config, args.authConfigId, {
|
|
802
|
+
wcagVersion: args.wcagVersion,
|
|
803
|
+
bestPractices: args.bestPractices,
|
|
804
|
+
needsReview: args.needsReview,
|
|
805
|
+
advancedRules: args.advancedRules,
|
|
806
|
+
});
|
|
258
807
|
}
|
|
259
808
|
catch (error) {
|
|
260
809
|
return handleMCPError("startAccessibilityScan", server, config, error);
|
|
@@ -331,10 +880,11 @@ async function executeGetAuthConfig(args, server, config) {
|
|
|
331
880
|
function createScanFailureResponse(name, status) {
|
|
332
881
|
return createErrorResponse(`❌ Accessibility scan "${name}" failed with status: ${status} , check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`);
|
|
333
882
|
}
|
|
334
|
-
function createScanSuccessResponse(name, totalIssues, pageLength, records, scanId, scanRunId, reportUrl, cursor, duplicates = []) {
|
|
883
|
+
function createScanSuccessResponse(name, totalIssues, pageLength, records, scanId, scanRunId, reportUrl, cursor, duplicates = [], settings = {}) {
|
|
335
884
|
const messages = [
|
|
336
885
|
`Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`,
|
|
337
886
|
`Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`,
|
|
887
|
+
scanSettingsNote(settings),
|
|
338
888
|
`You can also download the full report from the following link: ${reportUrl}`,
|
|
339
889
|
`We found ${totalIssues} issues. Below are the details of the ${pageLength} most critical issues.`,
|
|
340
890
|
...duplicates,
|
|
@@ -345,9 +895,9 @@ function createScanSuccessResponse(name, totalIssues, pageLength, records, scanI
|
|
|
345
895
|
}
|
|
346
896
|
return createSuccessResponse(messages);
|
|
347
897
|
}
|
|
348
|
-
async function runAccessibilityScan(name, pageURL, context, config, authConfigId,
|
|
898
|
+
async function runAccessibilityScan(name, pageURL, context, config, authConfigId, settings = {}) {
|
|
349
899
|
const scanner = await initializeScanner(config);
|
|
350
|
-
const startResp = await scanner.startScan(name, [pageURL], authConfigId,
|
|
900
|
+
const startResp = await scanner.startScan(name, [pageURL], authConfigId, settings);
|
|
351
901
|
const scanId = startResp.data.id;
|
|
352
902
|
const scanRunId = startResp.data.scanRunId;
|
|
353
903
|
await notifyScanProgress(context, `Accessibility scan "${name}" started`, 0);
|
|
@@ -356,32 +906,33 @@ async function runAccessibilityScan(name, pageURL, context, config, authConfigId
|
|
|
356
906
|
return createScanFailureResponse(name, status);
|
|
357
907
|
}
|
|
358
908
|
const reportFetcher = await initializeReportFetcher(config);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
scanId,
|
|
362
|
-
|
|
363
|
-
|
|
909
|
+
let reportLink;
|
|
910
|
+
try {
|
|
911
|
+
reportLink = await reportFetcher.getReportLink({ kind: "scan", scanId, scanRunId }, { onWait: exportProgress(context) });
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
if (error instanceof ExportPendingError) {
|
|
915
|
+
return createSuccessResponse([
|
|
916
|
+
`Accessibility scan "${name}" completed. Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`,
|
|
917
|
+
...exportPendingMessages({
|
|
918
|
+
kind: "scan",
|
|
919
|
+
scanId: String(scanId),
|
|
920
|
+
scanRunId: String(scanRunId),
|
|
921
|
+
}, error, ""),
|
|
922
|
+
]);
|
|
923
|
+
}
|
|
924
|
+
throw error;
|
|
925
|
+
}
|
|
364
926
|
const { records, page_length, total_issues, next_page, duplicate_issues, unlinked_duplicates, hidden_issues, } = await parseAccessibilityReportFromCSV(reportLink);
|
|
365
|
-
return createScanSuccessResponse(name, total_issues, page_length, records, scanId, scanRunId, reportLink, next_page, excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues));
|
|
927
|
+
return createScanSuccessResponse(name, total_issues, page_length, records, scanId, scanRunId, reportLink, next_page, excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues), settings);
|
|
366
928
|
}
|
|
367
929
|
export default function addAccessibilityTools(server, config) {
|
|
368
930
|
const tools = {};
|
|
369
|
-
tools.
|
|
370
|
-
query: z
|
|
371
|
-
.string()
|
|
372
|
-
.describe("Any accessibility, a11y, WCAG, or web accessibility question"),
|
|
373
|
-
}, {
|
|
374
|
-
title: "Accessibility Expert",
|
|
375
|
-
readOnlyHint: true,
|
|
376
|
-
openWorldHint: false,
|
|
377
|
-
destructiveHint: false,
|
|
378
|
-
idempotentHint: true,
|
|
379
|
-
}, async (args) => {
|
|
380
|
-
return await executeAccessibilityRAG(args, server, config);
|
|
381
|
-
});
|
|
382
|
-
tools.startAccessibilityScan = server.tool("startAccessibilityScan", "Start an accessibility scan via BrowserStack and retrieve a local CSV report path.", {
|
|
931
|
+
tools.startAccessibilityScan = server.tool("startAccessibilityScan", "Scan a web page URL for accessibility issues and get a CSV report. For a mobile app, run it on App Automate with accessibility on (setupBrowserStackAppAutomateTests), then fetchAccessibilityIssues.", {
|
|
383
932
|
name: z.string().describe("Name of the accessibility scan"),
|
|
384
|
-
pageURL: z
|
|
933
|
+
pageURL: z
|
|
934
|
+
.string()
|
|
935
|
+
.describe("The web page URL to scan for accessibility issues"),
|
|
385
936
|
authConfigId: z
|
|
386
937
|
.number()
|
|
387
938
|
.optional()
|
|
@@ -389,7 +940,19 @@ export default function addAccessibilityTools(server, config) {
|
|
|
389
940
|
advancedRules: z
|
|
390
941
|
.boolean()
|
|
391
942
|
.optional()
|
|
392
|
-
.describe("
|
|
943
|
+
.describe("Run the advanced accessibility rules (default true)."),
|
|
944
|
+
wcagVersion: z
|
|
945
|
+
.enum(Object.keys(WCAG_VERSIONS))
|
|
946
|
+
.optional()
|
|
947
|
+
.describe('WCAG version and level to check against, e.g. "wcag22aa", or "rgaa41" for RGAA 4.1 (default "wcag21aa").'),
|
|
948
|
+
bestPractices: z
|
|
949
|
+
.boolean()
|
|
950
|
+
.optional()
|
|
951
|
+
.describe("Also report best-practice issues (default false)."),
|
|
952
|
+
needsReview: z
|
|
953
|
+
.boolean()
|
|
954
|
+
.optional()
|
|
955
|
+
.describe('Also report "needs review" issues to check by hand (default true).'),
|
|
393
956
|
}, {
|
|
394
957
|
title: "Start Accessibility Scan",
|
|
395
958
|
readOnlyHint: false,
|
|
@@ -472,7 +1035,7 @@ export default function addAccessibilityTools(server, config) {
|
|
|
472
1035
|
}, async (args) => {
|
|
473
1036
|
return await executeGetAuthConfig(args, server, config);
|
|
474
1037
|
});
|
|
475
|
-
tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues
|
|
1038
|
+
tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from one source: scanId+scanRunId, buildId, workflowReportId, appReportId or reportUrl." +
|
|
476
1039
|
NEEDS_A11Y_SCAN_ID, {
|
|
477
1040
|
scanId: z
|
|
478
1041
|
.string()
|
|
@@ -486,19 +1049,36 @@ export default function addAccessibilityTools(server, config) {
|
|
|
486
1049
|
.string()
|
|
487
1050
|
.regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits")
|
|
488
1051
|
.optional()
|
|
489
|
-
.describe("Automated Tests
|
|
1052
|
+
.describe("Automated Tests or App Accessibility build id: the SDK's testhub id, or the thBuildId in a web report link."),
|
|
490
1053
|
workflowReportId: z
|
|
491
1054
|
.string()
|
|
492
1055
|
.optional()
|
|
493
1056
|
.describe("Workflow Analyzer: the report's numeric id."),
|
|
1057
|
+
appReportId: z
|
|
1058
|
+
.string()
|
|
1059
|
+
.regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, "A manual report id is a UUID")
|
|
1060
|
+
.optional()
|
|
1061
|
+
.describe("Manual App Accessibility report id (a UUID), from listAppAccessibilityReports or its /report/ link."),
|
|
494
1062
|
reportUrl: z
|
|
495
1063
|
.string()
|
|
496
1064
|
.optional()
|
|
497
|
-
.describe("A
|
|
1065
|
+
.describe("A web or app report link (dashboard, scanner share link, manual report), or an Automate or App Automate link."),
|
|
498
1066
|
cursor: z
|
|
499
1067
|
.number()
|
|
500
1068
|
.optional()
|
|
501
|
-
.describe("Character offset for pagination (default: 0)"),
|
|
1069
|
+
.describe("Character offset for pagination (default: 0); with compareWith, how many issues are already listed."),
|
|
1070
|
+
pageUrl: z
|
|
1071
|
+
.string()
|
|
1072
|
+
.optional()
|
|
1073
|
+
.describe("Only the issues on this page URL, counted as the dashboard's page view counts them."),
|
|
1074
|
+
compareWith: z
|
|
1075
|
+
.string()
|
|
1076
|
+
.optional()
|
|
1077
|
+
.describe(`"previous", an older scanRunId (Website Scanner), or an earlier app build's id or link; lists new and resolved issues.`),
|
|
1078
|
+
exportTaskId: z
|
|
1079
|
+
.string()
|
|
1080
|
+
.optional()
|
|
1081
|
+
.describe("Continue a CSV export that an earlier call said is still being generated."),
|
|
502
1082
|
}, {
|
|
503
1083
|
title: "Fetch Accessibility Issues",
|
|
504
1084
|
readOnlyHint: true,
|
|
@@ -508,5 +1088,36 @@ export default function addAccessibilityTools(server, config) {
|
|
|
508
1088
|
}, async (args, context) => {
|
|
509
1089
|
return await executeFetchAccessibilityIssues(args, context, server, config);
|
|
510
1090
|
});
|
|
1091
|
+
tools.listAppAccessibilityReports = server.tool("listAppAccessibilityReports", "List App Accessibility work newest first, with the ids fetchAccessibilityIssues reads: a project's builds (automated; omit projectName to list projects) or manual reports.", {
|
|
1092
|
+
kind: z
|
|
1093
|
+
.enum(["automated", "manual"])
|
|
1094
|
+
.optional()
|
|
1095
|
+
.default("automated")
|
|
1096
|
+
.describe("automated (App Automate builds, default) or manual (Workflow Analyzer reports)."),
|
|
1097
|
+
projectName: z
|
|
1098
|
+
.string()
|
|
1099
|
+
.optional()
|
|
1100
|
+
.describe("Automated only: the exact App Accessibility project name; omit to list the projects."),
|
|
1101
|
+
name: z
|
|
1102
|
+
.string()
|
|
1103
|
+
.optional()
|
|
1104
|
+
.describe("Partial build or report name to filter by."),
|
|
1105
|
+
limit: z
|
|
1106
|
+
.number()
|
|
1107
|
+
.int()
|
|
1108
|
+
.min(1)
|
|
1109
|
+
.max(30)
|
|
1110
|
+
.optional()
|
|
1111
|
+
.default(10)
|
|
1112
|
+
.describe("Rows to return, newest first (1 to 30, default 10)."),
|
|
1113
|
+
}, {
|
|
1114
|
+
title: "List App Accessibility Reports",
|
|
1115
|
+
readOnlyHint: true,
|
|
1116
|
+
openWorldHint: false,
|
|
1117
|
+
destructiveHint: false,
|
|
1118
|
+
idempotentHint: true,
|
|
1119
|
+
}, async (args) => {
|
|
1120
|
+
return await executeListAppAccessibilityReports(args, server, config);
|
|
1121
|
+
});
|
|
511
1122
|
return tools;
|
|
512
1123
|
}
|