@browserstack/mcp-server 2.2.6-beta.2 → 2.2.6
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/dist/server-factory.js +1 -6
- package/dist/tools/accessibility.js +69 -680
- package/dist/tools/accessiblity-utils/accessibility-rag.d.ts +12 -0
- package/dist/tools/accessiblity-utils/accessibility-rag.js +59 -0
- package/dist/tools/accessiblity-utils/report-fetcher.d.ts +7 -116
- package/dist/tools/accessiblity-utils/report-fetcher.js +29 -268
- package/dist/tools/accessiblity-utils/report-parser.d.ts +1 -41
- package/dist/tools/accessiblity-utils/report-parser.js +43 -142
- package/dist/tools/accessiblity-utils/scanner.d.ts +1 -15
- package/dist/tools/accessiblity-utils/scanner.js +4 -3
- package/dist/tools/appautomate-utils/appium-sdk/config-generator.d.ts +0 -2
- package/dist/tools/appautomate-utils/appium-sdk/config-generator.js +5 -15
- package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +1 -2
- package/dist/tools/appautomate-utils/appium-sdk/constants.js +1 -6
- package/dist/tools/appautomate-utils/appium-sdk/handler.js +1 -12
- package/dist/tools/appautomate-utils/appium-sdk/instructions.d.ts +1 -3
- package/dist/tools/appautomate-utils/appium-sdk/instructions.js +2 -2
- package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.d.ts +1 -3
- package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.js +6 -19
- package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +2 -7
- package/dist/tools/appautomate-utils/native-execution/appautomate.js +12 -15
- package/dist/tools/appautomate-utils/native-execution/constants.d.ts +1 -2
- package/dist/tools/appautomate-utils/native-execution/constants.js +1 -6
- package/dist/tools/appautomate.js +16 -29
- 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 +2 -3
- package/dist/tools/tool-handoff.js +2 -3
- package/package.json +1 -1
- package/dist/lib/accessibility-routing.d.ts +0 -6
- package/dist/lib/accessibility-routing.js +0 -6
- package/dist/tools/accessiblity-utils/app-report-fetcher.d.ts +0 -412
- package/dist/tools/accessiblity-utils/app-report-fetcher.js +0 -926
- package/dist/tools/accessiblity-utils/paginate.d.ts +0 -27
- package/dist/tools/accessiblity-utils/paginate.js +0 -50
- package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.d.ts +0 -4
- package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.js +0 -47
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { AccessibilityScanner
|
|
3
|
-
import { AccessibilityReportFetcher,
|
|
4
|
-
import { compareAppIssues, } from "./accessiblity-utils/app-report-fetcher.js";
|
|
2
|
+
import { AccessibilityScanner } from "./accessiblity-utils/scanner.js";
|
|
3
|
+
import { AccessibilityReportFetcher, } from "./accessiblity-utils/report-fetcher.js";
|
|
5
4
|
import { AccessibilityAuthConfig, safeAuthConfigData, } from "./accessiblity-utils/auth-config.js";
|
|
6
5
|
import { trackMCP } from "../lib/instrumentation.js";
|
|
7
|
-
import {
|
|
6
|
+
import { parseAccessibilityReportFromCSV } from "./accessiblity-utils/report-parser.js";
|
|
8
7
|
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,17 +66,25 @@ 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
|
+
}
|
|
69
78
|
/** The one source the arguments name, or why they do not name exactly one. */
|
|
70
79
|
function issueSourceFrom(args) {
|
|
71
80
|
const named = [
|
|
72
81
|
Boolean(args.scanId || args.scanRunId),
|
|
73
82
|
Boolean(args.buildId),
|
|
74
83
|
Boolean(args.workflowReportId),
|
|
75
|
-
Boolean(args.appReportId),
|
|
76
84
|
Boolean(args.reportUrl),
|
|
77
85
|
].filter(Boolean).length;
|
|
78
86
|
if (named !== 1) {
|
|
79
|
-
return "Pass exactly one source: scanId with scanRunId (a Website Scanner run), buildId (an Automated Tests
|
|
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).";
|
|
80
88
|
}
|
|
81
89
|
if (args.reportUrl)
|
|
82
90
|
return { kind: "link", reportUrl: args.reportUrl };
|
|
@@ -85,9 +93,6 @@ function issueSourceFrom(args) {
|
|
|
85
93
|
if (args.workflowReportId) {
|
|
86
94
|
return { kind: "workflow", reportId: args.workflowReportId };
|
|
87
95
|
}
|
|
88
|
-
if (args.appReportId) {
|
|
89
|
-
return { kind: "appReport", reportId: args.appReportId };
|
|
90
|
-
}
|
|
91
96
|
if (!args.scanId || !args.scanRunId) {
|
|
92
97
|
return "A Website Scanner run needs both scanId and scanRunId.";
|
|
93
98
|
}
|
|
@@ -99,12 +104,9 @@ function sourceArgs(source) {
|
|
|
99
104
|
case "scan":
|
|
100
105
|
return `scanId: "${source.scanId}", scanRunId: "${source.scanRunId}"`;
|
|
101
106
|
case "build":
|
|
102
|
-
case "appBuild":
|
|
103
107
|
return `buildId: "${source.buildId}"`;
|
|
104
108
|
case "workflow":
|
|
105
109
|
return `workflowReportId: "${source.reportId}"`;
|
|
106
|
-
case "appReport":
|
|
107
|
-
return `appReportId: "${source.reportId}"`;
|
|
108
110
|
}
|
|
109
111
|
}
|
|
110
112
|
/** What a dashboard link resolved to, in words. */
|
|
@@ -116,10 +118,6 @@ function describeSource(source) {
|
|
|
116
118
|
return `Automated Tests build ${source.buildId}`;
|
|
117
119
|
case "workflow":
|
|
118
120
|
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}`;
|
|
123
121
|
}
|
|
124
122
|
}
|
|
125
123
|
async function executeFetchAccessibilityIssues(args, context, server, config) {
|
|
@@ -128,11 +126,7 @@ async function executeFetchAccessibilityIssues(args, context, server, config) {
|
|
|
128
126
|
const source = issueSourceFrom(args);
|
|
129
127
|
if (typeof source === "string")
|
|
130
128
|
return createErrorResponse(source);
|
|
131
|
-
return await fetchAccessibilityIssues(source, config, args.cursor, context
|
|
132
|
-
compareWith: args.compareWith,
|
|
133
|
-
pageUrl: args.pageUrl,
|
|
134
|
-
exportTaskId: args.exportTaskId,
|
|
135
|
-
});
|
|
129
|
+
return await fetchAccessibilityIssues(source, config, args.cursor, context);
|
|
136
130
|
}
|
|
137
131
|
catch (error) {
|
|
138
132
|
return handleMCPError("fetchAccessibilityIssues", server, config, error);
|
|
@@ -161,21 +155,19 @@ function excludedIssuesNote(duplicates, unlinked, hidden) {
|
|
|
161
155
|
/** How often, and for how long, a first read waits on a report that is still processing. */
|
|
162
156
|
export const REPORT_STATUS_POLL_MS = 10_000;
|
|
163
157
|
export const REPORT_STATUS_WAIT_MS = 5 * 60 * 1000;
|
|
164
|
-
/** Statuses during which a report's issues are still being added
|
|
158
|
+
/** Statuses during which a report's issues are still being added. */
|
|
165
159
|
const PROCESSING_STATUSES = new Set([
|
|
166
160
|
"queued",
|
|
167
161
|
"running",
|
|
168
162
|
"processing",
|
|
169
163
|
"finalizing",
|
|
170
|
-
"rule_processing",
|
|
171
|
-
"in_progress",
|
|
172
164
|
]);
|
|
173
165
|
/**
|
|
174
|
-
* Of those, the
|
|
175
|
-
*
|
|
166
|
+
* Of those, the one worth waiting on: the advanced-issue processing after the run, which
|
|
167
|
+
* ends within minutes. A report whose scan or tests are still running is read as it is,
|
|
176
168
|
* with a note, since that can last as long as the suite does.
|
|
177
169
|
*/
|
|
178
|
-
const WAIT_STATUSES = new Set(["finalizing"
|
|
170
|
+
const WAIT_STATUSES = new Set(["finalizing"]);
|
|
179
171
|
/**
|
|
180
172
|
* Where a report is in its processing before its issues are read. Issues keep being added
|
|
181
173
|
* until it completes; an advanced scan adds its advanced-rule issues while "finalizing", for
|
|
@@ -223,587 +215,46 @@ function processingNote({ status, firstStatus, waitedMs, }) {
|
|
|
223
215
|
}
|
|
224
216
|
return [];
|
|
225
217
|
}
|
|
226
|
-
|
|
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, } = {}) {
|
|
218
|
+
async function fetchAccessibilityIssues(requested, config, cursor = 0, context) {
|
|
577
219
|
const reportFetcher = await initializeReportFetcher(config);
|
|
578
|
-
const
|
|
220
|
+
const source = requested.kind === "link"
|
|
579
221
|
? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
|
|
580
222
|
: 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
|
-
}
|
|
590
223
|
const processing = await waitForProcessing(reportFetcher, source, cursor === 0, context);
|
|
591
224
|
const stillProcessing = processing.status !== undefined &&
|
|
592
225
|
PROCESSING_STATUSES.has(processing.status);
|
|
593
|
-
const
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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,
|
|
226
|
+
const reportLink = await reportFetcher.getReportLink(source);
|
|
227
|
+
const { records, page_length, total_issues, next_page, duplicate_issues, unlinked_duplicates, hidden_issues, } = await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor });
|
|
228
|
+
const currentlyShown = cursor === 0
|
|
229
|
+
? page_length
|
|
230
|
+
: Math.floor(cursor / JSON.stringify(records[0] || {}).length) +
|
|
231
|
+
page_length;
|
|
232
|
+
const remainingIssues = total_issues - currentlyShown;
|
|
233
|
+
const messages = [
|
|
234
|
+
...(requested.kind === "link"
|
|
235
|
+
? [`Resolved the report link to ${describeSource(source)}.`]
|
|
236
|
+
: []),
|
|
625
237
|
...processingNote(processing),
|
|
626
|
-
...(page ? pageNote(page) : []),
|
|
627
238
|
`Retrieved ${page_length} accessibility issues (Total: ${total_issues})`,
|
|
628
239
|
...excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
|
|
629
240
|
`You can also download the full report (CSV) from the following link, valid for about 12 hours: ${reportLink}`,
|
|
630
241
|
`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.`,
|
|
648
242
|
];
|
|
649
|
-
|
|
650
|
-
|
|
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.');
|
|
664
|
-
}
|
|
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.`);
|
|
669
|
-
}
|
|
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
|
-
]);
|
|
678
|
-
}
|
|
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
|
-
]);
|
|
243
|
+
if (next_page !== null) {
|
|
244
|
+
messages.push(`${remainingIssues} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)} and cursor: ${next_page} to get the next batch.`);
|
|
749
245
|
}
|
|
750
|
-
|
|
751
|
-
|
|
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);
|
|
246
|
+
else if (stillProcessing) {
|
|
247
|
+
messages.push(`All issues found so far retrieved; the report is still processing.`);
|
|
760
248
|
}
|
|
761
|
-
|
|
762
|
-
|
|
249
|
+
else {
|
|
250
|
+
messages.push(`✅ All issues retrieved.`);
|
|
763
251
|
}
|
|
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")}.`;
|
|
252
|
+
return createSuccessResponse(messages);
|
|
797
253
|
}
|
|
798
254
|
async function executeAccessibilityScan(args, context, server, config) {
|
|
799
255
|
try {
|
|
800
256
|
trackMCP("startAccessibilityScan", server.server.getClientVersion(), undefined, config);
|
|
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
|
-
});
|
|
257
|
+
return await runAccessibilityScan(args.name, args.pageURL, context, config, args.authConfigId, args.advancedRules);
|
|
807
258
|
}
|
|
808
259
|
catch (error) {
|
|
809
260
|
return handleMCPError("startAccessibilityScan", server, config, error);
|
|
@@ -880,11 +331,10 @@ async function executeGetAuthConfig(args, server, config) {
|
|
|
880
331
|
function createScanFailureResponse(name, status) {
|
|
881
332
|
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}].`);
|
|
882
333
|
}
|
|
883
|
-
function createScanSuccessResponse(name, totalIssues, pageLength, records, scanId, scanRunId, reportUrl, cursor, duplicates = []
|
|
334
|
+
function createScanSuccessResponse(name, totalIssues, pageLength, records, scanId, scanRunId, reportUrl, cursor, duplicates = []) {
|
|
884
335
|
const messages = [
|
|
885
336
|
`Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`,
|
|
886
337
|
`Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`,
|
|
887
|
-
scanSettingsNote(settings),
|
|
888
338
|
`You can also download the full report from the following link: ${reportUrl}`,
|
|
889
339
|
`We found ${totalIssues} issues. Below are the details of the ${pageLength} most critical issues.`,
|
|
890
340
|
...duplicates,
|
|
@@ -895,9 +345,9 @@ function createScanSuccessResponse(name, totalIssues, pageLength, records, scanI
|
|
|
895
345
|
}
|
|
896
346
|
return createSuccessResponse(messages);
|
|
897
347
|
}
|
|
898
|
-
async function runAccessibilityScan(name, pageURL, context, config, authConfigId,
|
|
348
|
+
async function runAccessibilityScan(name, pageURL, context, config, authConfigId, advancedRules) {
|
|
899
349
|
const scanner = await initializeScanner(config);
|
|
900
|
-
const startResp = await scanner.startScan(name, [pageURL], authConfigId,
|
|
350
|
+
const startResp = await scanner.startScan(name, [pageURL], authConfigId, advancedRules);
|
|
901
351
|
const scanId = startResp.data.id;
|
|
902
352
|
const scanRunId = startResp.data.scanRunId;
|
|
903
353
|
await notifyScanProgress(context, `Accessibility scan "${name}" started`, 0);
|
|
@@ -906,33 +356,32 @@ async function runAccessibilityScan(name, pageURL, context, config, authConfigId
|
|
|
906
356
|
return createScanFailureResponse(name, status);
|
|
907
357
|
}
|
|
908
358
|
const reportFetcher = await initializeReportFetcher(config);
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
}
|
|
359
|
+
const reportLink = await reportFetcher.getReportLink({
|
|
360
|
+
kind: "scan",
|
|
361
|
+
scanId,
|
|
362
|
+
scanRunId,
|
|
363
|
+
});
|
|
926
364
|
const { records, page_length, total_issues, next_page, duplicate_issues, unlinked_duplicates, hidden_issues, } = await parseAccessibilityReportFromCSV(reportLink);
|
|
927
|
-
return createScanSuccessResponse(name, total_issues, page_length, records, scanId, scanRunId, reportLink, next_page, excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues)
|
|
365
|
+
return createScanSuccessResponse(name, total_issues, page_length, records, scanId, scanRunId, reportLink, next_page, excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues));
|
|
928
366
|
}
|
|
929
367
|
export default function addAccessibilityTools(server, config) {
|
|
930
368
|
const tools = {};
|
|
931
|
-
tools.
|
|
932
|
-
|
|
933
|
-
pageURL: z
|
|
369
|
+
tools.accessibilityExpert = server.tool("accessibilityExpert", "Answer accessibility, a11y, and WCAG questions using BrowserStack's accessibility knowledge base.", {
|
|
370
|
+
query: z
|
|
934
371
|
.string()
|
|
935
|
-
.describe("
|
|
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.", {
|
|
383
|
+
name: z.string().describe("Name of the accessibility scan"),
|
|
384
|
+
pageURL: z.string().describe("The URL to scan for accessibility issues"),
|
|
936
385
|
authConfigId: z
|
|
937
386
|
.number()
|
|
938
387
|
.optional()
|
|
@@ -940,19 +389,7 @@ export default function addAccessibilityTools(server, config) {
|
|
|
940
389
|
advancedRules: z
|
|
941
390
|
.boolean()
|
|
942
391
|
.optional()
|
|
943
|
-
.describe("
|
|
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).'),
|
|
392
|
+
.describe("Enable advanced accessibility rules in the scan settings"),
|
|
956
393
|
}, {
|
|
957
394
|
title: "Start Accessibility Scan",
|
|
958
395
|
readOnlyHint: false,
|
|
@@ -1035,7 +472,7 @@ export default function addAccessibilityTools(server, config) {
|
|
|
1035
472
|
}, async (args) => {
|
|
1036
473
|
return await executeGetAuthConfig(args, server, config);
|
|
1037
474
|
});
|
|
1038
|
-
tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from one source: scanId+scanRunId, buildId, workflowReportId
|
|
475
|
+
tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues (paged by cursor) from one source: scanId+scanRunId, buildId, workflowReportId or reportUrl." +
|
|
1039
476
|
NEEDS_A11Y_SCAN_ID, {
|
|
1040
477
|
scanId: z
|
|
1041
478
|
.string()
|
|
@@ -1049,36 +486,19 @@ export default function addAccessibilityTools(server, config) {
|
|
|
1049
486
|
.string()
|
|
1050
487
|
.regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits")
|
|
1051
488
|
.optional()
|
|
1052
|
-
.describe("Automated Tests
|
|
489
|
+
.describe("Automated Tests: the thBuildId in the build's accessibility report link (not the Automate build id)."),
|
|
1053
490
|
workflowReportId: z
|
|
1054
491
|
.string()
|
|
1055
492
|
.optional()
|
|
1056
493
|
.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."),
|
|
1062
494
|
reportUrl: z
|
|
1063
495
|
.string()
|
|
1064
496
|
.optional()
|
|
1065
|
-
.describe("A
|
|
497
|
+
.describe("A Website Scanner or Automated Tests report link from the Accessibility dashboard (carries thBuildId)."),
|
|
1066
498
|
cursor: z
|
|
1067
499
|
.number()
|
|
1068
500
|
.optional()
|
|
1069
|
-
.describe("Character offset for pagination (default: 0)
|
|
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."),
|
|
501
|
+
.describe("Character offset for pagination (default: 0)"),
|
|
1082
502
|
}, {
|
|
1083
503
|
title: "Fetch Accessibility Issues",
|
|
1084
504
|
readOnlyHint: true,
|
|
@@ -1088,36 +508,5 @@ export default function addAccessibilityTools(server, config) {
|
|
|
1088
508
|
}, async (args, context) => {
|
|
1089
509
|
return await executeFetchAccessibilityIssues(args, context, server, config);
|
|
1090
510
|
});
|
|
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
|
-
});
|
|
1122
511
|
return tools;
|
|
1123
512
|
}
|