Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion capability/app_accessibility.capability-index.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "1.0",
"build_id": "app_accessibility-v1.0-lists",
"app_accessibility": {
"summary": "BrowserStack App Accessibility: WCAG accessibility results for mobile apps (Android and iOS), from automated App Automate tests run with accessibility checks on, and from manual App Live sessions scanned with Workflow Analyzer. Find the account's App Accessibility projects, a project's builds, and its manual reports, each with the id, status, app and issue counts needed to pick one. These capabilities list results; they do not read a build's or report's individual issues, compare builds, start tests or scans, or change or delete anything. Website accessibility (Website Scanner, Automate web tests) is not here.",
"summary": "BrowserStack App Accessibility: WCAG accessibility results for mobile apps (Android and iOS), from automated App Automate tests run with accessibility checks on, and from manual App Live sessions scanned with Workflow Analyzer. Find the account's App Accessibility projects, a project's builds, and its manual reports, each with the id, status, app and issue counts needed to pick one. These capabilities list results; they do not read a build's or report's individual issues, compare builds, start tests or scans, or change or delete anything. Websites are covered by a separate product.",
"routing_terms": [
"app accessibility",
"app a11y",
Expand Down
1,274 changes: 1,274 additions & 0 deletions capability/web_accessibility.capability-index.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ searchCapability → describeCapability → invokeCapability
| `list_app_accessibility_builds` | List an App Accessibility project's builds newest first, with each build's id, status, app and issue counts - use this to find the latest run, a build by name, or one person's builds. |
| `list_app_accessibility_projects` | List the account's App Accessibility projects newest first, or find one by name - use this to get the project id that lists its builds. |
| `list_manual_app_accessibility_reports` | List manual App Accessibility reports (App Live sessions scanned with Workflow Analyzer) newest first, with each report's id, app, status and issue counts - use this to find a report QA shared or the latest manual scan of an app. |
| `list_assisted_test_reports` | List the account's Assisted Test reports newest first, with each report's id, name, creator, status, test type and issue counts - use this to find a guided keyboard, forms or other assisted check someone ran. |
| `list_automated_tests_builds` | List the account's Automated Tests builds newest first, or one project's, with each build's id, number, name, creator, status and issue counts - use this to find a suite's latest run, someone's builds, or the build id that reads its issues. |
| `list_automated_tests_projects` | List the account's Automated Tests projects newest first, with each project's id, name and creation date - use this to find the project name that narrows its builds. |
| `list_website_scanner_scan_runs` | List one Website Scanner scan's runs newest first, with each run's id, date, issue and component counts and how many pages loaded - use this to pick an earlier run or follow a site's issues over time. |
| `list_website_scanner_scans` | List the account's Website Scanner scans, most recently run first, with each scan's id, name, creator, dashboard link and latest run - use this to find a scan by name or person, or the scan and run ids that read its issues. |
| `list_workflow_analyzer_reports` | List the account's Workflow Analyzer reports newest first, with each report's id, name, creator, status, settings and issue counts - use this to find someone's latest reports, a report by name, or the id that reads its issues. |

</details>

Expand Down
12 changes: 8 additions & 4 deletions src/tools/accessibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1678,24 +1678,28 @@ export default function addAccessibilityTools(
scanId: z
.string()
.optional()
.describe("Website Scanner: the scan id from startAccessibilityScan."),
.describe(
"Website Scanner scan id: from startAccessibilityScan or list_website_scanner_scans (searchCapability).",
),
scanRunId: z
.string()
.optional()
.describe(
"Website Scanner: the scan run id from startAccessibilityScan.",
"Website Scanner run id: from startAccessibilityScan, or list_website_scanner_scan_runs (searchCapability).",
),
buildId: z
.string()
.regex(/^[a-z0-9]{40}$/, "40 lowercase letters and digits")
.optional()
.describe(
"Web or App Accessibility build id: the SDK's testhub id, a web link's thBuildId, or an app build via searchCapability.",
"Web or App Accessibility build id: the SDK's testhub id, a link's thBuildId, or one listed via searchCapability.",
),
workflowReportId: z
.string()
.optional()
.describe("Workflow Analyzer: the report's numeric id."),
.describe(
"Workflow Analyzer report id: from its link, or list_workflow_analyzer_reports (searchCapability).",
),
appReportId: z
.string()
.regex(
Expand Down
25 changes: 16 additions & 9 deletions src/tools/capability-registry/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ export interface InvokeResult {
/**
* Whether this response is the whole answer.
*
* False when the envelope itself says there is another page (`info.next`), so a caller
* knows to ask for one rather than assuming it has everything. This is a peek at one
* declared field, not a reshaping of the body.
* False when the envelope itself says there is another page (`info.next`, or
* `data.pagination.hasNext` / `has_next`), so a caller knows to ask for one rather than
* assuming it has everything. This is a peek at one declared field, not a reshaping of
* the body.
*/
completed: boolean;
/** The product's status, and its body exactly as sent. */
Expand All @@ -42,12 +43,18 @@ export interface InvokeResult {
}

function hasNextPage(body: unknown): boolean {
if (typeof body !== "object" || body === null || Array.isArray(body))
return false;
const info = (body as Record<string, unknown>).info;
if (typeof info !== "object" || info === null) return false;
const next = (info as Record<string, unknown>).next;
return next !== null && next !== undefined && next !== false;
const record = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
const envelope = record(body);
if (!envelope) return false;
const next = record(envelope.info)?.next;
if (next !== null && next !== undefined && next !== false) return true;
// Accessibility's lists page under data.pagination instead, camelCase on some and
// snake_case on others.
const pagination = record(record(envelope.data)?.pagination);
return pagination?.hasNext === true || pagination?.has_next === true;
}

/**
Expand Down
10 changes: 6 additions & 4 deletions src/tools/tool-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,14 @@ export const NEEDS_SESSION_ID =
/**
* A scan run's ids come from startAccessibilityScan; a build's id is printed by the SDK at
* the end of its run. The tool takes a report's dashboard link, or an Automate link, and
* looks the ids up itself; for a past run, Ask AI can locate it. App Accessibility's builds
* and manual reports are listed by its capabilities, which the id arguments name, as this
* line has no room left within the description budget.
* looks the ids up itself. For a past report, the web_accessibility capabilities list
* Workflow Analyzer reports, scans and their runs, and Automated Tests builds with the id
* each takes; App Accessibility's builds and manual reports are listed by its own
* capabilities, which the id arguments name, as this line has no room left within the
* description budget.
*/
export const NEEDS_A11Y_SCAN_ID =
' Ids: startAccessibilityScan, a link, or askBrowserStackAI with product "a11y".';
" Ids: a link, startAccessibilityScan, or web_accessibility lists (searchCapability).";

/** Auth-config ids are not listable by any tool here; Ask AI can find one. */
export const NEEDS_A11Y_CONFIG_ID =
Expand Down
5 changes: 4 additions & 1 deletion tests/tools/askBrowserstackE2E.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,10 @@ describe("askBrowserStackAI, end to end through the server factory", () => {
const productText = shape?.product?.description as string;
for (const value of values) expect(productText).toContain(`${value} (`);

for (const handoff of [NEEDS_A11Y_SCAN_ID, NEEDS_A11Y_CONFIG_ID]) {
// Report ids are listed by the web_accessibility capabilities now, so that hand-off
// no longer sends the model to Ask AI; the auth-config one still does.
expect(NEEDS_A11Y_SCAN_ID).not.toMatch(/askBrowserStackAI/);
for (const handoff of [NEEDS_A11Y_CONFIG_ID]) {
const named = handoff.match(/askBrowserStackAI with product "([^"]+)"/)?.[1];
expect(named).toBeDefined();
expect(values).toContain(named);
Expand Down
2 changes: 1 addition & 1 deletion tests/tools/capabilityAppAccessibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe("the App Accessibility capability index", () => {

it.each([
["List my App Accessibility reports"],
["Show the Workflow Analyzer reports QA ran last week"],
["Show the manual app accessibility reports QA ran last week"],
["What builds does my app accessibility project have?"],
])("routes %j to App Accessibility without asking", (query) => {
expect(ambiguousProducts(products, query).settled).toBe(
Expand Down
16 changes: 16 additions & 0 deletions tests/tools/capabilityRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,22 @@ describe("invoke — one request, response returned untouched", () => {
expect(result.completed).toBe(false);
});

// Accessibility's lists page with a token under data.pagination (camelCase on some lists,
// snake_case on others) rather than info.next. Read as complete, an agent would stop at
// the first 20 reports and answer "the last report Priya ran" from the wrong page.
it.each([
[{ success: true, data: { reports: [], pagination: { hasNext: true, nextPage: "t" } } }, false],
[{ success: true, data: { builds: [], pagination: { has_next: true, next_page: "t" } } }, false],
[{ success: true, data: { scans: [], pagination: { hasNext: false, nextPage: null } } }, true],
[{ success: true, data: null, info: ["No Projects found"] }, true],
])("reads another page from a data.pagination envelope (%#)", async (body, completed) => {
const result = await invoke(
LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } },
"https://a11y.example", credentials, async () => ({ status: 200, body }),
);
expect(result.completed).toBe(completed);
});

it("mirrors a non-2xx and lets the product's own body explain it", async () => {
const transport = async () => ({
status: 422,
Expand Down
167 changes: 167 additions & 0 deletions tests/tools/capabilityWebAccessibility.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { describe, expect, it } from "vitest";
import { readdirSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

import {
ambiguousProducts,
searchCapabilities,
} from "../../src/tools/capability-registry/search.js";

// Web Accessibility's lists (Workflow Analyzer reports, Website Scanner scans and their runs,
// Automated Tests projects and builds, Assisted Test reports) are reached through the
// capability registry. Without them an agent asked for "the last 3 reports Priya ran" had
// no list to read and handed the question to Ask AI. What has to hold is that each list
// names the id fetchAccessibilityIssues reads, that the parameters the API ignores are not
// offered, and that the words users say reach the right product.

const DIR = new URL("../../capability/", import.meta.url);

function shipped(): Record<string, any> {
const products: Record<string, any> = {};
for (const file of readdirSync(fileURLToPath(DIR))) {
if (!file.endsWith(".capability-index.json")) continue;
const raw = JSON.parse(
readFileSync(fileURLToPath(new URL(file, DIR)), "utf8"),
);
const [name] = Object.keys(raw).filter(
(k) => !["schema_version", "version", "build_id"].includes(k),
);
products[name] = raw[name];
}
return products;
}

const products = shipped();
const web = products.web_accessibility;
const capability = (name: string) =>
web.capabilities.find((c: any) => c.name === name);
const queryNames = (name: string) =>
(capability(name).query ?? []).map((q: any) => q.name);

describe("the Web Accessibility capability index", () => {
it("ships the six lists, as paged reads, each on an entity that names it", () => {
const names = web.capabilities.map((c: any) => c.name).sort();
expect(names).toEqual([
"list_assisted_test_reports",
"list_automated_tests_builds",
"list_automated_tests_projects",
"list_website_scanner_scan_runs",
"list_website_scanner_scans",
"list_workflow_analyzer_reports",
]);
for (const c of web.capabilities) {
expect(c.mode).toBe("read");
expect(c.paginated).toBe(true);
expect(c.max_page_size).toBeGreaterThan(0);
expect(web.entities[c.entity].capabilities).toContain(c.name);
}
for (const entity of Object.values<any>(web.entities)) {
expect(entity.description.length).toBeLessThanOrEqual(140);
}
expect(web.summary.length).toBeLessThanOrEqual(900);
});

it("caps each page size where paging still works", () => {
const limit = (name: string) =>
capability(name).query.find((q: any) => q.name === "limit");
// 100 returns 30 reports and says there is no next page.
expect(limit("list_workflow_analyzer_reports").maximum).toBe(99);
expect(limit("list_assisted_test_reports").maximum).toBe(99);
// Above 25 the page holds 25 scans and the next one starts past the ones in between.
expect(limit("list_website_scanner_scans").maximum).toBe(25);
for (const c of web.capabilities) {
expect(c.max_page_size).toBe(
c.query.find((q: any) => q.name === "limit").maximum,
);
}
});

it("says a changed limit is refused only by the lists that refuse it", () => {
// All six answer a bad next_page with 422 (InvalidPageToken). Only the Website Scanner
// lists also answer a changed limit with 422; the Automated Tests lists answer it with
// 400 (PagingChanged), and the Workflow Analyzer and Assisted Test lists do not check
// it, so their pages shift instead.
const invalidToken = web.responses.InvalidPageToken.description;
expect(invalidToken).toMatch(/Website Scanner lists/);
expect(invalidToken).not.toMatch(/^[^.]*limit changed/);
for (const name of [
"list_automated_tests_projects",
"list_automated_tests_builds",
]) {
expect(capability(name).responses["400"]).toEqual({
$response: "PagingChanged",
});
}
});

it("does not offer the parameters the API ignores", () => {
// The public docs list these; the handlers never read them, so each would return the
// whole account's list while looking filtered.
for (const ignored of ["current_user_id", "team", "page", "page_size"]) {
expect(queryNames("list_website_scanner_scans")).not.toContain(ignored);
}
for (const ignored of ["projectId", "projectName", "project_id"]) {
expect(queryNames("list_automated_tests_builds")).not.toContain(ignored);
}
expect(queryNames("list_automated_tests_builds")).toContain("project_name");
});

it("says which field each list hands to fetchAccessibilityIssues", () => {
const guidance = (name: string) => capability(name).guidance.join(" ");
expect(guidance("list_workflow_analyzer_reports")).toMatch(
/workflowReportId/,
);
expect(guidance("list_website_scanner_scans")).toMatch(/scanId/);
expect(guidance("list_website_scanner_scans")).toMatch(/scanRunId/);
expect(guidance("list_website_scanner_scan_runs")).toMatch(/scanRunId/);
expect(guidance("list_automated_tests_builds")).toMatch(/buildId/);
// Assisted Test reports have no issue reader here, and saying so stops a wasted call.
expect(guidance("list_assisted_test_reports")).toMatch(
/fetchAccessibilityIssues does not read/,
);
});

it.each([
["List my website scanner scans"],
["Show the builds of my automated tests project"],
["Find the last 3 web workflow analyzer reports Priya ran"],
["What did the latest scan run of our website scan find?"],
])("routes %j to Web Accessibility", (query) => {
expect(ambiguousProducts(products, query).settled).toBe(
"web_accessibility",
);
});

it("asks web or app when only Workflow Analyzer is named, as both products have it", () => {
const { products: asked } = ambiguousProducts(
products,
"last 3 workflow analyzer reports by Priya",
);
expect(asked).toEqual(
expect.arrayContaining(["app_accessibility", "web_accessibility"]),
);
});

it("leaves App Accessibility and Test Reporting questions where they were", () => {
expect(
ambiguousProducts(products, "List my App Accessibility reports").settled,
).toBe("app_accessibility");
expect(
ambiguousProducts(products, "Which tests are flaky in my build?").settled,
).toBe("tra");
});

it.each([
["list workflow analyzer reports", "list_workflow_analyzer_reports"],
["list website scanner scans", "list_website_scanner_scans"],
["list the runs of a website scanner scan", "list_website_scanner_scan_runs"],
["list automated tests projects", "list_automated_tests_projects"],
["list the builds of an automated tests project", "list_automated_tests_builds"],
["list assisted test reports", "list_assisted_test_reports"],
])("finds %j as %s", (query, expected) => {
const { hits } = searchCapabilities(products, query, {
product: "web_accessibility",
});
expect(hits[0]?.capability.name).toBe(expected);
});
});
Loading