Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
78af477
fix(app-automate): write a browserstack.yml the SDK reads as intended…
Aaryan430 Oct 21, 2026
1698672
feat(accessibility): read App Accessibility builds, and say where mob…
Aaryan430 Oct 21, 2026
af48f8e
test(accessibility): keep the server entry out of the scan-output test
Aaryan430 Oct 21, 2026
e1900bc
fix(accessibility): find a dashboard link's app build by number, not …
Aaryan430 Oct 21, 2026
332096c
fix(app-automate): write osVersion, the key the SDK's App Accessibili…
Aaryan430 Oct 21, 2026
53c5117
fix(accessibility): keep the app-aware tool text within the descripti…
Aaryan430 Oct 22, 2026
a685be6
Merge branch 'feat/a11y-wait-for-processing' into feat/a11y-app-builds
Aaryan430 Oct 22, 2026
6fe7be6
fix(accessibility): address review on #504: WDIO App Accessibility ve…
Aaryan430 Oct 23, 2026
81ebc13
fix(accessibility): leave no tag behind when stripping an app fix tex…
Aaryan430 Oct 23, 2026
35a1c47
fix(accessibility): strip an app fix text's markup without a regex
Aaryan430 Oct 23, 2026
4144519
feat(accessibility): hand out 10,000 characters of issues per call, n…
Aaryan430 Oct 23, 2026
f5f88c3
fix(accessibility): address review on #504: oversized issues, snapsho…
Aaryan430 Oct 23, 2026
fbbc084
feat(accessibility): an app issue's measurements, and its rule's text…
Aaryan430 Oct 23, 2026
98dc138
fix(accessibility): know an iOS element by its class, id and text
Aaryan430 Oct 23, 2026
4725773
Merge remote-tracking branch 'origin/main' into feat/a11y-app-builds
Aaryan430 Oct 23, 2026
6b26e1f
fix: address review on #504: keep the setup defaults, fail lookups on…
Aaryan430 Oct 23, 2026
4b40345
feat(accessibility): App Accessibility checks on native builds, and A…
Aaryan430 Oct 23, 2026
abe325e
Merge remote-tracking branch 'origin/main' into feat/a11y-app-builds
Aaryan430 Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ searchCapability → describeCapability → invokeCapability
|---|---|
| `takeAppScreenshot` | Launch the app on a specified device and capture a quick verification screenshot to confirm your app has launched. |
| `listTestIds` | List the tests in a BrowserStack build (Automate or App Automate) with each test's `status` and `session_id`, optionally filtered by status (passed/failed/pending/skipped). The `session_id` feeds `getFailureLogs` and `fetchAutomationScreenshots` directly. |
| `setupBrowserStackAppAutomateTests` | Set up BrowserStack App Automate SDK integration for Appium-based mobile app testing. |
| `setupBrowserStackAppAutomateTests` | Set up BrowserStack App Automate SDK integration for Appium-based mobile app testing. With `accessibility: true`, the run also performs App Accessibility (WCAG) checks on the screens the tests interact with, and the output says what makes a scan happen and how to read the issues. |
| `runAppTestsOnBrowserStack` | Run pre-built native mobile test suites (Espresso/XCUITest) by direct upload of compiled .apk/.ipa test files. |

</details>
Expand All @@ -340,11 +340,11 @@ searchCapability → describeCapability → invokeCapability

| Tool | What it does |
|---|---|
| `startAccessibilityScan` | Start a web accessibility scan and retrieve a local CSV report path. |
| `startAccessibilityScan` | Start a web accessibility scan of a page URL and retrieve a local CSV report path. Mobile apps are scanned through an App Automate run instead (`setupBrowserStackAppAutomateTests` with `accessibility: true`). |
| `accessibilityExpert` | Ask the A11y Expert (WCAG 2.0/2.1/2.2, mobile/web usability, best practices). |
| `createAccessibilityAuthConfig` | Create an authentication configuration (form-based or basic) for accessibility scans behind a login. |
| `getAccessibilityAuthConfig` | Retrieve an existing accessibility authentication configuration by ID. |
| `fetchAccessibilityIssues` | Fetch accessibility issues from a completed scan, with pagination support. |
| `fetchAccessibilityIssues` | Fetch accessibility issues, with pagination, from a Website Scanner run, an Automated Tests build (web) or App Accessibility build (mobile app, by the build id the SDK prints), a Workflow Analyzer report, or a report's dashboard link. Waits while a report is still processing; for an app build with no scans, lists the likely causes. |

</details>

Expand Down
202 changes: 165 additions & 37 deletions src/tools/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
AccessibilityReportFetcher,
type IssueSource,
} from "./accessiblity-utils/report-fetcher.js";
import type { AppBuildReport } from "./accessiblity-utils/app-report-fetcher.js";
import {
AccessibilityAuthConfig,
safeAuthConfigData,
Expand Down Expand Up @@ -170,7 +171,7 @@ function issueSourceFrom(args: FetchIssuesArgs): RequestedSource | string {
Boolean(args.reportUrl),
].filter(Boolean).length;
if (named !== 1) {
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).";
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) or reportUrl (a report's dashboard link).";
}
if (args.reportUrl) return { kind: "link", reportUrl: args.reportUrl };
if (args.buildId) return { kind: "build", buildId: args.buildId };
Expand All @@ -189,6 +190,7 @@ function sourceArgs(source: IssueSource): string {
case "scan":
return `scanId: "${source.scanId}", scanRunId: "${source.scanRunId}"`;
case "build":
case "appBuild":
return `buildId: "${source.buildId}"`;
case "workflow":
return `workflowReportId: "${source.reportId}"`;
Expand All @@ -204,6 +206,8 @@ function describeSource(source: IssueSource): string {
return `Automated Tests build ${source.buildId}`;
case "workflow":
return `Workflow Analyzer report ${source.reportId}`;
case "appBuild":
return `App Accessibility build ${source.buildId}`;
}
}

Expand Down Expand Up @@ -262,20 +266,21 @@ function excludedIssuesNote(
export const REPORT_STATUS_POLL_MS = 10_000;
export const REPORT_STATUS_WAIT_MS = 5 * 60 * 1000;

/** Statuses during which a report's issues are still being added. */
/** Statuses during which a report's issues are still being added (`rule_processing`: an app build's). */
const PROCESSING_STATUSES = new Set([
"queued",
"running",
"processing",
"finalizing",
"rule_processing",
]);

/**
* Of those, the one worth waiting on: the advanced-issue processing after the run, which
* ends within minutes. A report whose scan or tests are still running is read as it is,
* Of those, the ones worth waiting on: the processing after the run (a web scan's advanced
* issues, an app build's rules), which ends within minutes. A report whose scan or tests are still running is read as it is,
* with a note, since that can last as long as the suite does.
*/
const WAIT_STATUSES = new Set(["finalizing"]);
const WAIT_STATUSES = new Set(["finalizing", "rule_processing"]);

interface ProcessingState {
/** The status when the issues were read; undefined when it could not be read. */
Expand Down Expand Up @@ -351,17 +356,137 @@ function processingNote({
return [];
}

/** How many issues the pages after this one still hold (an estimate past the first page). */
function remainingIssues(
cursor: number,
page_length: number,
total_issues: number,
records: unknown[],
): number {
const currentlyShown =
cursor === 0
? page_length
: Math.floor(cursor / JSON.stringify(records[0] || {}).length) +
page_length;
return total_issues - currentlyShown;
}

/** The last line: how to get the next page, or that there is none. */
function closingMessage(
next_page: number | null,
remaining: number,
source: IssueSource,
stillProcessing: boolean,
): string {
if (next_page !== null) {
return `${remaining} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)} and cursor: ${next_page} to get the next batch.`;
}
if (stillProcessing) {
return `All issues found so far retrieved; the report is still processing.`;
}
return `✅ All issues retrieved.`;
}

/**
* An app build's name and dashboard link, as data: the name is the user's own (from their
* CI or browserstack.yml), so it is quoted in an untrusted block rather than in our prose.
*/
function appBuildDetails(report: AppBuildReport): string {
return `Build: ${wrapUntrusted(
"App Accessibility build",
JSON.stringify(
{
name: report.build_name,
...(report.dashboard_link
? { dashboard_link: report.dashboard_link }
: {}),
},
null,
2,
),
)}`;
}

/**
* Why a finished app build has no scans. Each cause here produced a run that finished
* green with nothing scanned and no warning; they are listed most likely first, with what
* the build's own details say ahead of the rest.
*/
function noScansNote(report: AppBuildReport): string[] {
const causes = [
...(report.fup_capped
? [
"This build hit the fair-usage cap (fup in the build details), which drops tests or scans.",
]
: []),
...(report.status === "timeout"
? [
'The session timed out during initialization (status "timeout"): check the script and the config.',
]
: []),
"`accessibility: true` is not set in browserstack.yml (or the capabilities); the SDK defaults it to false.",
'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`.',
'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").',
"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.",
"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.",
...(report.status === "timeout"
? []
: [
'The session timed out during initialization (status "timeout"): check the script and the config.',
]),
];
return [
`No accessibility scans were recorded for the App Accessibility build below (status: ${report.status}), so there are no issues to list.`,
appBuildDetails(report),
"The usual causes, most likely first:",
...causes.map((cause, i) => `${i + 1}. ${cause}`),
];
}

/** An app build's issues come inline from its scanned screens; there is no CSV to link. */
async function appBuildMessages(
reportFetcher: AccessibilityReportFetcher,
source: { kind: "appBuild"; buildId: string },
cursor: number,
preface: string[],
processing: ProcessingState,
stillProcessing: boolean,
): Promise<string[]> {
const report = await reportFetcher.appBuildReport(source.buildId, cursor);
if (report.scan_count === 0 && !stillProcessing) {
return [...preface, ...processingNote(processing), ...noScansNote(report)];
}
const { records, page_length, total_issues, next_page } = report;
const screens = `${report.scan_count} screen${report.scan_count === 1 ? "" : "s"} scanned`;
return [
...preface,
...processingNote(processing),
`Retrieved ${page_length} accessibility issues (Total: ${total_issues}) from the App Accessibility build below (${screens})`,
appBuildDetails(report),
...excludedIssuesNote(null, 0, report.hidden_issues),
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
closingMessage(
next_page,
remainingIssues(cursor, page_length, total_issues, records),
source,
stillProcessing,
),
];
}

async function fetchAccessibilityIssues(
requested: RequestedSource,
config: BrowserStackConfig,
cursor = 0,
context?: ScanProgressContext,
): Promise<CallToolResult> {
const reportFetcher = await initializeReportFetcher(config);
const source =
const named =
requested.kind === "link"
? await reportFetcher.sourceFromReportUrl(requested.reportUrl)
: requested;
// A build id names a web build or a mobile app build; the APIs tell which.
const source = await reportFetcher.resolveBuild(named);
const processing = await waitForProcessing(
reportFetcher,
source,
Expand All @@ -371,6 +496,24 @@ async function fetchAccessibilityIssues(
const stillProcessing =
processing.status !== undefined &&
PROCESSING_STATUSES.has(processing.status);
const preface =
requested.kind === "link"
? [`Resolved the report link to ${describeSource(source)}.`]
: [];

if (source.kind === "appBuild") {
return createSuccessResponse(
await appBuildMessages(
reportFetcher,
source,
cursor,
preface,
processing,
stillProcessing,
),
);
}

const reportLink = await reportFetcher.getReportLink(source);

const {
Expand All @@ -383,37 +526,20 @@ async function fetchAccessibilityIssues(
hidden_issues,
} = await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor });

const currentlyShown =
cursor === 0
? page_length
: Math.floor(cursor / JSON.stringify(records[0] || {}).length) +
page_length;
const remainingIssues = total_issues - currentlyShown;

const messages = [
...(requested.kind === "link"
? [`Resolved the report link to ${describeSource(source)}.`]
: []),
return createSuccessResponse([
...preface,
...processingNote(processing),
`Retrieved ${page_length} accessibility issues (Total: ${total_issues})`,
...excludedIssuesNote(duplicate_issues, unlinked_duplicates, hidden_issues),
`You can also download the full report (CSV) from the following link, valid for about 12 hours: ${reportLink}`,
`Issues: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`,
];

if (next_page !== null) {
messages.push(
`${remainingIssues} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)} and cursor: ${next_page} to get the next batch.`,
);
} else if (stillProcessing) {
messages.push(
`All issues found so far retrieved; the report is still processing.`,
);
} else {
messages.push(`✅ All issues retrieved.`);
}

return createSuccessResponse(messages);
closingMessage(
next_page,
remainingIssues(cursor, page_length, total_issues, records),
source,
stillProcessing,
),
]);
}

async function executeAccessibilityScan(
Expand Down Expand Up @@ -688,10 +814,12 @@ export default function addAccessibilityTools(

tools.startAccessibilityScan = server.tool(
"startAccessibilityScan",
"Start an accessibility scan via BrowserStack and retrieve a local CSV report path.",
"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.",
{
name: z.string().describe("Name of the accessibility scan"),
pageURL: z.string().describe("The URL to scan for accessibility issues"),
pageURL: z
.string()
.describe("The web page URL to scan for accessibility issues"),
authConfigId: z
.number()
.optional()
Expand Down Expand Up @@ -826,7 +954,7 @@ export default function addAccessibilityTools(

tools.fetchAccessibilityIssues = server.tool(
"fetchAccessibilityIssues",
"Fetch accessibility issues (paged by cursor) from one source: scanId+scanRunId, buildId, workflowReportId or reportUrl." +
"Fetch accessibility issues from one source: scanId+scanRunId, buildId (web or app build), workflowReportId or reportUrl." +
NEEDS_A11Y_SCAN_ID,
{
scanId: z
Expand All @@ -844,7 +972,7 @@ export default function addAccessibilityTools(
.regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits")
.optional()
.describe(
"Automated Tests: the thBuildId in the build's accessibility report link (not the Automate build id).",
"Automated Tests or App Accessibility build id: the SDK's testhub id, or the thBuildId in a web report link.",
),
workflowReportId: z
.string()
Expand All @@ -854,7 +982,7 @@ export default function addAccessibilityTools(
.string()
.optional()
.describe(
"A Website Scanner or Automated Tests report link from the Accessibility dashboard (carries thBuildId).",
"A Website Scanner, Automated Tests or App Accessibility report link from its dashboard.",
),
cursor: z
.number()
Expand Down
Loading
Loading