@browserstack/mcp-server 2.2.5-beta.1 → 2.2.5-beta.3

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.
Files changed (36) hide show
  1. package/dist/lib/accessibility-routing.d.ts +5 -0
  2. package/dist/lib/accessibility-routing.js +5 -0
  3. package/dist/server-factory.js +6 -1
  4. package/dist/tools/accessibility.js +588 -67
  5. package/dist/tools/accessiblity-utils/app-report-fetcher.d.ts +341 -0
  6. package/dist/tools/accessiblity-utils/app-report-fetcher.js +731 -0
  7. package/dist/tools/accessiblity-utils/paginate.d.ts +21 -0
  8. package/dist/tools/accessiblity-utils/paginate.js +44 -0
  9. package/dist/tools/accessiblity-utils/report-fetcher.d.ts +116 -7
  10. package/dist/tools/accessiblity-utils/report-fetcher.js +273 -30
  11. package/dist/tools/accessiblity-utils/report-parser.d.ts +41 -1
  12. package/dist/tools/accessiblity-utils/report-parser.js +142 -43
  13. package/dist/tools/appautomate-utils/appium-sdk/config-generator.d.ts +2 -0
  14. package/dist/tools/appautomate-utils/appium-sdk/config-generator.js +15 -5
  15. package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +2 -1
  16. package/dist/tools/appautomate-utils/appium-sdk/constants.js +6 -1
  17. package/dist/tools/appautomate-utils/appium-sdk/handler.js +11 -1
  18. package/dist/tools/appautomate-utils/appium-sdk/instructions.d.ts +3 -1
  19. package/dist/tools/appautomate-utils/appium-sdk/instructions.js +2 -2
  20. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.d.ts +2 -0
  21. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.js +32 -0
  22. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.d.ts +3 -1
  23. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.js +19 -6
  24. package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +7 -2
  25. package/dist/tools/appautomate-utils/native-execution/appautomate.js +15 -12
  26. package/dist/tools/appautomate-utils/native-execution/constants.d.ts +2 -1
  27. package/dist/tools/appautomate-utils/native-execution/constants.js +6 -1
  28. package/dist/tools/appautomate.js +29 -16
  29. package/dist/tools/failurelogs-utils/app-automate.js +1 -1
  30. package/dist/tools/testmanagement-utils/create-testrun.d.ts +1 -1
  31. package/dist/tools/testmanagement-utils/update-testrun.d.ts +1 -1
  32. package/dist/tools/tool-handoff.d.ts +5 -4
  33. package/dist/tools/tool-handoff.js +5 -10
  34. package/package.json +1 -1
  35. package/dist/tools/accessiblity-utils/accessibility-rag.d.ts +0 -12
  36. package/dist/tools/accessiblity-utils/accessibility-rag.js +0 -59
@@ -1,11 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { AccessibilityScanner } from "./accessiblity-utils/scanner.js";
3
- import { AccessibilityReportFetcher, } from "./accessiblity-utils/report-fetcher.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,27 @@ 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
  ]);
173
+ /**
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,
176
+ * with a note, since that can last as long as the suite does.
177
+ */
178
+ const WAIT_STATUSES = new Set(["finalizing", "rule_processing"]);
165
179
  /**
166
180
  * Where a report is in its processing before its issues are read. Issues keep being added
167
- * until it completes (an advanced scan adds its advanced-rule issues while "finalizing", for
168
- * minutes after the tests end), so a first read waits for it, reporting progress, for up to
169
- * REPORT_STATUS_WAIT_MS. A later page does not wait again, and a status that cannot be read
170
- * is not waited on.
181
+ * until it completes; an advanced scan adds its advanced-rule issues while "finalizing", for
182
+ * minutes after the tests end, so a first read waits for that, reporting progress, for up to
183
+ * REPORT_STATUS_WAIT_MS. A run still in progress is not waited on (it can take as long as
184
+ * the suite), nor is a later page, nor a status that cannot be read.
171
185
  */
172
186
  async function waitForProcessing(reportFetcher, source, wait, context) {
173
187
  const started = Date.now();
@@ -175,7 +189,7 @@ async function waitForProcessing(reportFetcher, source, wait, context) {
175
189
  let status = firstStatus;
176
190
  while (wait &&
177
191
  status &&
178
- PROCESSING_STATUSES.has(status) &&
192
+ WAIT_STATUSES.has(status) &&
179
193
  Date.now() - started < REPORT_STATUS_WAIT_MS) {
180
194
  const waited = Date.now() - started;
181
195
  if (context) {
@@ -209,41 +223,498 @@ function processingNote({ status, firstStatus, waitedMs, }) {
209
223
  }
210
224
  return [];
211
225
  }
212
- async function fetchAccessibilityIssues(requested, config, cursor = 0, context) {
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, resolved, retained } = compareAppIssues(before.issues, after.issues);
436
+ const which = baseline.sameName === undefined
437
+ ? ""
438
+ : baseline.sameName
439
+ ? ", the build before it"
440
+ : baseline.searchedAll
441
+ ? ", the build just before it in its project (no earlier build has this build's name)"
442
+ : ", the build just before it in its project (none of the newest builds before it has this build's name)";
443
+ const warnings = [];
444
+ if (before.scan_count !== after.scan_count) {
445
+ 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.`);
446
+ }
447
+ const notRun = before.tests.filter((t) => !after.tests.includes(t));
448
+ if (notRun.length > 0) {
449
+ 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))}`);
450
+ }
451
+ // Build names are the user's own (from their CI or browserstack.yml), so they are data.
452
+ const builds = wrapUntrusted("App Accessibility builds", JSON.stringify({
453
+ later: { build_number: after.build_number, name: after.build_name },
454
+ earlier: { build_number: before.build_number, name: before.build_name },
455
+ }, null, 2));
456
+ return createSuccessResponse([
457
+ ...preface,
458
+ ...processingNote(processing),
459
+ ...(reversed
460
+ ? [
461
+ "compareWith names a build newer than this one, so the comparison runs from this build (the earlier) to that one (the later).",
462
+ ]
463
+ : []),
464
+ `Compared App Accessibility build #${after.build_number} with build #${before.build_number}${which}: ${added.length} new, ${resolved.length} resolved, ${retained} still open.`,
465
+ `Builds: ${builds}`,
466
+ `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}.`,
467
+ ...warnings,
468
+ issueList("New issues", added, cursor),
469
+ issueList("Resolved issues", resolved, cursor),
470
+ Math.max(added.length, resolved.length) > cursor + COMPARE_LIST_MAX
471
+ ? `More to list: call fetchAccessibilityIssues with buildId: "${source.buildId}", compareWith: "${compareWith}" and cursor: ${cursor + COMPARE_LIST_MAX}.`
472
+ : `Read the later build's open issues with fetchAccessibilityIssues with buildId: "${after.buildId}".`,
473
+ ]);
474
+ }
475
+ /** An app build's issues come inline from its scanned screens; there is no CSV to link. */
476
+ async function appBuildMessages(reportFetcher, source, cursor, preface, processing, stillProcessing) {
477
+ const report = await reportFetcher.appBuildReport(source.buildId, cursor);
478
+ if (report.scan_count === 0 && !stillProcessing) {
479
+ return [...preface, ...processingNote(processing), ...noScansNote(report)];
480
+ }
481
+ const { records, page_length, total_issues, next_page } = report;
482
+ const screens = `${report.scan_count} screen${report.scan_count === 1 ? "" : "s"} scanned`;
483
+ return [
484
+ ...preface,
485
+ ...processingNote(processing),
486
+ `Retrieved ${page_length} accessibility issues (Total: ${total_issues}) from the App Accessibility build below (${screens})`,
487
+ appBuildDetails(report),
488
+ ...(cursor === 0 ? summaryNote(report.summary, total_issues) : []),
489
+ ...excludedIssuesNote(null, 0, report.hidden_issues),
490
+ `Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
491
+ closingMessage(next_page, report.remaining, source, stillProcessing),
492
+ ];
493
+ }
494
+ /**
495
+ * A manual report's issues come inline from its scanned screens. The API lists every
496
+ * occurrence and does not mark the duplicates the dashboard folds, so the dashboard's
497
+ * own count is stated next to the total rather than reproduced.
498
+ */
499
+ async function appReportMessages(reportFetcher, source, cursor, preface, processing, stillProcessing) {
500
+ const report = await reportFetcher.appReportIssues(source.reportId, cursor);
501
+ // The report's name is whatever its author typed, so it is quoted as data.
502
+ const details = `Report: ${wrapUntrusted("App Accessibility manual report", JSON.stringify({ name: report.report_name, dashboard_link: report.dashboard_link }, null, 2))}`;
503
+ if (report.scan_count === 0 && !stillProcessing) {
504
+ return [
505
+ ...preface,
506
+ ...processingNote(processing),
507
+ `No screens were scanned in the App Accessibility manual report below (status: ${report.status}), so there are no issues to list.`,
508
+ details,
509
+ ];
510
+ }
511
+ const { records, page_length, total_issues, next_page } = report;
512
+ const where = `${report.scan_count} screen${report.scan_count === 1 ? "" : "s"} scanned${report.device ? ` on ${report.device}` : ""}`;
513
+ const dashboardNote = report.duplicate_occurrences > 0
514
+ ? [
515
+ `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.`,
516
+ ]
517
+ : [];
518
+ return [
519
+ ...preface,
520
+ ...processingNote(processing),
521
+ `Retrieved ${page_length} accessibility issue occurrences (Total: ${total_issues}) from the App Accessibility manual report below (${where})`,
522
+ details,
523
+ ...(cursor === 0 ? summaryNote(report.summary, total_issues) : []),
524
+ ...dashboardNote,
525
+ ...excludedIssuesNote(null, 0, report.hidden_issues),
526
+ `Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
527
+ closingMessage(next_page, report.remaining, source, stillProcessing),
528
+ ];
529
+ }
530
+ async function fetchAccessibilityIssues(requested, config, cursor = 0, context, { compareWith, pageUrl, exportTaskId, } = {}) {
213
531
  const reportFetcher = await initializeReportFetcher(config);
214
- const source = requested.kind === "link"
532
+ const named = requested.kind === "link"
215
533
  ? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
216
534
  : requested;
535
+ // A build id names a web build or a mobile app build; the APIs tell which.
536
+ const source = await reportFetcher.resolveBuild(named);
537
+ // Website Scanner runs and App Accessibility builds can be compared, each in its own way.
538
+ if (compareWith !== undefined && source.kind === "scan") {
539
+ return compareScanRuns(requested.kind === "link" ? requested : source, compareWith, config, context);
540
+ }
541
+ if (compareWith !== undefined && source.kind !== "appBuild") {
542
+ return createErrorResponse(`compareWith compares Website Scanner runs and App Accessibility builds; ${describeSource(source)} is neither.`);
543
+ }
217
544
  const processing = await waitForProcessing(reportFetcher, source, cursor === 0, context);
218
545
  const stillProcessing = processing.status !== undefined &&
219
546
  PROCESSING_STATUSES.has(processing.status);
220
- const reportLink = await reportFetcher.getReportLink(source);
221
- const { records, page_length, total_issues, next_page, duplicate_issues, unlinked_duplicates, hidden_issues, } = await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor });
222
- const currentlyShown = cursor === 0
223
- ? page_length
224
- : Math.floor(cursor / JSON.stringify(records[0] || {}).length) +
225
- page_length;
226
- const remainingIssues = total_issues - currentlyShown;
227
- const messages = [
228
- ...(requested.kind === "link"
229
- ? [`Resolved the report link to ${describeSource(source)}.`]
230
- : []),
547
+ const preface = requested.kind === "link"
548
+ ? [`Resolved the report link to ${describeSource(source)}.`]
549
+ : [];
550
+ if (source.kind === "appBuild" && compareWith !== undefined) {
551
+ return compareAppBuilds(reportFetcher, source, compareWith, cursor, preface, processing);
552
+ }
553
+ if (source.kind === "appBuild") {
554
+ return createSuccessResponse(await appBuildMessages(reportFetcher, source, cursor, preface, processing, stillProcessing));
555
+ }
556
+ if (source.kind === "appReport") {
557
+ return createSuccessResponse(await appReportMessages(reportFetcher, source, cursor, preface, processing, stillProcessing));
558
+ }
559
+ const pageArg = pageUrl ? `, pageUrl: "${pageUrl}"` : "";
560
+ let reportLink;
561
+ try {
562
+ reportLink = await reportFetcher.getReportLink(source, {
563
+ exportTaskId,
564
+ onWait: exportProgress(context),
565
+ });
566
+ }
567
+ catch (error) {
568
+ if (error instanceof ExportPendingError) {
569
+ return createSuccessResponse(exportPendingMessages(source, error, pageArg));
570
+ }
571
+ throw error;
572
+ }
573
+ const { records, page_length, total_issues, next_page, remaining, duplicate_issues, unlinked_duplicates, hidden_issues, page, } = await parseAccessibilityReportFromCSV(reportLink, {
574
+ nextPage: cursor,
575
+ pageUrl,
576
+ });
577
+ return createSuccessResponse([
578
+ ...preface,
231
579
  ...processingNote(processing),
580
+ ...(page ? pageNote(page) : []),
232
581
  `Retrieved ${page_length} accessibility issues (Total: ${total_issues})`,
233
582
  ...excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
234
583
  `You can also download the full report (CSV) from the following link, valid for about 12 hours: ${reportLink}`,
235
584
  `Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
585
+ closingMessage(next_page, remaining, source, stillProcessing, pageArg),
586
+ ]);
587
+ }
588
+ /** What one page of a report holds, in the dashboard's page-view terms. */
589
+ function pageNote(page) {
590
+ if (page.occurrences === 0) {
591
+ const similar = page.similar_pages?.length
592
+ ? ` Pages in this report with a similar address: ${wrapUntrusted("report page addresses", JSON.stringify(page.similar_pages))}`
593
+ : "";
594
+ return [`No issue in this report is on ${page.url}.${similar}`];
595
+ }
596
+ const severities = Object.entries(page.by_severity)
597
+ .sort(([, a], [, b]) => b - a)
598
+ .map(([severity, n]) => `${n} ${severity}`)
599
+ .join(", ");
600
+ return [
601
+ `${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.`,
236
602
  ];
237
- if (next_page !== null) {
238
- messages.push(`${remainingIssues} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)} and cursor: ${next_page} to get the next batch.`);
603
+ }
604
+ /** At most this many new and resolved issues are listed in a comparison. */
605
+ const COMPARE_LIST_LIMIT = 50;
606
+ /**
607
+ * A Website Scanner run against an earlier run of the same scan: the run before it
608
+ * ("previous") or a given scanRunId. Issues are matched by rule, element and page; against
609
+ * the previous run, the dashboard's own counts (the run's changesSinceLastRun) are given too.
610
+ */
611
+ async function compareScanRuns(requested, compareWith, config, context) {
612
+ const reportFetcher = await initializeReportFetcher(config);
613
+ const source = requested.kind === "link"
614
+ ? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
615
+ : requested;
616
+ if (source.kind !== "scan") {
617
+ 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.');
239
618
  }
240
- else if (stillProcessing) {
241
- messages.push(`All issues found so far retrieved; the report is still processing.`);
619
+ const previous = await reportFetcher.previousScanRun(source.scanId, source.scanRunId);
620
+ const baseRunId = compareWith === "previous" ? previous && String(previous.id) : compareWith;
621
+ if (!baseRunId) {
622
+ return createErrorResponse(`Run ${source.scanRunId} is the first run of scan ${source.scanId}; there is no earlier run to compare it with.`);
242
623
  }
243
- else {
244
- messages.push(`✅ All issues retrieved.`);
624
+ const base = { ...source, scanRunId: baseRunId };
625
+ const againstPrevious = previous !== undefined && String(previous.id) === baseRunId;
626
+ let links;
627
+ try {
628
+ links = await Promise.all([
629
+ reportFetcher.getReportLink(source, { onWait: exportProgress(context) }),
630
+ reportFetcher.getReportLink(base, {}),
631
+ ]);
632
+ }
633
+ catch (error) {
634
+ if (error instanceof ExportPendingError) {
635
+ return createSuccessResponse([
636
+ `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.`,
637
+ ]);
638
+ }
639
+ throw error;
640
+ }
641
+ const [after, before] = await Promise.all(links.map(loadMainIssues));
642
+ const diff = compareIssues(before, after);
643
+ const changes = againstPrevious
644
+ ? await reportFetcher.changesSinceLastRun(source.scanId, source.scanRunId)
645
+ : undefined;
646
+ const baseLabel = againstPrevious
647
+ ? `the run before it, run ${baseRunId}${previous?.scanDate ? ` (${previous.scanDate})` : ""}`
648
+ : `run ${baseRunId}`;
649
+ const matched = `${diff.added.length} new, ${diff.resolved.length} resolved, ${diff.retained} still open`;
650
+ const listed = (issues) => wrapUntrusted("accessibility comparison", JSON.stringify(issues.slice(0, COMPARE_LIST_LIMIT), null, 2)) +
651
+ (issues.length > COMPARE_LIST_LIMIT
652
+ ? `\n(${issues.length - COMPARE_LIST_LIMIT} more not listed; see the CSV links.)`
653
+ : "");
654
+ return createSuccessResponse([
655
+ ...(requested.kind === "link"
656
+ ? [`Resolved the report link to ${describeSource(source)}.`]
657
+ : []),
658
+ `Compared Website Scanner scan ${source.scanId}: run ${source.scanRunId} with ${baseLabel}.`,
659
+ changes
660
+ ? `The dashboard's comparison: ${changes.newIssues} new, ${changes.resolvedIssues} resolved, ${changes.retainedIssues} still open. Matching the issues by rule, element and page gives ${matched}.`
661
+ : `Matching the issues by rule, element and page: ${matched}.`,
662
+ `New issues: ${listed(diff.added)}`,
663
+ `Resolved issues: ${listed(diff.resolved)}`,
664
+ `CSV of each run, valid for about 12 hours: newer ${links[0]} , older ${links[1]}`,
665
+ ]);
666
+ }
667
+ /**
668
+ * What App Accessibility has on record, newest first, with the ids the issues are read by.
669
+ * Without a project name, the automated listing shows the projects: a build list needs one.
670
+ */
671
+ async function listAppAccessibilityReports(args, config) {
672
+ const reportFetcher = await initializeReportFetcher(config);
673
+ const limit = args.limit ?? 10;
674
+ const filtered = (what) => args.name ? ` whose ${what} contains "${args.name}"` : "";
675
+ if (args.kind === "manual") {
676
+ const { total, reports } = await reportFetcher.listAppManualReports(args.name, limit);
677
+ if (reports.length === 0) {
678
+ return createSuccessResponse([
679
+ `No App Accessibility manual reports${filtered("name")}. Drop the name filter to list the newest reports.`,
680
+ ]);
681
+ }
682
+ return createSuccessResponse([
683
+ `${total} App Accessibility manual reports (newest first)${filtered("name")}${total > reports.length ? `, showing ${reports.length}` : ""}:`,
684
+ wrapUntrusted("app accessibility reports", JSON.stringify(reports, null, 2)),
685
+ `Read a report's issues with fetchAccessibilityIssues with appReportId: <reportId>; its dashboard_link opens it on the dashboard.`,
686
+ ]);
687
+ }
688
+ if (!args.projectName) {
689
+ const { total, projects } = await reportFetcher.listAppProjects(limit);
690
+ return createSuccessResponse([
691
+ `${total} App Accessibility projects (newest first)${total > projects.length ? `, showing ${projects.length}` : ""}:`,
692
+ wrapUntrusted("app accessibility projects", JSON.stringify(projects, null, 2)),
693
+ `A build list needs a project: call listAppAccessibilityReports again with projectName: <name> (exact) to list its builds.`,
694
+ ]);
695
+ }
696
+ const { project, total, builds } = await reportFetcher.listAppBuilds(args.projectName, args.name, limit);
697
+ if (builds.length === 0) {
698
+ return createSuccessResponse([
699
+ `No builds of App Accessibility project "${project.name}"${filtered("name")}${args.name
700
+ ? ". Drop the name filter to list the project's newest builds"
701
+ : ": nothing has run with accessibility on in it yet"}.`,
702
+ ]);
703
+ }
704
+ return createSuccessResponse([
705
+ `${total} builds of App Accessibility project "${project.name}" (newest first)${filtered("name")}${total > builds.length ? `, showing ${builds.length}` : ""}:`,
706
+ wrapUntrusted("app accessibility builds", JSON.stringify(builds, null, 2)),
707
+ `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.`,
708
+ ]);
709
+ }
710
+ async function executeListAppAccessibilityReports(args, server, config) {
711
+ try {
712
+ trackMCP("listAppAccessibilityReports", server.server.getClientVersion(), undefined, config);
713
+ return await listAppAccessibilityReports(args, config);
714
+ }
715
+ catch (error) {
716
+ return handleMCPError("listAppAccessibilityReports", server, config, error);
245
717
  }
246
- return createSuccessResponse(messages);
247
718
  }
248
719
  async function executeAccessibilityScan(args, context, server, config) {
249
720
  try {
@@ -350,32 +821,33 @@ async function runAccessibilityScan(name, pageURL, context, config, authConfigId
350
821
  return createScanFailureResponse(name, status);
351
822
  }
352
823
  const reportFetcher = await initializeReportFetcher(config);
353
- const reportLink = await reportFetcher.getReportLink({
354
- kind: "scan",
355
- scanId,
356
- scanRunId,
357
- });
824
+ let reportLink;
825
+ try {
826
+ reportLink = await reportFetcher.getReportLink({ kind: "scan", scanId, scanRunId }, { onWait: exportProgress(context) });
827
+ }
828
+ catch (error) {
829
+ if (error instanceof ExportPendingError) {
830
+ return createSuccessResponse([
831
+ `Accessibility scan "${name}" completed. Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`,
832
+ ...exportPendingMessages({
833
+ kind: "scan",
834
+ scanId: String(scanId),
835
+ scanRunId: String(scanRunId),
836
+ }, error, ""),
837
+ ]);
838
+ }
839
+ throw error;
840
+ }
358
841
  const { records, page_length, total_issues, next_page, duplicate_issues, unlinked_duplicates, hidden_issues, } = await parseAccessibilityReportFromCSV(reportLink);
359
842
  return createScanSuccessResponse(name, total_issues, page_length, records, scanId, scanRunId, reportLink, next_page, excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues));
360
843
  }
361
844
  export default function addAccessibilityTools(server, config) {
362
845
  const tools = {};
363
- tools.accessibilityExpert = server.tool("accessibilityExpert", "Answer accessibility, a11y, and WCAG questions using BrowserStack's accessibility knowledge base.", {
364
- query: z
365
- .string()
366
- .describe("Any accessibility, a11y, WCAG, or web accessibility question"),
367
- }, {
368
- title: "Accessibility Expert",
369
- readOnlyHint: true,
370
- openWorldHint: false,
371
- destructiveHint: false,
372
- idempotentHint: true,
373
- }, async (args) => {
374
- return await executeAccessibilityRAG(args, server, config);
375
- });
376
- tools.startAccessibilityScan = server.tool("startAccessibilityScan", "Start an accessibility scan via BrowserStack and retrieve a local CSV report path.", {
846
+ 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.", {
377
847
  name: z.string().describe("Name of the accessibility scan"),
378
- pageURL: z.string().describe("The URL to scan for accessibility issues"),
848
+ pageURL: z
849
+ .string()
850
+ .describe("The web page URL to scan for accessibility issues"),
379
851
  authConfigId: z
380
852
  .number()
381
853
  .optional()
@@ -466,32 +938,50 @@ export default function addAccessibilityTools(server, config) {
466
938
  }, async (args) => {
467
939
  return await executeGetAuthConfig(args, server, config);
468
940
  });
469
- tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues with pagination support, from exactly one source: a completed Website Scanner run (scanId with scanRunId), an Automated Tests build from a BrowserStack SDK test run (buildId), a Workflow Analyzer report (workflowReportId), or a Website Scanner or Automated Tests report's dashboard link (reportUrl). Also returns a link to download the full report as a CSV. Use cursor parameter to get subsequent pages of results." +
941
+ tools.fetchAccessibilityIssues = server.tool("fetchAccessibilityIssues", "Fetch accessibility issues from one source: scanId+scanRunId, buildId, workflowReportId, appReportId or reportUrl." +
470
942
  NEEDS_A11Y_SCAN_ID, {
471
943
  scanId: z
472
944
  .string()
473
945
  .optional()
474
- .describe("Website Scanner: the scan ID from a completed accessibility scan"),
946
+ .describe("Website Scanner: the scan id from startAccessibilityScan."),
475
947
  scanRunId: z
476
948
  .string()
477
949
  .optional()
478
- .describe("Website Scanner: the scan run ID from a completed accessibility scan"),
950
+ .describe("Website Scanner: the scan run id from startAccessibilityScan."),
479
951
  buildId: z
480
952
  .string()
953
+ .regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits")
481
954
  .optional()
482
- .describe("Automated Tests: the Accessibility build id, shown as thBuildId in the build's accessibility report link (40 lowercase letters and digits). Not the Automate build id or the build name."),
955
+ .describe("Automated Tests or App Accessibility build id: the SDK's testhub id, or the thBuildId in a web report link."),
483
956
  workflowReportId: z
484
957
  .string()
485
958
  .optional()
486
- .describe("Workflow Analyzer: the report's numeric id, e.g. 123456"),
959
+ .describe("Workflow Analyzer: the report's numeric id."),
960
+ appReportId: z
961
+ .string()
962
+ .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")
963
+ .optional()
964
+ .describe("Manual App Accessibility report id (a UUID), from listAppAccessibilityReports or its /report/ link."),
487
965
  reportUrl: z
488
966
  .string()
489
967
  .optional()
490
- .describe("A report's Accessibility dashboard link, as the user pasted it: a Website Scanner or Automated Tests report link carrying thBuildId. A Website Scanner link resolves only to its scan's latest run. Workflow Analyzer links are not supported; use workflowReportId."),
968
+ .describe("A web or app report link (dashboard, scanner share link, manual report), or an Automate or App Automate link."),
491
969
  cursor: z
492
970
  .number()
493
971
  .optional()
494
- .describe("Character offset for pagination (default: 0)"),
972
+ .describe("Character offset for pagination (default: 0); with compareWith, how many issues are already listed."),
973
+ pageUrl: z
974
+ .string()
975
+ .optional()
976
+ .describe("Only the issues on this page URL, counted as the dashboard's page view counts them."),
977
+ compareWith: z
978
+ .string()
979
+ .optional()
980
+ .describe(`"previous", an older scanRunId (Website Scanner), or an earlier app build's id or link; lists new and resolved issues.`),
981
+ exportTaskId: z
982
+ .string()
983
+ .optional()
984
+ .describe("Continue a CSV export that an earlier call said is still being generated."),
495
985
  }, {
496
986
  title: "Fetch Accessibility Issues",
497
987
  readOnlyHint: true,
@@ -501,5 +991,36 @@ export default function addAccessibilityTools(server, config) {
501
991
  }, async (args, context) => {
502
992
  return await executeFetchAccessibilityIssues(args, context, server, config);
503
993
  });
994
+ 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.", {
995
+ kind: z
996
+ .enum(["automated", "manual"])
997
+ .optional()
998
+ .default("automated")
999
+ .describe("automated (App Automate builds, default) or manual (Workflow Analyzer reports)."),
1000
+ projectName: z
1001
+ .string()
1002
+ .optional()
1003
+ .describe("Automated only: the exact App Accessibility project name; omit to list the projects."),
1004
+ name: z
1005
+ .string()
1006
+ .optional()
1007
+ .describe("Partial build or report name to filter by."),
1008
+ limit: z
1009
+ .number()
1010
+ .int()
1011
+ .min(1)
1012
+ .max(30)
1013
+ .optional()
1014
+ .default(10)
1015
+ .describe("Rows to return, newest first (1 to 30, default 10)."),
1016
+ }, {
1017
+ title: "List App Accessibility Reports",
1018
+ readOnlyHint: true,
1019
+ openWorldHint: false,
1020
+ destructiveHint: false,
1021
+ idempotentHint: true,
1022
+ }, async (args) => {
1023
+ return await executeListAppAccessibilityReports(args, server, config);
1024
+ });
504
1025
  return tools;
505
1026
  }