Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
63a47ca
feat(ask-ai): offer a11y as a product again
sarve-shreyas Oct 19, 2026
bc9e7e4
feat(accessibility): read an Automate build or session link's accessi…
Aaryan430 Oct 21, 2026
4cabfc2
fix(accessibility): read shared report links, wait out large exports,…
Aaryan430 Oct 22, 2026
da9f51c
fix(appautomate): ask native runs for devices as App Automate lists them
Aaryan430 Oct 22, 2026
0be9a14
chore(accessibility): deprecate the accessibilityExpert tool
Aaryan430 Oct 23, 2026
e8b8ac9
chore(accessibility): remove the accessibilityExpert tool
Aaryan430 Oct 23, 2026
b53e4be
Merge remote-tracking branch 'origin/main' into pr502-merge
Aaryan430 Oct 23, 2026
0e3762d
Merge remote-tracking branch 'origin/fix/a11y-share-links-big-reports…
Aaryan430 Oct 23, 2026
07ecdda
Merge remote-tracking branch 'origin/chore/deprecate-accessibility-ex…
Aaryan430 Oct 23, 2026
045742c
fix(accessibility): address review on #509: scan run paging, short CS…
Aaryan430 Oct 23, 2026
735203d
Merge branch 'fix/a11y-share-links-big-reports-compare' into pr502-merge
Aaryan430 Oct 23, 2026
8b3476a
fix(accessibility): address review on #502: report a failed accessibi…
Aaryan430 Oct 23, 2026
9392814
feat(accessibility): choose a scan's WCAG version, best practices and…
Aaryan430 Oct 23, 2026
4c4077b
feat(accessibility): hand out 10,000 characters of issues per call, n…
Aaryan430 Oct 23, 2026
12624b3
Merge branch 'fix/a11y-share-links-big-reports-compare' into pr502-merge
Aaryan430 Oct 23, 2026
03d8ee5
fix(accessibility): address review on #502: don't reuse an export whi…
Aaryan430 Oct 23, 2026
f9ebfee
revert: keep accessibilityExpert (take #512 out of #502)
Aaryan430 Oct 23, 2026
54f6b47
Merge remote-tracking branch 'origin/fix/a11y-share-links-big-reports…
Aaryan430 Oct 23, 2026
9fca08e
Merge remote-tracking branch 'origin/main' into pr502-merge
Aaryan430 Oct 23, 2026
0fa20be
Merge remote-tracking branch 'origin/main' into fix/appautomate-nativ…
Aaryan430 Oct 23, 2026
61f02c6
fix(appautomate): address review on #510: say which devices a native …
Aaryan430 Oct 23, 2026
2c5b5b6
fix(appautomate): type the resolved devices as the validator returns …
Aaryan430 Oct 23, 2026
7898b22
Merge remote-tracking branch 'origin/main' into feat/a11y-scan-settings
Aaryan430 Oct 23, 2026
79cc46d
fix(accessibility): report the settings a scan ran with, and a 422's …
Aaryan430 Oct 23, 2026
f2fd8c4
Merge remote-tracking branch 'origin/fix/appautomate-native-device-ve…
Aaryan430 Oct 23, 2026
c7e454d
Merge remote-tracking branch 'origin/feat/a11y-scan-settings' into pr…
Aaryan430 Oct 23, 2026
898f31e
Merge remote-tracking branch 'origin/askai-a11y' into pr502-merge
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
328 changes: 310 additions & 18 deletions src/tools/accessibility.ts

Large diffs are not rendered by default.

299 changes: 270 additions & 29 deletions src/tools/accessiblity-utils/report-fetcher.ts

Large diffs are not rendered by default.

291 changes: 250 additions & 41 deletions src/tools/accessiblity-utils/report-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,25 @@ type SimplifiedAccessibilityIssue = {
source_test?: string;
/** Grouped reports only: how many duplicate rows point at this issue. */
duplicate_count?: number;
/** Read for one page: how many times the issue occurs on that page. */
occurrences_on_page?: number;
/** Read for one page: the page the issue was first found on, when another one. */
first_found_on?: string;
};

/** One page of a report, counted as the dashboard's page view counts it. */
type PageSummary = {
url: string;
/** Every occurrence on the page, repeats of issues first found elsewhere included. */
occurrences: number;
/** The issues those occurrences are of. */
distinct_issues: number;
/** Of those, the ones first found on this page. */
first_found_here: number;
/** Occurrences by severity. */
by_severity: Record<string, number>;
/** When nothing is on the page: report pages whose address looks like it. */
similar_pages?: string[];
};

const SOURCE_TEST_COLUMN = "Source test";
Expand All @@ -24,6 +43,8 @@ type PaginationOptions = {
maxCharacterLength?: number;
/** Character offset to start from (default 0) */
nextPage?: number;
/** Only the issues on this page. */
pageUrl?: string;
};

type PaginatedResult = {
Expand All @@ -39,6 +60,8 @@ type PaginatedResult = {
unlinked_duplicates: number;
/** Issues hidden in the dashboard and left out; null when the export has no Issue status. */
hidden_issues: number | null;
/** Set when the report was read for one page. */
page?: PageSummary;
};

const DUPLICATE_COLUMN = "Duplicate Issue of";
Expand All @@ -56,11 +79,76 @@ function isMainIssue(row: Record<string, string>): boolean {
return value === "" || value.toUpperCase() === "NA";
}

export async function parseAccessibilityReportFromCSV(
/** A page address as the report writes it, give or take host case, trailing slash and hash. */
function samePage(url: string): string {
try {
const u = new URL(url.trim());
return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, "")}${u.search}`;
} catch {
return url.trim().replace(/\/+$/, "");
}
}

function recordOfRow(
row: any,
hasSourceTest: boolean,
): SimplifiedAccessibilityIssue {
return {
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] } : {}),
};
}

const SEVERITY_ORDER: Record<string, number> = {
critical: 0,
serious: 1,
moderate: 2,
minor: 3,
};

/** Most severe first, then the page in character-length slices from `nextPage`. */
function pageOf(
all: SimplifiedAccessibilityIssue[],
maxCharacterLength: number,
nextPage: number,
): { records: SimplifiedAccessibilityIssue[]; next_page: number | null } {
all.sort(
(a, b) =>
(SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99),
);
let charCursor = 0;
let idx = 0;
for (; idx < all.length; idx++) {
const len = JSON.stringify(all[idx]).length;
if (charCursor + len > nextPage) break;
charCursor += len;
}
const page: SimplifiedAccessibilityIssue[] = [];
for (let i = idx; i < all.length; i++) {
const recStr = JSON.stringify(all[i]);
// An issue larger than a whole page still makes a page of its own, or paging would stall.
if (
page.length > 0 &&
charCursor - nextPage + recStr.length > maxCharacterLength
) {
break;
}
page.push(all[i]);
charCursor += recStr.length;
}
const hasMore = idx + page.length < all.length;
return { records: page, next_page: hasMore ? charCursor : null };
}

async function loadReport(
reportLink: string,
{ maxCharacterLength = 5000, nextPage = 0 }: PaginationOptions = {},
): Promise<PaginatedResult> {
// 1) Download & parse
): Promise<{ parsed: any[]; columns: string[] }> {
const res = await apiClient.get({ url: reportLink });
if (!res.ok) throw new Error(`Failed to download report: ${res.statusText}`);
const text =
Expand All @@ -85,6 +173,156 @@ export async function parseAccessibilityReportFromCSV(
);
throw err;
}
return { parsed, columns };
}

/**
* One page of a report: every occurrence on it, as the dashboard's page view counts, with
* each issue listed once. A grouped report records a repeat under the page it occurs on but
* points it at its main issue, which may be on another page; those count here too.
*/
function pageIssues(
parsed: any[],
columns: string[],
pageUrl: string,
): { all: SimplifiedAccessibilityIssue[]; page: PageSummary } {
const grouped = columns.includes(DUPLICATE_COLUMN);
const hasSourceTest = columns.includes(SOURCE_TEST_COLUMN);
const target = samePage(pageUrl);
const visible = parsed.filter((row) => !isHidden(row));
const onPage = visible.filter(
(row) => samePage(row["Affected page"] ?? "") === target,
);
const mainByLink = new Map<string, any>();
for (const row of visible) {
if (!grouped || isMainIssue(row)) {
mainByLink.set((row[ISSUE_LINK_COLUMN] ?? "").trim(), row);
}
}
const issues = new Map<string, SimplifiedAccessibilityIssue>();
const bySeverity: Record<string, number> = {};
onPage.forEach((row, i) => {
const severity = (row["Severity"] || "unknown").trim();
bySeverity[severity] = (bySeverity[severity] ?? 0) + 1;
const mainLink =
grouped && !isMainIssue(row)
? (row[DUPLICATE_COLUMN] ?? "").trim()
: (row[ISSUE_LINK_COLUMN] ?? "").trim();
const key = mainLink || `row-${i}`;
let issue = issues.get(key);
if (!issue) {
const main = mainByLink.get(mainLink) ?? row;
issue = {
...recordOfRow(main, hasSourceTest),
affected_page: row["Affected page"],
occurrences_on_page: 0,
};
if (samePage(main["Affected page"] ?? "") !== target) {
issue.first_found_on = main["Affected page"];
}
issues.set(key, issue);
}
issue.occurrences_on_page = (issue.occurrences_on_page ?? 0) + 1;
});
const all = [...issues.values()];
const page: PageSummary = {
url: pageUrl,
occurrences: onPage.length,
distinct_issues: all.length,
first_found_here: all.filter((issue) => !issue.first_found_on).length,
by_severity: bySeverity,
};
if (onPage.length === 0) {
const tail =
new URL(target, "https://x").pathname.split("/").filter(Boolean).pop() ??
"";
const stem = tail.split("-").slice(0, 2).join("-");
// A short row may have no Affected page at all.
const pages = [
...new Set(
visible
.map((row) => row["Affected page"])
.filter((p): p is string => Boolean(p)),
),
];
page.similar_pages = pages
.filter((p) => stem && samePage(p).includes(stem))
.slice(0, 5);
}
return { all, page };
}

/** A main issue as two runs are matched on: the rule, the element and the page. */
export type RunIssue = {
key: string;
issue_type: string;
severity: string;
component: string;
affected_page: string;
};

/** A report's main issues that the dashboard counts (not hidden), keyed for comparing runs. */
export async function loadMainIssues(reportLink: string): Promise<RunIssue[]> {
const { parsed, columns } = await loadReport(reportLink);
const grouped = columns.includes(DUPLICATE_COLUMN);
return parsed
.filter((row) => (!grouped || isMainIssue(row)) && !isHidden(row))
.map((row) => {
const element = (row["CSS selector"] || row["Component"] || "").trim();
return {
key: [
row["Issue type"],
element,
samePage(row["Affected page"] ?? ""),
].join("|"),
issue_type: row["Issue type"],
severity: (row["Severity"] || "unknown").trim(),
component: row["Component"],
affected_page: row["Affected page"],
};
});
}

/** Which issues of `current` are new since `base`, which of `base` are gone, and how many remain. */
export function compareIssues(
base: RunIssue[],
current: RunIssue[],
): { added: RunIssue[]; resolved: RunIssue[]; retained: number } {
const before = new Set(base.map((issue) => issue.key));
const after = new Set(current.map((issue) => issue.key));
const bySeverity = (a: RunIssue, b: RunIssue) =>
(SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99);
return {
added: current.filter((issue) => !before.has(issue.key)).sort(bySeverity),
resolved: base.filter((issue) => !after.has(issue.key)).sort(bySeverity),
retained: current.filter((issue) => before.has(issue.key)).length,
};
}

export async function parseAccessibilityReportFromCSV(
reportLink: string,
{
maxCharacterLength = 10_000,
nextPage = 0,
pageUrl,
}: PaginationOptions = {},
): Promise<PaginatedResult> {
// 1) Download & parse
const { parsed, columns } = await loadReport(reportLink);

if (pageUrl) {
const { all, page } = pageIssues(parsed, columns, pageUrl);
const slice = pageOf(all, maxCharacterLength, nextPage);
return {
...slice,
page_length: slice.records.length,
total_issues: all.length,
duplicate_issues: null,
unlinked_duplicates: 0,
hidden_issues: null,
page,
};
}

// 2) Leave out hidden issues and fold duplicates into their main issue, as the
// dashboard's count does.
Expand All @@ -107,14 +345,7 @@ export async function parseAccessibilityReportFromCSV(
const all: SimplifiedAccessibilityIssue[] = visibleMainRows.map(
(row: any) => {
const record: SimplifiedAccessibilityIssue = {
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] } : {}),
...recordOfRow(row, hasSourceTest),
...(linkable ? { duplicate_count: 0 } : {}),
};
recordOf.set(row, record);
Expand Down Expand Up @@ -150,39 +381,17 @@ export async function parseAccessibilityReportFromCSV(

const totalIssues = all.length;

// 3) Sort by severity
const order: Record<string, number> = {
critical: 0,
serious: 1,
moderate: 2,
minor: 3,
};
all.sort((a, b) => (order[a.severity] ?? 99) - (order[b.severity] ?? 99));

// 4) Walk to the starting offset
let charCursor = 0;
let idx = 0;
for (; idx < all.length; idx++) {
const len = JSON.stringify(all[idx]).length;
if (charCursor + len > nextPage) break;
charCursor += len;
}

// 5) Collect up to maxCharacterLength
const page: SimplifiedAccessibilityIssue[] = [];
for (let i = idx; i < all.length; i++) {
const recStr = JSON.stringify(all[i]);
if (charCursor - nextPage + recStr.length > maxCharacterLength) break;
page.push(all[i]);
charCursor += recStr.length;
}

// 3) Most severe first, in pages by JSON length
const { records: page, next_page } = pageOf(
all,
maxCharacterLength,
nextPage,
);
const pageLength = page.length;

const hasMore = idx + pageLength < totalIssues;
return {
records: page,
next_page: hasMore ? charCursor : null,
next_page,
page_length: pageLength,
total_issues: totalIssues,
duplicate_issues: grouped ? duplicates : null,
Expand Down
Loading
Loading