diff --git a/src/tools/accessibility.ts b/src/tools/accessibility.ts index 462c64dd..d63d9309 100644 --- a/src/tools/accessibility.ts +++ b/src/tools/accessibility.ts @@ -2,7 +2,10 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { AccessibilityScanner } from "./accessiblity-utils/scanner.js"; -import { AccessibilityReportFetcher } from "./accessiblity-utils/report-fetcher.js"; +import { + AccessibilityReportFetcher, + type IssueSource, +} from "./accessiblity-utils/report-fetcher.js"; import { AccessibilityAuthConfig, safeAuthConfigData, @@ -68,11 +71,15 @@ function createErrorResponse(message: string): CallToolResult { }; } +/** + * Each message ends with a newline: clients that join text blocks with no separator + * would otherwise run a link at the end of one block into the next block's first word. + */ function createSuccessResponse(messages: string[]): CallToolResult { return { content: messages.map((text) => ({ type: "text" as const, - text, + text: `${text}\n`, })), }; } @@ -142,8 +149,66 @@ async function executeAccessibilityRAG( } } +interface FetchIssuesArgs { + scanId?: string; + scanRunId?: string; + buildId?: string; + workflowReportId?: string; + reportUrl?: string; + cursor?: number; +} + +/** A source named by its ids, or a dashboard link still to be resolved to them. */ +type RequestedSource = IssueSource | { kind: "link"; reportUrl: string }; + +/** The one source the arguments name, or why they do not name exactly one. */ +function issueSourceFrom(args: FetchIssuesArgs): RequestedSource | string { + const named = [ + Boolean(args.scanId || args.scanRunId), + Boolean(args.buildId), + Boolean(args.workflowReportId), + 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)."; + } + if (args.reportUrl) return { kind: "link", reportUrl: args.reportUrl }; + if (args.buildId) return { kind: "build", buildId: args.buildId }; + if (args.workflowReportId) { + return { kind: "workflow", reportId: args.workflowReportId }; + } + if (!args.scanId || !args.scanRunId) { + return "A Website Scanner run needs both scanId and scanRunId."; + } + return { kind: "scan", scanId: args.scanId, scanRunId: args.scanRunId }; +} + +/** The arguments that name the same source again, for the next-page hint. */ +function sourceArgs(source: IssueSource): string { + switch (source.kind) { + case "scan": + return `scanId: "${source.scanId}", scanRunId: "${source.scanRunId}"`; + case "build": + return `buildId: "${source.buildId}"`; + case "workflow": + return `workflowReportId: "${source.reportId}"`; + } +} + +/** What a dashboard link resolved to, in words. */ +function describeSource(source: IssueSource): string { + switch (source.kind) { + case "scan": + return `Website Scanner scan ${source.scanId}, run ${source.scanRunId}`; + case "build": + return `Automated Tests build ${source.buildId}`; + case "workflow": + return `Workflow Analyzer report ${source.reportId}`; + } +} + async function executeFetchAccessibilityIssues( - args: { scanId: string; scanRunId: string; cursor?: number }, + args: FetchIssuesArgs, server: McpServer, config: BrowserStackConfig, ): Promise { @@ -154,28 +219,46 @@ async function executeFetchAccessibilityIssues( undefined, config, ); - return await fetchAccessibilityIssues( - args.scanId, - args.scanRunId, - config, - args.cursor, - ); + const source = issueSourceFrom(args); + if (typeof source === "string") return createErrorResponse(source); + return await fetchAccessibilityIssues(source, config, args.cursor); } catch (error) { return handleMCPError("fetchAccessibilityIssues", server, config, error); } } +/** How grouped reports' duplicates were handled, or nothing for an ungrouped report. */ +function duplicatesNote(duplicates: number | null, unlinked: number): string[] { + if (!duplicates) return []; + const unmatched = + unlinked > 0 + ? ` ${unlinked} of them could not be matched to a main issue and are counted only here.` + : ""; + return [ + `This report groups repeated occurrences: ${duplicates} duplicate occurrences are folded into their main issue (see duplicate_count) and are not included in the total.${unmatched}`, + ]; +} + async function fetchAccessibilityIssues( - scanId: string, - scanRunId: string, + requested: RequestedSource, config: BrowserStackConfig, cursor = 0, ): Promise { const reportFetcher = await initializeReportFetcher(config); - const reportLink = await reportFetcher.getReportLink(scanId, scanRunId); + const source = + requested.kind === "link" + ? await reportFetcher.sourceFromReportUrl(requested.reportUrl) + : requested; + const reportLink = await reportFetcher.getReportLink(source); - const { records, page_length, total_issues, next_page } = - await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor }); + const { + records, + page_length, + total_issues, + next_page, + duplicate_issues, + unlinked_duplicates, + } = await parseAccessibilityReportFromCSV(reportLink, { nextPage: cursor }); const currentlyShown = cursor === 0 @@ -185,13 +268,18 @@ async function fetchAccessibilityIssues( const remainingIssues = total_issues - currentlyShown; const messages = [ + ...(requested.kind === "link" + ? [`Resolved the report link to ${describeSource(source)}.`] + : []), `Retrieved ${page_length} accessibility issues (Total: ${total_issues})`, + ...duplicatesNote(duplicate_issues, unlinked_duplicates), + `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 cursor: ${next_page} to get the next batch.`, + `${remainingIssues} more issues available. Use fetchAccessibilityIssues with ${sourceArgs(source)} and cursor: ${next_page} to get the next batch.`, ); } else { messages.push(`✅ All issues retrieved.`); @@ -366,12 +454,14 @@ function createScanSuccessResponse( scanRunId: string, reportUrl: string, cursor: number | null, + duplicates: string[] = [], ): CallToolResult { const messages = [ `Accessibility scan "${name}" completed. check the BrowserStack dashboard for more details [https://scanner.browserstack.com/site-scanner/scan-details/${name}].`, `Scan ID: ${scanId} and Scan Run ID: ${scanRunId}`, `You can also download the full report from the following link: ${reportUrl}`, `We found ${totalIssues} issues. Below are the details of the ${pageLength} most critical issues.`, + ...duplicates, `Scan results: ${wrapUntrusted("accessibility scan results", JSON.stringify(records, null, 2))}`, ]; @@ -411,10 +501,20 @@ async function runAccessibilityScan( } const reportFetcher = await initializeReportFetcher(config); - const reportLink = await reportFetcher.getReportLink(scanId, scanRunId); + const reportLink = await reportFetcher.getReportLink({ + kind: "scan", + scanId, + scanRunId, + }); - const { records, page_length, total_issues, next_page } = - await parseAccessibilityReportFromCSV(reportLink); + const { + records, + page_length, + total_issues, + next_page, + duplicate_issues, + unlinked_duplicates, + } = await parseAccessibilityReportFromCSV(reportLink); return createScanSuccessResponse( name, @@ -425,6 +525,7 @@ async function runAccessibilityScan( scanRunId, reportLink, next_page, + duplicatesNote(duplicate_issues, unlinked_duplicates), ); } @@ -596,15 +697,37 @@ export default function addAccessibilityTools( tools.fetchAccessibilityIssues = server.tool( "fetchAccessibilityIssues", - "Fetch accessibility issues from a completed scan with pagination support. Use cursor parameter to get subsequent pages of results." + + "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 link (reportUrl) to a Website Scanner or Automated Tests report or to an Automate build or session, for the accessibility issues of that Automate run. Also returns a link to download the full report as a CSV. Use cursor parameter to get subsequent pages of results." + NEEDS_A11Y_SCAN_ID, { scanId: z .string() - .describe("The scan ID from a completed accessibility scan"), + .optional() + .describe( + "Website Scanner: the scan ID from a completed accessibility scan", + ), scanRunId: z .string() - .describe("The scan run ID from a completed accessibility scan"), + .optional() + .describe( + "Website Scanner: the scan run ID from a completed accessibility scan", + ), + buildId: z + .string() + .optional() + .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; for an Automate build, pass its Automate link as reportUrl.", + ), + workflowReportId: z + .string() + .optional() + .describe("Workflow Analyzer: the report's numeric id, e.g. 123456"), + reportUrl: z + .string() + .optional() + .describe( + "A link as the user pasted it: a Website Scanner or Automated Tests report link from the Accessibility dashboard (carrying thBuildId), or an Automate build or session link from automate.browserstack.com. A Website Scanner link resolves only to its scan's latest run. An Automate link resolves to the whole build's accessibility issues, also for a session link, and only for runs made through the BrowserStack SDK with accessibility testing on. Workflow Analyzer links are not supported; use workflowReportId.", + ), cursor: z .number() .optional() diff --git a/src/tools/accessiblity-utils/report-fetcher.ts b/src/tools/accessiblity-utils/report-fetcher.ts index d81893da..3afc2351 100644 --- a/src/tools/accessiblity-utils/report-fetcher.ts +++ b/src/tools/accessiblity-utils/report-fetcher.ts @@ -1,4 +1,7 @@ import { apiClient } from "../../lib/apiClient.js"; +import { SessionType } from "../../lib/constants.js"; +import { sessionsListUrl } from "../automate-utils/list-session-ids.js"; +import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js"; interface ReportInitResponse { success: true; @@ -12,6 +15,101 @@ interface ReportResponse { error?: any; } +/** What to read issues from: a Website Scanner run, an Automated Tests build or a Workflow Analyzer report. */ +export type IssueSource = + | { kind: "scan"; scanId: string; scanRunId: string } + | { kind: "build"; buildId: string } + | { kind: "workflow"; reportId: string }; + +const API_BASE = "https://api-accessibility.browserstack.com/api"; + +/** + * How far a report link's scan is searched for. The scan list has no name or link filter and + * spans the whole organisation, so the walk covers only the newest scans, which is where a + * link someone just opened will be. It runs at the list's default page size (~1.2 s a page): + * at limit=50 or 100 the cursor skips scans, so a larger page cannot be used to go faster. + */ +export const MAX_SCAN_LIST_PAGES = 15; + +interface ScanListPage { + scans?: { + id: number; + name: string; + scanUrl?: string; + lastScanDetails?: { scanRunId?: number }; + }[]; + pagination?: { hasNext?: boolean; nextPage?: string }; +} + +/** The thBuildId a dashboard report link carries, if any. */ +function thBuildIdOf(link: string | undefined): string | null { + try { + return link ? new URL(link).searchParams.get("thBuildId") : null; + } catch { + return null; + } +} + +/** + * Each source exports its issues the same way: one call starts a CSV export and returns a + * task id, and the same route polled with that task id returns the link. Only the route and + * its parameter names differ (the scanner's are snake_case, the other two camelCase). + */ +function exportUrls(source: IssueSource): { + start: string; + poll: (taskId: string) => string; +} { + const q = encodeURIComponent; + switch (source.kind) { + case "scan": { + const route = `${API_BASE}/website-scanner/v1/scans/${q(source.scanId)}/scan_runs/issues`; + return { + start: `${route}?scan_run_id=${q(source.scanRunId)}`, + poll: (taskId) => `${route}?task_id=${q(taskId)}`, + }; + } + case "build": { + const route = `${API_BASE}/automated-tests/v1/builds/issues`; + return { + start: `${route}?buildUuid=${q(source.buildId)}`, + poll: (taskId) => `${route}?taskId=${q(taskId)}`, + }; + } + case "workflow": { + const route = `${API_BASE}/workflow-analyzer/v1/reports/issues`; + return { + start: `${route}?reportId=${q(source.reportId)}`, + poll: (taskId) => `${route}?taskId=${q(taskId)}`, + }; + } + } +} + +/** Why an export did not start, in the API's words, and where a build's id comes from when it is not found. */ +function startFailure(source: IssueSource, status: number, body: any): string { + const reason = + [body?.error].flat().filter(Boolean).join("; ") || + body?.data?.message || + `HTTP ${status}`; + // The API takes only its own build id; the Automate build id is a 404 "Build not found". + const hint = + status === 404 && source.kind === "build" + ? ". Pass the Accessibility build id: the thBuildId in the build's accessibility report link (40 lowercase letters and digits), not the Automate build id or the build name. For an Automate build or session, pass its Automate dashboard link as reportUrl" + : ""; + return `Failed to initiate report: ${reason}${hint}`; +} + +/** The build and session ids an Automate dashboard link carries, if it is one. */ +function automateIds( + url: URL, +): { buildId?: string; sessionId?: string } | undefined { + if (url.hostname !== "automate.browserstack.com") return undefined; + return { + buildId: url.pathname.match(/\/builds\/([0-9a-f]{40})(?:\/|$)/i)?.[1], + sessionId: url.pathname.match(/\/sessions\/([0-9a-f]{40})(?:\/|$)/i)?.[1], + }; +} + export class AccessibilityReportFetcher { private auth: { username: string; password: string } | undefined; @@ -19,41 +117,188 @@ export class AccessibilityReportFetcher { this.auth = auth; } - async getReportLink(scanId: string, scanRunId: string): Promise { - // Initiate CSV link generation - const initUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?scan_run_id=${scanRunId}`; + private authHeaders(): Record | undefined { + if (!this.auth) return undefined; + const { username, password } = this.auth; + return { + Authorization: + "Basic " + Buffer.from(`${username}:${password}`).toString("base64"), + }; + } - let basicAuthHeader = undefined; - if (this.auth) { - const { username, password } = this.auth; - basicAuthHeader = - "Basic " + Buffer.from(`${username}:${password}`).toString("base64"); + /** + * The source a dashboard report link points at. An Automated Tests link carries the build + * id itself. An Automate build or session link is mapped to that build through a session + * (see buildOfAutomateSession). A Website Scanner link carries the thBuildId of one run, + * which only the scan list can map to ids: each scan there carries its latest run's link. + * A link to an older run therefore matches no scan, and is reported as such rather than + * guessed at. + */ + async sourceFromReportUrl(reportUrl: string): Promise { + let url: URL | undefined; + try { + url = new URL(reportUrl); + } catch { + url = undefined; } - const initResp = await apiClient.get({ - url: initUrl, - headers: basicAuthHeader ? { Authorization: basicAuthHeader } : undefined, + const automate = url && automateIds(url); + if (automate?.sessionId) { + return this.buildOfAutomateSession(automate.sessionId); + } + if (automate?.buildId) { + return this.buildOfAutomateSession( + await this.anyAutomateSession(automate.buildId), + ); + } + const thBuildId = url?.searchParams.get("thBuildId"); + if (url && thBuildId && url.pathname.includes("/automated-tests/")) { + return { kind: "build", buildId: thBuildId }; + } + const scanName = url?.pathname.match( + /\/site-scanner\/projects\/([^/]+)\/builds/, + )?.[1]; + if (thBuildId && scanName) { + return this.findScanRun(thBuildId, decodeURIComponent(scanName)); + } + throw new Error( + "This link does not name a report the API can look up. Pass an Automated Tests or Website Scanner report link from the Accessibility dashboard (both carry thBuildId), an Automate build or session link, or the ids: scanId with scanRunId, buildId, or workflowReportId", + ); + } + + /** Any one session of an Automate build; every session of an SDK run names the same build. */ + private async anyAutomateSession(automateBuildId: string): Promise { + const resp = await apiClient.get({ + url: `${sessionsListUrl(SessionType.Automate, automateBuildId)}?limit=1`, + headers: this.authHeaders(), + raise_error: false, }); - const initData: ReportInitResponse = initResp.data; - if (!initData.success) { + if (resp.status === 404) { + throw new Error(`No Automate build with id ${automateBuildId}`); + } + if (!resp.ok) { throw new Error( - `Failed to initiate report: ${initData.error || initData.data.message}`, + `Could not list the sessions of Automate build ${automateBuildId}: HTTP ${resp.status}`, ); } + const sessionId = resp.data?.[0]?.automation_session?.hashed_id; + if (!sessionId) { + throw new Error(`Automate build ${automateBuildId} has no sessions`); + } + return sessionId; + } + + /** + * The Accessibility build an Automate session ran in. A session run through the BrowserStack + * SDK belongs to the SDK's build, whose id is also the Accessibility build id, and the + * session's test-code record names it (build_uuid). A session run without the SDK has no + * such record, and an SDK run without accessibility testing has a build the Accessibility + * API does not know; both are said plainly instead of surfacing later as a bare 404. + */ + private async buildOfAutomateSession( + sessionId: string, + ): Promise { + const resp = await apiClient.get({ + url: `${getAutomationBaseUrl()}/ext/v1/sessions/${encodeURIComponent(sessionId)}/testCode`, + headers: this.authHeaders(), + raise_error: false, + }); + if (!resp.ok && resp.status !== 404) { + throw new Error( + `Could not read Automate session ${sessionId}: HTTP ${resp.status}`, + ); + } + const buildId: string | undefined = resp.ok + ? resp.data?.build_uuid + : undefined; + if (!buildId) { + throw new Error( + `Automate session ${sessionId} is not linked to a BrowserStack SDK build (it was not run through the SDK, or belongs to another account), so it has no accessibility results`, + ); + } + const build = await apiClient.get({ + url: `${API_BASE}/automated-tests/v1/builds/${encodeURIComponent(buildId)}`, + headers: this.authHeaders(), + raise_error: false, + }); + if (build.status === 404) { + throw new Error( + `This Automate run belongs to build ${buildId}, which has no accessibility results: accessibility testing was not on for it`, + ); + } + return { kind: "build", buildId }; + } + + /** Walk the newest scans for the one whose own link carries this thBuildId. */ + private async findScanRun( + thBuildId: string, + scanName: string, + ): Promise { + let nextPage: string | undefined; + let sameName: number | undefined; + for (let page = 0; page < MAX_SCAN_LIST_PAGES; page++) { + const resp = await apiClient.get({ + url: `${API_BASE}/website-scanner/v1/scans${ + nextPage ? `?nextPage=${encodeURIComponent(nextPage)}` : "" + }`, + headers: this.authHeaders(), + }); + const data: ScanListPage | undefined = resp.data?.data; + for (const scan of data?.scans ?? []) { + if (thBuildIdOf(scan.scanUrl) === thBuildId) { + return { + kind: "scan", + scanId: String(scan.id), + scanRunId: String(scan.lastScanDetails?.scanRunId), + }; + } + if (sameName === undefined && scan.name === scanName) { + sameName = scan.id; + } + } + if (!data?.pagination?.hasNext || !data.pagination.nextPage) { + nextPage = undefined; + break; + } + nextPage = data.pagination.nextPage; + } + if (sameName !== undefined) { + throw new Error( + `This link is to an earlier run of scan "${scanName}" (scanId ${sameName}). A link resolves only to a scan's latest run; ask the user for that run's scanRunId and pass it with scanId ${sameName}`, + ); + } + // nextPage is still set only when the walk stopped at the page cap, not at the end. + const searched = nextPage + ? ` among the most recent scans (${MAX_SCAN_LIST_PAGES} pages)` + : ""; + throw new Error( + `No Website Scanner scan${searched} matches this link. Pass scanId and scanRunId instead`, + ); + } + + async getReportLink(source: IssueSource): Promise { + const urls = exportUrls(source); + + // Initiate CSV link generation + const initResp = await apiClient.get({ + url: urls.start, + headers: this.authHeaders(), + raise_error: false, + }); + const initData: ReportInitResponse = initResp.data; + if (!initResp.ok || !initData?.success) { + throw new Error(startFailure(source, initResp.status, initData)); + } const taskId = initData.data.task_id; // Poll for the generated CSV link (task is async, may take a few seconds) - const reportUrl = `https://api-accessibility.browserstack.com/api/website-scanner/v1/scans/${scanId}/scan_runs/issues?task_id=${encodeURIComponent( - taskId, - )}`; + const reportUrl = urls.poll(taskId); const maxAttempts = 3; const pollIntervalMs = 2000; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const reportResp = await apiClient.get({ url: reportUrl, - headers: basicAuthHeader - ? { Authorization: basicAuthHeader } - : undefined, + headers: this.authHeaders(), }); const reportData: ReportResponse = reportResp.data; if (!reportData.success) { diff --git a/src/tools/accessiblity-utils/report-parser.ts b/src/tools/accessiblity-utils/report-parser.ts index 5128753f..08e4d26a 100644 --- a/src/tools/accessiblity-utils/report-parser.ts +++ b/src/tools/accessiblity-utils/report-parser.ts @@ -9,8 +9,16 @@ type SimplifiedAccessibilityIssue = { HTML_snippet: string; how_to_fix: string; severity: string; + /** The page the issue is on; builds and Workflow Analyzer reports span several. */ + affected_page: string; + /** Automated Tests builds only: the test that found the issue. */ + source_test?: string; + /** Grouped reports only: how many duplicate rows point at this issue. */ + duplicate_count?: number; }; +const SOURCE_TEST_COLUMN = "Source test"; + type PaginationOptions = { /** How many JSON-chars max per “page” (default 10000) */ maxCharacterLength?: number; @@ -22,10 +30,24 @@ type PaginatedResult = { records: SimplifiedAccessibilityIssue[]; /** Character offset for the next page, or null if done */ page_length: number; + /** Main issues only when the report is grouped, so it matches the run's issue count. */ total_issues: number; next_page: number | null; + /** Duplicate rows left out of `records`; null when the report is not grouped. */ + duplicate_issues: number | null; + /** Of those, the ones whose main issue could not be identified (counted only here). */ + unlinked_duplicates: number; }; +const DUPLICATE_COLUMN = "Duplicate Issue of"; +const ISSUE_LINK_COLUMN = "Issue link"; + +/** A grouped report marks a main issue `NA`; a duplicate carries its main issue's link. */ +function isMainIssue(row: Record): boolean { + const value = (row[DUPLICATE_COLUMN] ?? "").trim(); + return value === "" || value.toUpperCase() === "NA"; +} + export async function parseAccessibilityReportFromCSV( reportLink: string, { maxCharacterLength = 5000, nextPage = 0 }: PaginationOptions = {}, @@ -37,9 +59,13 @@ export async function parseAccessibilityReportFromCSV( typeof res.data === "string" ? res.data : JSON.stringify(res.data); let parsed: any[]; + let columns: string[] = []; try { parsed = parse(text, { - columns: true, + columns: (header: string[]) => { + columns = header; + return header; + }, skip_empty_lines: true, relax_column_count_less: true, }); @@ -51,18 +77,53 @@ export async function parseAccessibilityReportFromCSV( ); throw err; } - const all: SimplifiedAccessibilityIssue[] = parsed.map((row: any) => ({ + + // 2) Fold duplicates into their main issue (grouped reports only). + // + // The export writes a duplicate's "Duplicate Issue of" as the exact "Issue link" of the + // main issue it belongs to, so the two are matched verbatim. A value that matches no + // main issue, or matches more than one, is not guessed at: it is counted in + // `unlinked_duplicates` and the duplicate total, never against a specific issue. + const grouped = columns.includes(DUPLICATE_COLUMN); + const linkable = grouped && columns.includes(ISSUE_LINK_COLUMN); + const hasSourceTest = columns.includes(SOURCE_TEST_COLUMN); + const mainRows = grouped ? parsed.filter(isMainIssue) : parsed; + const duplicateRows = grouped + ? parsed.filter((row) => !isMainIssue(row)) + : []; + + const all: SimplifiedAccessibilityIssue[] = mainRows.map((row: any) => ({ issue_type: row["Issue type"], component: row["Component"], issue_description: row["Issue description"], HTML_snippet: row["HTML snippet"], how_to_fix: row["How to fix this issue"], severity: (row["Severity"] || "unknown").trim(), + affected_page: row["Affected page"], + ...(hasSourceTest ? { source_test: row[SOURCE_TEST_COLUMN] } : {}), + ...(linkable ? { duplicate_count: 0 } : {}), })); + let unlinkedDuplicates = duplicateRows.length; + if (linkable && duplicateRows.length > 0) { + const byLink = new Map(); + mainRows.forEach((row: any, i: number) => { + const link = (row[ISSUE_LINK_COLUMN] ?? "").trim(); + if (!link) return; + // Two main issues sharing a link cannot tell their duplicates apart. + byLink.set(link, byLink.has(link) ? null : all[i]); + }); + unlinkedDuplicates = 0; + for (const row of duplicateRows) { + const main = byLink.get((row[DUPLICATE_COLUMN] ?? "").trim()); + if (main) main.duplicate_count = (main.duplicate_count ?? 0) + 1; + else unlinkedDuplicates++; + } + } + const totalIssues = all.length; - // 2) Sort by severity + // 3) Sort by severity const order: Record = { critical: 0, serious: 1, @@ -71,7 +132,7 @@ export async function parseAccessibilityReportFromCSV( }; all.sort((a, b) => (order[a.severity] ?? 99) - (order[b.severity] ?? 99)); - // 3) Walk to the starting offset + // 4) Walk to the starting offset let charCursor = 0; let idx = 0; for (; idx < all.length; idx++) { @@ -80,7 +141,7 @@ export async function parseAccessibilityReportFromCSV( charCursor += len; } - // 4) Collect up to maxCharacterLength + // 5) Collect up to maxCharacterLength const page: SimplifiedAccessibilityIssue[] = []; for (let i = idx; i < all.length; i++) { const recStr = JSON.stringify(all[i]); @@ -97,5 +158,7 @@ export async function parseAccessibilityReportFromCSV( next_page: hasMore ? charCursor : null, page_length: pageLength, total_issues: totalIssues, + duplicate_issues: grouped ? duplicateRows.length : null, + unlinked_duplicates: unlinkedDuplicates, }; } diff --git a/src/tools/tool-handoff.ts b/src/tools/tool-handoff.ts index ce41a2b3..b3332915 100644 --- a/src/tools/tool-handoff.ts +++ b/src/tools/tool-handoff.ts @@ -88,11 +88,17 @@ export const NEEDS_SESSION_ID = " Requires a session id. listSessions lists them for a build; if you do not have the " + "build either, getBuildId or listBuildId resolves one from a project and build name."; -/** A completed scan's ids come from startAccessibilityScan, or from the agent. */ +/** + * A scan run's ids come from startAccessibilityScan. Nothing here lists builds or reports, + * but the tool takes a report's dashboard link, or an Automate link, and looks the ids up itself. + */ export const NEEDS_A11Y_SCAN_ID = - " Requires the ids of a completed scan, which are returned by startAccessibilityScan. " + - "No tool here lists past scans, so if you do not have the ids, start a new scan rather " + - "than guessing."; + " Requires a completed source: a Website Scanner run's ids, returned by " + + "startAccessibilityScan; an Automated Tests build's id, the thBuildId in its accessibility " + + "report link; a Workflow Analyzer report's numeric id; or, passed as reportUrl, a Website " + + "Scanner or Automated Tests report's dashboard link or an Automate build or session link. " + + "No tool here lists past scans, builds or reports, so if you have none of these, ask the " + + "user for the report link, or start a new scan, rather than guessing."; /** Auth-config ids are not listable by any tool here. */ export const NEEDS_A11Y_CONFIG_ID = diff --git a/tests/tools/accessibilityReport.test.ts b/tests/tools/accessibilityReport.test.ts new file mode 100644 index 00000000..1ae6bbbf --- /dev/null +++ b/tests/tools/accessibilityReport.test.ts @@ -0,0 +1,195 @@ +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; + +vi.mock("../../src/lib/apiClient.js", () => ({ + apiClient: { get: vi.fn(), post: vi.fn() }, +})); +vi.mock("../../src/logger.js", () => ({ + default: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, +})); + +import { apiClient } from "../../src/lib/apiClient.js"; +import { parseAccessibilityReportFromCSV } from "../../src/tools/accessiblity-utils/report-parser.js"; + +const get = apiClient.get as Mock; + +const COLUMNS = [ + "Issue type", + "Issue name", + "Severity", + "Component", + "Behavior", + "Issue description", + "Affected page", + "Guideline(s)", + "HTML snippet", + "CSS selector", + "Needs review", + "Needs Review Status", + "Best practice", + "How to fix this issue", + "Issue link", + "Duplicate Issue of", + "Timestamp", + "Issue status", + "Page HTML Source", +]; + +const LINK_A = "https://dash.example/issue?activeIssueId=a"; +const LINK_B = "https://dash.example/issue?activeIssueId=b"; + +function row(fields: Record): Record { + return { + Behavior: "", + "Needs Review Status": "Confirmed", + "Best practice": "No", + Timestamp: "2026-10-01", + "Issue status": "Visible", + "Page HTML Source": "https://dash.example/html", + ...fields, + }; +} + +const contrast = { + "Issue type": "color-contrast", + "Issue name": "Elements must meet contrast", + Severity: "serious", + Component: "p.note", + "Issue description": "Low contrast", + "Guideline(s)": "WCAG 2.1 AA, 1.4.3", + "CSS selector": "p.note", + "Needs review": "No", + "How to fix this issue": "Raise contrast", +}; + +/** Two main issues; two duplicates of A, one duplicate whose main is not in the file. */ +const GROUPED_ROWS = [ + row({ + ...contrast, + "Affected page": "https://site.example/", + "HTML snippet": '

x

', + "Issue link": LINK_A, + "Duplicate Issue of": "NA", + }), + row({ + "Issue type": "image-alt", + "Issue name": "Images must have alt text", + Severity: "critical", + Component: "img.hero", + "Issue description": "Missing alt", + "Affected page": "https://site.example/", + "Guideline(s)": "WCAG 2.1 A", + "HTML snippet": "", + "CSS selector": "img.hero", + "Needs review": "Yes", + "How to fix this issue": "Add alt", + "Issue link": LINK_B, + "Duplicate Issue of": "NA", + }), + row({ + ...contrast, + "Affected page": "https://site.example/a", + "HTML snippet": "

y

", + "Issue link": "https://dash.example/issue?activeIssueId=c", + "Duplicate Issue of": LINK_A, + }), + row({ + ...contrast, + "Affected page": "https://site.example/b", + "HTML snippet": "

z

", + "Issue link": "https://dash.example/issue?activeIssueId=d", + "Duplicate Issue of": LINK_A, + }), + row({ + ...contrast, + "Issue type": "label", + "Affected page": "https://site.example/c", + "Issue link": "https://dash.example/issue?activeIssueId=e", + "Duplicate Issue of": "https://dash.example/issue?activeIssueId=gone", + }), +]; + +/** Quote every cell, CSV-style. */ +function toCsv(columns: string[], rows: Record[]): string { + const cell = (value = "") => `"${value.replace(/"/g, '""')}"`; + return [columns, ...rows.map((r) => columns.map((c) => r[c]))] + .map((cells) => cells.map(cell).join(",")) + .join("\n"); +} + +function csvResponse(text: string) { + return { ok: true, statusText: "OK", data: text }; +} + +describe("parseAccessibilityReportFromCSV", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns only main issues, with duplicates counted against the issue they point at", async () => { + get.mockResolvedValue(csvResponse(toCsv(COLUMNS, GROUPED_ROWS))); + const result = await parseAccessibilityReportFromCSV("https://csv"); + + expect(result.total_issues).toBe(2); + expect( + result.records.map((r) => [r.issue_type, r.duplicate_count]), + ).toEqual([ + ["image-alt", 0], + ["color-contrast", 2], + ]); + // The third duplicate's main issue is not in the file: counted in the total only. + expect(result.duplicate_issues).toBe(3); + expect(result.unlinked_duplicates).toBe(1); + expect(result.next_page).toBeNull(); + }); + + it("does not attribute duplicates when the report has no Issue link to match them by", async () => { + const withoutLinks = toCsv( + COLUMNS.filter((c) => c !== "Issue link"), + GROUPED_ROWS, + ); + get.mockResolvedValue(csvResponse(withoutLinks)); + const result = await parseAccessibilityReportFromCSV("https://csv"); + + expect(result.total_issues).toBe(2); + expect(result.records.every((r) => !("duplicate_count" in r))).toBe(true); + expect(result.duplicate_issues).toBe(3); + expect(result.unlinked_duplicates).toBe(3); + }); + + it("keeps every row of an ungrouped report and adds no duplicate fields", async () => { + const ungrouped = [ + "Issue type,Severity,Component,Issue description,HTML snippet,How to fix this issue,Issue link", + "image-alt,critical,img,Missing alt,,Add alt,https://d/1", + "image-alt,critical,img,Missing alt,,Add alt,https://d/2", + ].join("\n"); + get.mockResolvedValue(csvResponse(ungrouped)); + const result = await parseAccessibilityReportFromCSV("https://csv"); + + expect(result.total_issues).toBe(2); + expect(result.duplicate_issues).toBeNull(); + expect(result.unlinked_duplicates).toBe(0); + expect(result.records.every((r) => !("duplicate_count" in r))).toBe(true); + }); + + it("says which page each issue is on and, for a build, which test found it", async () => { + const buildCsv = toCsv( + [...COLUMNS, "Source test"], + GROUPED_ROWS.map((r) => ({ ...r, "Source test": "test_checkout" })), + ); + get.mockResolvedValue(csvResponse(buildCsv)); + const { records } = await parseAccessibilityReportFromCSV("https://csv"); + + expect(records.map((r) => [r.affected_page, r.source_test])).toEqual([ + ["https://site.example/", "test_checkout"], + ["https://site.example/", "test_checkout"], + ]); + }); + + it("adds no source_test to a report without that column", async () => { + get.mockResolvedValue(csvResponse(toCsv(COLUMNS, GROUPED_ROWS))); + const { records } = await parseAccessibilityReportFromCSV("https://csv"); + + expect( + records.every((r) => r.affected_page === "https://site.example/"), + ).toBe(true); + expect(records.every((r) => !("source_test" in r))).toBe(true); + }); +}); diff --git a/tests/tools/accessibilityReportFetcher.test.ts b/tests/tools/accessibilityReportFetcher.test.ts new file mode 100644 index 00000000..4771df4c --- /dev/null +++ b/tests/tools/accessibilityReportFetcher.test.ts @@ -0,0 +1,254 @@ +import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; + +vi.mock("../../src/lib/apiClient.js", () => ({ + apiClient: { get: vi.fn() }, +})); + +import { apiClient } from "../../src/lib/apiClient.js"; +import { getAutomationBaseUrl } from "../../src/tools/rca-agent-utils/constants.js"; +import { + AccessibilityReportFetcher, + IssueSource, + MAX_SCAN_LIST_PAGES, +} from "../../src/tools/accessiblity-utils/report-fetcher.js"; + +const get = apiClient.get as Mock; +const API = "https://api-accessibility.browserstack.com/api"; +const BUILD_ID = "q7m2k9x4c1v8b3n6z0w5r2t9y4p1s8d3f6g0h2ja"; + +const ok = (data: unknown) => ({ + ok: true, + status: 200, + data: { success: true, data }, +}); +const failed = (status: number, error: unknown) => ({ + ok: false, + status, + data: { success: false, data: null, error }, +}); + +function fetcher() { + const f = new AccessibilityReportFetcher(); + f.setAuth({ username: "u", password: "p" }); + return f; +} + +describe("AccessibilityReportFetcher.getReportLink", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each<[string, IssueSource, string, string]>([ + [ + "a Website Scanner run", + { kind: "scan", scanId: "1234", scanRunId: "5678" }, + `${API}/website-scanner/v1/scans/1234/scan_runs/issues?scan_run_id=5678`, + `${API}/website-scanner/v1/scans/1234/scan_runs/issues?task_id=t%3A1`, + ], + [ + "an Automated Tests build", + { kind: "build", buildId: BUILD_ID }, + `${API}/automated-tests/v1/builds/issues?buildUuid=${BUILD_ID}`, + `${API}/automated-tests/v1/builds/issues?taskId=t%3A1`, + ], + [ + "a Workflow Analyzer report", + { kind: "workflow", reportId: "123456" }, + `${API}/workflow-analyzer/v1/reports/issues?reportId=123456`, + `${API}/workflow-analyzer/v1/reports/issues?taskId=t%3A1`, + ], + ])( + "exports the issues of %s on its own route", + async (_label, source, startUrl, pollUrl) => { + get + .mockResolvedValueOnce(ok({ task_id: "t:1", message: "started" })) + .mockResolvedValueOnce(ok({ reportLink: "https://csv" })); + + await expect(fetcher().getReportLink(source)).resolves.toBe( + "https://csv", + ); + expect(get.mock.calls.map(([options]) => options.url)).toEqual([ + startUrl, + pollUrl, + ]); + expect(get.mock.calls[0][0].headers).toEqual({ + Authorization: `Basic ${Buffer.from("u:p").toString("base64")}`, + }); + }, + ); + + it("says which id a build needs when the API does not find it", async () => { + get.mockResolvedValueOnce(failed(404, ["Build not found"])); + + await expect( + fetcher().getReportLink({ + kind: "build", + buildId: "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c", + }), + ).rejects.toThrow(/Build not found.*thBuildId.*not the Automate build id/s); + }); + + it("passes on the API's reason when it rejects a report id", async () => { + get.mockResolvedValueOnce(failed(400, ["Report id must be an Integer"])); + + await expect( + fetcher().getReportLink({ kind: "workflow", reportId: "abc" }), + ).rejects.toThrow("Report id must be an Integer"); + }); +}); + +describe("AccessibilityReportFetcher.sourceFromReportUrl", () => { + const DASHBOARD = "https://accessibility.browserstack.com"; + const scannerLink = (name: string, thBuildId: string) => + `${DASHBOARD}/site-scanner/projects/${encodeURIComponent(name)}/builds?thBuildId=${thBuildId}&recurring=false&tunnel_vision=false`; + const scan = ( + id: number, + name: string, + thBuildId: string, + scanRunId: number, + ) => ({ + id, + name, + scanUrl: scannerLink(name, thBuildId), + lastScanDetails: { scanRunId }, + }); + const scansPage = (scans: unknown[], nextPage?: string) => + ok({ scans, pagination: { hasNext: Boolean(nextPage), nextPage } }); + + beforeEach(() => vi.clearAllMocks()); + + it("takes an Automated Tests link's build id without calling the API", async () => { + await expect( + fetcher().sourceFromReportUrl( + `${DASHBOARD}/automated-tests/projects/p/builds/b/1?thBuildId=${BUILD_ID}`, + ), + ).resolves.toEqual({ kind: "build", buildId: BUILD_ID }); + expect(get).not.toHaveBeenCalled(); + }); + + it("finds a Website Scanner link's scan and latest run by paging the scan list", async () => { + get + .mockResolvedValueOnce(scansPage([scan(1, "other", "aaa", 11)], "tok2")) + .mockResolvedValueOnce( + scansPage([scan(1234, "checkout fix", "bbb", 5678)]), + ); + + await expect( + fetcher().sourceFromReportUrl(scannerLink("checkout fix", "bbb")), + ).resolves.toEqual({ kind: "scan", scanId: "1234", scanRunId: "5678" }); + // No limit: larger pages make the list skip scans, so it is walked at its default size. + expect(get.mock.calls.map(([options]) => options.url)).toEqual([ + `${API}/website-scanner/v1/scans`, + `${API}/website-scanner/v1/scans?nextPage=tok2`, + ]); + }); + + it("names the scan when the link is to one of its earlier runs", async () => { + get.mockResolvedValueOnce( + scansPage([scan(1234, "checkout fix", "newer", 5678)]), + ); + + await expect( + fetcher().sourceFromReportUrl(scannerLink("checkout fix", "older")), + ).rejects.toThrow(/earlier run of scan "checkout fix" \(scanId 1234\)/); + }); + + it("stops after the newest scans and asks for the ids", async () => { + get.mockResolvedValue(scansPage([scan(1, "other", "aaa", 11)], "more")); + + await expect( + fetcher().sourceFromReportUrl(scannerLink("checkout fix", "bbb")), + ).rejects.toThrow(/most recent scans.*scanId and scanRunId/s); + expect(get).toHaveBeenCalledTimes(MAX_SCAN_LIST_PAGES); + }); + + it("refuses a link that carries no report id", async () => { + await expect( + fetcher().sourceFromReportUrl( + "https://scanner.browserstack.com/site-scanner/scan-details/checkout", + ), + ).rejects.toThrow(/thBuildId/); + expect(get).not.toHaveBeenCalled(); + }); +}); + +describe("AccessibilityReportFetcher.sourceFromReportUrl for Automate links", () => { + const AUTOMATE_BUILD = "0f1e2d3c4b5a69788796a5b4c3d2e1f00f1e2d3c"; + const SESSION = "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d"; + const DASHBOARD = "https://automate.browserstack.com/dashboard/v2/builds"; + const testCodeUrl = `${getAutomationBaseUrl()}/ext/v1/sessions/${SESSION}/testCode`; + const testCode = (buildUuid?: string) => ({ + ok: true, + status: 200, + data: { session_id: SESSION, build_uuid: buildUuid, tests: [] }, + }); + const urls = () => get.mock.calls.map(([options]) => options.url); + + beforeEach(() => vi.clearAllMocks()); + + it("maps a session link to the accessibility build it ran in", async () => { + get + .mockResolvedValueOnce(testCode(BUILD_ID)) + .mockResolvedValueOnce(ok({ id: BUILD_ID })); + + await expect( + fetcher().sourceFromReportUrl( + `${DASHBOARD}/${AUTOMATE_BUILD}/sessions/${SESSION}`, + ), + ).resolves.toEqual({ kind: "build", buildId: BUILD_ID }); + expect(urls()).toEqual([ + testCodeUrl, + `${API}/automated-tests/v1/builds/${BUILD_ID}`, + ]); + }); + + it("maps a build link through one of its sessions", async () => { + get + .mockResolvedValueOnce({ + ok: true, + status: 200, + data: [{ automation_session: { hashed_id: SESSION } }], + }) + .mockResolvedValueOnce(testCode(BUILD_ID)) + .mockResolvedValueOnce(ok({ id: BUILD_ID })); + + await expect( + fetcher().sourceFromReportUrl(`${DASHBOARD}/${AUTOMATE_BUILD}`), + ).resolves.toEqual({ kind: "build", buildId: BUILD_ID }); + expect(urls()[0]).toBe( + `https://api.browserstack.com/automate/builds/${AUTOMATE_BUILD}/sessions.json?limit=1`, + ); + }); + + it("says a session not run through the SDK has no accessibility results", async () => { + get.mockResolvedValueOnce({ + ok: false, + status: 404, + data: { message: "Session not found" }, + }); + + await expect( + fetcher().sourceFromReportUrl( + `${DASHBOARD}/${AUTOMATE_BUILD}/sessions/${SESSION}`, + ), + ).rejects.toThrow(/not linked to a BrowserStack SDK build/); + }); + + it("says an SDK run without accessibility testing has no results", async () => { + get + .mockResolvedValueOnce(testCode(BUILD_ID)) + .mockResolvedValueOnce(failed(404, ["Build not found"])); + + await expect( + fetcher().sourceFromReportUrl( + `${DASHBOARD}/${AUTOMATE_BUILD}/sessions/${SESSION}`, + ), + ).rejects.toThrow(/accessibility testing was not on/); + }); + + it("names an Automate build it does not know", async () => { + get.mockResolvedValueOnce({ ok: false, status: 404, data: null }); + + await expect( + fetcher().sourceFromReportUrl(`${DASHBOARD}/${AUTOMATE_BUILD}`), + ).rejects.toThrow(`No Automate build with id ${AUTOMATE_BUILD}`); + }); +}); diff --git a/tests/tools/accessibilityScanOutput.test.ts b/tests/tools/accessibilityScanOutput.test.ts new file mode 100644 index 00000000..1e4b1fdd --- /dev/null +++ b/tests/tools/accessibilityScanOutput.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// What startAccessibilityScan / fetchAccessibilityIssues tell the agent about the issue +// count: main issues only, matching the dashboard, with duplicates stated separately. + +const m = vi.hoisted(() => ({ + startScan: vi.fn(), + waitUntilComplete: vi.fn(), + getReportLink: vi.fn(), + sourceFromReportUrl: vi.fn(), + parse: vi.fn(), +})); + +vi.mock("../../src/tools/accessiblity-utils/scanner.js", () => ({ + AccessibilityScanner: class { + setAuth = vi.fn(); + startScan = m.startScan; + waitUntilComplete = m.waitUntilComplete; + }, +})); +vi.mock("../../src/tools/accessiblity-utils/report-fetcher.js", () => ({ + AccessibilityReportFetcher: class { + setAuth = vi.fn(); + getReportLink = m.getReportLink; + sourceFromReportUrl = m.sourceFromReportUrl; + }, +})); +vi.mock("../../src/tools/accessiblity-utils/report-parser.js", () => ({ + parseAccessibilityReportFromCSV: m.parse, +})); +vi.mock("../../src/lib/get-auth.js", () => ({ + getBrowserStackAuth: vi.fn().mockReturnValue("fake-user:fake-key"), +})); +vi.mock("../../src/logger.js", () => ({ + default: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, +})); +vi.mock("../../src/lib/instrumentation.js", () => ({ trackMCP: vi.fn() })); + +import addAccessibilityTools from "../../src/tools/accessibility.js"; + +const CONFIG = { + "browserstack-username": "fake-user", + "browserstack-access-key": "fake-key", +}; + +function register() { + const handlers: Record any> = {}; + const server: any = { + tool: vi.fn((...args: any[]) => { + handlers[args[0]] = args[args.length - 1]; + }), + server: { + getClientVersion: vi.fn().mockReturnValue({ version: "1.0" }), + getClientCapabilities: vi.fn().mockReturnValue({}), + }, + }; + addAccessibilityTools(server, CONFIG); + return handlers; +} + +const text = (result: any) => + result.content.map((c: { text: string }) => c.text).join("\n"); + +const CONTEXT = { sendNotification: vi.fn(), _meta: {} }; + +/** A grouped report: 79 main issues, 43 duplicates (all matched). */ +const GROUPED = { + records: [{ issue_type: "select-name", duplicate_count: 0 }], + page_length: 1, + total_issues: 79, + next_page: 140, + duplicate_issues: 43, + unlinked_duplicates: 0, +}; + +describe("accessibility issue counts", () => { + let handlers: Record any>; + + beforeEach(() => { + vi.clearAllMocks(); + m.startScan.mockResolvedValue({ + data: { id: "scan-42", scanRunId: "run-7" }, + }); + m.waitUntilComplete.mockResolvedValue("completed"); + m.getReportLink.mockResolvedValue("https://csv"); + handlers = register(); + }); + + it("startAccessibilityScan reports main issues as the total and states the duplicates separately", async () => { + m.parse.mockResolvedValue(GROUPED); + const result = await handlers.startAccessibilityScan( + { name: "dedupe", pageURL: "https://site.example" }, + CONTEXT, + ); + + expect(result.isError).toBeFalsy(); + const body = text(result); + expect(body).toContain("We found 79 issues."); + expect(body).toContain( + "43 duplicate occurrences are folded into their main issue", + ); + expect(body).not.toContain("could not be matched"); + }); + + it("fetchAccessibilityIssues reports main issues as the total and names unmatched duplicates", async () => { + m.parse.mockResolvedValue({ ...GROUPED, unlinked_duplicates: 2 }); + const result = await handlers.fetchAccessibilityIssues( + { scanId: "scan-42", scanRunId: "run-7" }, + CONTEXT, + ); + + const body = text(result); + expect(body).toContain("Retrieved 1 accessibility issues (Total: 79)"); + expect(body).toContain( + "43 duplicate occurrences are folded into their main issue", + ); + expect(body).toContain("2 of them could not be matched"); + }); + + it("adds no duplicates note for a report without duplicates", async () => { + m.parse.mockResolvedValue({ + ...GROUPED, + duplicate_issues: null, + next_page: null, + }); + const result = await handlers.startAccessibilityScan( + { name: "plain", pageURL: "https://site.example" }, + CONTEXT, + ); + + const body = text(result); + expect(body).toContain("We found 79 issues."); + expect(body).not.toContain("duplicate occurrences"); + }); + + it("keeps the report link intact when a client joins the blocks with no separator", async () => { + m.getReportLink.mockResolvedValue( + "https://reports.example/r.csv?Signature=abc&Key-Pair-Id=K1", + ); + m.parse.mockResolvedValue(GROUPED); + const result = await handlers.startAccessibilityScan( + { name: "joined", pageURL: "https://site.example" }, + CONTEXT, + ); + + const joined = result.content.map((c: { text: string }) => c.text).join(""); + expect(joined.match(/following link: (\S+)/)?.[1]).toBe( + "https://reports.example/r.csv?Signature=abc&Key-Pair-Id=K1", + ); + expect(joined).not.toMatch(/Scan Run ID: run-7\S/); + }); +}); + +describe("fetchAccessibilityIssues sources", () => { + let handlers: Record any>; + const BUILD_ID = "q7m2k9x4c1v8b3n6z0w5r2t9y4p1s8d3f6g0h2ja"; + + beforeEach(() => { + vi.clearAllMocks(); + m.startScan.mockResolvedValue({ + data: { id: "scan-42", scanRunId: "run-7" }, + }); + m.waitUntilComplete.mockResolvedValue("completed"); + m.getReportLink.mockResolvedValue("https://csv"); + m.parse.mockResolvedValue(GROUPED); + handlers = register(); + }); + + it.each([ + [ + "a Website Scanner run", + { scanId: "scan-42", scanRunId: "run-7" }, + { kind: "scan", scanId: "scan-42", scanRunId: "run-7" }, + 'scanId: "scan-42", scanRunId: "run-7"', + ], + [ + "an Automated Tests build", + { buildId: BUILD_ID }, + { kind: "build", buildId: BUILD_ID }, + `buildId: "${BUILD_ID}"`, + ], + [ + "a Workflow Analyzer report", + { workflowReportId: "123456" }, + { kind: "workflow", reportId: "123456" }, + 'workflowReportId: "123456"', + ], + ])( + "reads the issues of %s and names it again for the next page", + async (_label, args, source, nextPageArgs) => { + const result = await handlers.fetchAccessibilityIssues(args, CONTEXT); + + expect(result.isError).toBeFalsy(); + expect(m.getReportLink).toHaveBeenCalledWith(source); + const body = text(result); + expect(body).toContain("Retrieved 1 accessibility issues (Total: 79)"); + expect(body).toContain( + `Use fetchAccessibilityIssues with ${nextPageArgs} and cursor: 140`, + ); + expect(body).toContain( + "download the full report (CSV) from the following link", + ); + expect(body).toContain("https://csv\n"); + }, + ); + + it("resolves a dashboard report link and pages by the ids it found", async () => { + const reportUrl = + "https://accessibility.browserstack.com/site-scanner/projects/x/builds?thBuildId=abc"; + m.sourceFromReportUrl.mockResolvedValue({ + kind: "scan", + scanId: "scan-42", + scanRunId: "run-7", + }); + const result = await handlers.fetchAccessibilityIssues( + { reportUrl }, + CONTEXT, + ); + + expect(result.isError).toBeFalsy(); + expect(m.sourceFromReportUrl).toHaveBeenCalledWith(reportUrl); + expect(m.getReportLink).toHaveBeenCalledWith({ + kind: "scan", + scanId: "scan-42", + scanRunId: "run-7", + }); + const body = text(result); + expect(body).toContain( + "Resolved the report link to Website Scanner scan scan-42, run run-7.", + ); + expect(body).toContain( + 'Use fetchAccessibilityIssues with scanId: "scan-42", scanRunId: "run-7" and cursor: 140', + ); + }); + + it("startAccessibilityScan reads the run it just started", async () => { + await handlers.startAccessibilityScan( + { name: "run", pageURL: "https://site.example" }, + CONTEXT, + ); + expect(m.getReportLink).toHaveBeenCalledWith({ + kind: "scan", + scanId: "scan-42", + scanRunId: "run-7", + }); + }); + + it.each([ + ["no source", {}, /exactly one/], + [ + "two sources", + { scanId: "scan-42", scanRunId: "run-7", buildId: BUILD_ID }, + /exactly one/, + ], + ["a scan without its run", { scanId: "scan-42" }, /scanRunId/], + [ + "a report link with another source", + { + reportUrl: "https://accessibility.browserstack.com/x", + buildId: BUILD_ID, + }, + /exactly one/, + ], + ])("refuses %s without calling the API", async (_label, args, message) => { + const result = await handlers.fetchAccessibilityIssues(args, CONTEXT); + + expect(result.isError).toBe(true); + expect(text(result)).toMatch(message); + expect(m.getReportLink).not.toHaveBeenCalled(); + expect(m.sourceFromReportUrl).not.toHaveBeenCalled(); + }); +});