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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/lib/accessibility-routing.d.ts +5 -0
  2. package/dist/lib/accessibility-routing.js +5 -0
  3. package/dist/server-factory.js +6 -1
  4. package/dist/tools/accessibility.js +588 -67
  5. package/dist/tools/accessiblity-utils/app-report-fetcher.d.ts +341 -0
  6. package/dist/tools/accessiblity-utils/app-report-fetcher.js +731 -0
  7. package/dist/tools/accessiblity-utils/paginate.d.ts +21 -0
  8. package/dist/tools/accessiblity-utils/paginate.js +44 -0
  9. package/dist/tools/accessiblity-utils/report-fetcher.d.ts +116 -7
  10. package/dist/tools/accessiblity-utils/report-fetcher.js +273 -30
  11. package/dist/tools/accessiblity-utils/report-parser.d.ts +41 -1
  12. package/dist/tools/accessiblity-utils/report-parser.js +142 -43
  13. package/dist/tools/appautomate-utils/appium-sdk/config-generator.d.ts +2 -0
  14. package/dist/tools/appautomate-utils/appium-sdk/config-generator.js +15 -5
  15. package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +2 -1
  16. package/dist/tools/appautomate-utils/appium-sdk/constants.js +6 -1
  17. package/dist/tools/appautomate-utils/appium-sdk/handler.js +11 -1
  18. package/dist/tools/appautomate-utils/appium-sdk/instructions.d.ts +3 -1
  19. package/dist/tools/appautomate-utils/appium-sdk/instructions.js +2 -2
  20. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.d.ts +2 -0
  21. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.js +32 -0
  22. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.d.ts +3 -1
  23. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.js +19 -6
  24. package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +7 -2
  25. package/dist/tools/appautomate-utils/native-execution/appautomate.js +15 -12
  26. package/dist/tools/appautomate-utils/native-execution/constants.d.ts +2 -1
  27. package/dist/tools/appautomate-utils/native-execution/constants.js +6 -1
  28. package/dist/tools/appautomate.js +29 -16
  29. package/dist/tools/failurelogs-utils/app-automate.js +1 -1
  30. package/dist/tools/testmanagement-utils/create-testrun.d.ts +1 -1
  31. package/dist/tools/testmanagement-utils/update-testrun.d.ts +1 -1
  32. package/dist/tools/tool-handoff.d.ts +5 -4
  33. package/dist/tools/tool-handoff.js +5 -10
  34. package/package.json +1 -1
  35. package/dist/tools/accessiblity-utils/accessibility-rag.d.ts +0 -12
  36. package/dist/tools/accessiblity-utils/accessibility-rag.js +0 -59
@@ -0,0 +1,731 @@
1
+ import { apiClient } from "../../lib/apiClient.js";
2
+ import { SessionType } from "../../lib/constants.js";
3
+ import { sessionsListUrl } from "../automate-utils/list-session-ids.js";
4
+ import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js";
5
+ import { pageOf, sortBySeverity } from "./paginate.js";
6
+ /**
7
+ * App Accessibility: the issues of a mobile app build run through App Automate with
8
+ * accessibility on. Unlike the web products there is no CSV export behind an access key,
9
+ * so the issues come inline, one API page per batch of scanned screens.
10
+ */
11
+ export const APP_API_BASE = "https://api-app-accessibility.browserstack.com/automate/api/v1";
12
+ export const APP_DASHBOARD = "https://app-accessibility.browserstack.com";
13
+ /** Manual (Workflow Analyzer) reports recorded on the dashboard: a separate, older route. */
14
+ export const MANUAL_API_BASE = "https://api-app-accessibility.browserstack.com/api/v1";
15
+ /** Screens per API page, and how many pages a build or report is read across (1,000 screens). */
16
+ const SNAPSHOT_PAGE_SIZE = 50;
17
+ export const MAX_SNAPSHOT_PAGES = 20;
18
+ /** A dashboard link names a build by number; a build someone just opened is among the newest. */
19
+ export const MAX_APP_BUILD_LIST_PAGES = 3;
20
+ const BUILD_LIST_PAGE_SIZE = 30;
21
+ /** A native build is found by name in the account's App Automate build list, newest first. */
22
+ const APP_AUTOMATE_BUILDS_URL = "https://api-cloud.browserstack.com/app-automate/builds.json";
23
+ const APP_AUTOMATE_BUILDS_PAGE_SIZE = 100;
24
+ export const MAX_APP_AUTOMATE_BUILD_PAGES = 3;
25
+ /** A native run's accessibility build is created as the run starts: seconds apart, in practice. */
26
+ const NATIVE_BUILD_START_WINDOW_MS = 2 * 60 * 1000;
27
+ /** The element details worth repeating; the rest are the rule's own inputs. */
28
+ const ELEMENT_DETAILS = new Set([
29
+ "className",
30
+ "viewId",
31
+ "resourceId",
32
+ "accessibilityIdentifier",
33
+ "accessibilityLabel",
34
+ "text",
35
+ "contentDescription",
36
+ "frame",
37
+ ]);
38
+ /** A detail's value is JSON-encoded ('"android.widget.Button"', '{"x":63,…}') when it can be. */
39
+ function detailValue(value) {
40
+ if (typeof value !== "string")
41
+ return value;
42
+ try {
43
+ return JSON.parse(value);
44
+ }
45
+ catch {
46
+ return value;
47
+ }
48
+ }
49
+ function elementOf(issue) {
50
+ const element = {};
51
+ for (const { label, value } of issue.issue_details ?? []) {
52
+ if (!label || !ELEMENT_DETAILS.has(label))
53
+ continue;
54
+ const parsed = detailValue(value);
55
+ if (parsed === "" || parsed === null || parsed === undefined)
56
+ continue;
57
+ element[label] = parsed;
58
+ }
59
+ // The class is the element's kind even when the details leave it out.
60
+ const className = issue.issue_class_android || issue.issue_class_ios;
61
+ if (className && element.className === undefined) {
62
+ element.className = className;
63
+ }
64
+ return element;
65
+ }
66
+ /**
67
+ * The fix text is light markup (`<document>`, `<code>`); the words are what matter. Text
68
+ * inside angle brackets is dropped, and so is every bracket, character by character:
69
+ * nested markup such as `<scr<script>ipt>` cannot leave a tag behind, as a regex
70
+ * replacement of whole tags could.
71
+ */
72
+ function plainText(markup) {
73
+ let text = "";
74
+ let inTag = false;
75
+ for (const ch of markup ?? "") {
76
+ if (ch === "<")
77
+ inTag = true;
78
+ else if (ch === ">")
79
+ inTag = false;
80
+ else if (!inTag)
81
+ text += ch;
82
+ }
83
+ return text.replace(/\s+/g, " ").trim();
84
+ }
85
+ /** Issues the dashboard does not count: hidden by the user, or marked as not an issue. */
86
+ function isLeftOut(issue) {
87
+ return issue.status === "hidden" || issue.status === "non_issue";
88
+ }
89
+ /** One row per element with an issue, most severe first. Screens are numbered in order. */
90
+ export function flattenAppSnapshots(details) {
91
+ const rows = [];
92
+ details.forEach((snapshot, index) => {
93
+ const test = snapshot.meta?.testcase_name;
94
+ const screen = `Screen ${index + 1}${test ? ` (${test})` : ""}`;
95
+ const rules = snapshot.aggregated_results ?? snapshot.meta?.aggregated_results ?? [];
96
+ for (const rule of rules) {
97
+ const wcag = (rule.guidelines ?? [])
98
+ .map((g) => g.message)
99
+ .filter(Boolean)
100
+ .join("; ");
101
+ for (const issue of rule.rule_issues ?? []) {
102
+ if (isLeftOut(issue))
103
+ continue;
104
+ rows.push({
105
+ issue_type: rule.label,
106
+ rule_id: rule.rule_id,
107
+ severity: (rule.severity ?? "unknown").toLowerCase(),
108
+ screen,
109
+ component: issue.issue_id_android ||
110
+ issue.issue_id_ios ||
111
+ issue.issue_class_android ||
112
+ issue.issue_class_ios ||
113
+ rule.label,
114
+ element: elementOf(issue),
115
+ issue_description: rule.description ?? "",
116
+ how_to_fix: plainText(issue.how_to_fix_desc),
117
+ wcag,
118
+ ...(snapshot.meta?.screenshot_url
119
+ ? { screenshot: snapshot.meta.screenshot_url }
120
+ : {}),
121
+ ...(issue.duplication_count
122
+ ? { duplicate_count: issue.duplication_count }
123
+ : {}),
124
+ });
125
+ }
126
+ }
127
+ });
128
+ return sortBySeverity(rows);
129
+ }
130
+ function countsOf(rows, keyOf) {
131
+ const counts = new Map();
132
+ for (const row of rows) {
133
+ counts.set(keyOf(row), (counts.get(keyOf(row)) ?? 0) + 1);
134
+ }
135
+ return counts;
136
+ }
137
+ /** Most first; ties keep the order the rows came in, which is most severe first. */
138
+ function largestFirst(counts) {
139
+ return [...counts].sort((a, b) => b[1] - a[1]);
140
+ }
141
+ /** The issues counted by severity, by rule and by screen, largest first. */
142
+ export function summarizeAppIssues(rows) {
143
+ const ruleNames = new Map(rows.map((r) => [r.rule_id, r.issue_type]));
144
+ return {
145
+ by_severity: Object.fromEntries(countsOf(rows, (r) => r.severity)),
146
+ by_rule: largestFirst(countsOf(rows, (r) => r.rule_id)).map(([rule_id, count]) => ({
147
+ rule_id,
148
+ issue_type: ruleNames.get(rule_id) ?? rule_id,
149
+ count,
150
+ })),
151
+ by_screen: largestFirst(countsOf(rows, (r) => r.screen)).map(([screen, count]) => ({ screen, count })),
152
+ };
153
+ }
154
+ /**
155
+ * The same issue in two builds: the same rule on the same element. An element is known by
156
+ * its id; one without an id, by its class (or, with no class either, as an iOS element can
157
+ * be, only by its rule) and its text or label. The screen is left out, as screens are
158
+ * numbered in the order they were scanned, which a fix can shift.
159
+ */
160
+ function appIssueKey(row) {
161
+ const unnamed = row.component === row.element.className || row.component === row.issue_type;
162
+ const label = row.element.text ?? row.element.accessibilityLabel ?? "";
163
+ return [row.rule_id, row.component, unnamed ? label : ""]
164
+ .map(String)
165
+ .join("|");
166
+ }
167
+ const sameFrame = (a, b) => JSON.stringify(a.element.frame ?? null) ===
168
+ JSON.stringify(b.element.frame ?? null);
169
+ /**
170
+ * Which issues of two builds are the same issue. Issues that look alike (one rule on
171
+ * elements known by neither id nor label) are paired one for one, those on the same screen
172
+ * and spot first, then on the same screen, then any; what is left over in either build is
173
+ * new or resolved. So new and still open add up to the later build's issues, and resolved
174
+ * and still open to the earlier one's.
175
+ */
176
+ export function compareAppIssues(before, after) {
177
+ const earlierByKey = new Map();
178
+ for (const row of before) {
179
+ const key = appIssueKey(row);
180
+ earlierByKey.set(key, [...(earlierByKey.get(key) ?? []), row]);
181
+ }
182
+ const matched = new Set();
183
+ const passes = [
184
+ (a, b) => a.screen === b.screen && sameFrame(a, b),
185
+ (a, b) => a.screen === b.screen,
186
+ () => true,
187
+ ];
188
+ for (const pairs of passes) {
189
+ for (const row of after) {
190
+ if (matched.has(row))
191
+ continue;
192
+ const twin = earlierByKey
193
+ .get(appIssueKey(row))
194
+ ?.find((earlier) => !matched.has(earlier) && pairs(row, earlier));
195
+ if (twin) {
196
+ matched.add(row);
197
+ matched.add(twin);
198
+ }
199
+ }
200
+ }
201
+ const added = after.filter((row) => !matched.has(row));
202
+ return {
203
+ added,
204
+ resolved: before.filter((row) => !matched.has(row)),
205
+ retained: after.length - added.length,
206
+ };
207
+ }
208
+ const APP_AUTOMATE_HOST = "app-automate.browserstack.com";
209
+ /** The App Automate build and, when the link names one, the session (both 40-hex ids). */
210
+ function appAutomateIds(url) {
211
+ if (url.hostname !== APP_AUTOMATE_HOST)
212
+ return undefined;
213
+ return {
214
+ buildId: url.pathname.match(/\/builds\/([0-9a-f]{40})(?:\/|$)/i)?.[1],
215
+ sessionId: url.pathname.match(/\/sessions\/([0-9a-f]{40})(?:\/|$)/i)?.[1],
216
+ };
217
+ }
218
+ /** The id a manual report's dashboard link names (`/report/{uuid}`), if it is one. */
219
+ export function manualReportIdFromDashboardLink(url) {
220
+ if (url.hostname !== new URL(APP_DASHBOARD).hostname)
221
+ return undefined;
222
+ return url.pathname.match(/^\/report\/([0-9a-f-]{36})\/?$/i)?.[1];
223
+ }
224
+ /** A manual report has no status of its own; it is complete once it has ended. */
225
+ export function manualReportStatus(detail) {
226
+ return detail.meta_data?.ended_at ? "complete" : "in_progress";
227
+ }
228
+ /** The severity counts of an issue summary, each defaulting to 0. */
229
+ function severityCounts(summary) {
230
+ const sev = summary.severity_count ?? {};
231
+ return {
232
+ total: summary.total ?? 0,
233
+ critical: sev.critical ?? 0,
234
+ serious: sev.serious ?? 0,
235
+ moderate: sev.moderate ?? 0,
236
+ minor: sev.minor ?? 0,
237
+ };
238
+ }
239
+ /** A link's CGI-escaped name ("+" for a space), read the same however it was escaped. */
240
+ function unescapedName(name) {
241
+ try {
242
+ return decodeURIComponent(name.replace(/\+/g, " "));
243
+ }
244
+ catch {
245
+ return name;
246
+ }
247
+ }
248
+ /** The build an App Accessibility dashboard link names: project id, build name and number. */
249
+ function buildOfDashboardLink(url) {
250
+ const match = url.pathname.match(/\/automated-tests\/projects\/(\d+)\/builds\/([^/]+)\/(\d+)\/?$/);
251
+ if (!match)
252
+ return undefined;
253
+ // The name in the link is the build's normalised name (TestHub's, which can differ from
254
+ // the build's own), and the number counts up per name: a project's first build of each
255
+ // name is number 1. The two together name one build.
256
+ return {
257
+ projectId: match[1],
258
+ name: unescapedName(match[2]),
259
+ number: Number(match[3]),
260
+ };
261
+ }
262
+ export class AppAccessibilityApi {
263
+ headers;
264
+ constructor(headers) {
265
+ this.headers = headers;
266
+ }
267
+ /** The build's details, or undefined when the API does not know the id. */
268
+ async buildMeta(buildId) {
269
+ const resp = await apiClient.get({
270
+ url: `${APP_API_BASE}/build/${encodeURIComponent(buildId)}`,
271
+ headers: this.headers(),
272
+ raise_error: false,
273
+ });
274
+ return resp.ok ? resp.data?.meta_data : undefined;
275
+ }
276
+ /**
277
+ * The id of the build an App Accessibility dashboard link points at, if it is one: the
278
+ * project's newest builds, by number. (The list's name filter would not do: the link
279
+ * carries TestHub's name for the build, which the filter does not match.)
280
+ */
281
+ async buildIdFromDashboardLink(url) {
282
+ if (url.hostname !== new URL(APP_DASHBOARD).hostname)
283
+ return undefined;
284
+ const build = buildOfDashboardLink(url);
285
+ if (!build)
286
+ return undefined;
287
+ const buildId = await this.findBuild(build.projectId, undefined, (row) => row.build_number === build.number &&
288
+ (row.normalised_name === undefined ||
289
+ unescapedName(row.normalised_name) === build.name));
290
+ if (buildId)
291
+ return buildId;
292
+ throw new Error(`No build ${build.number} of "${build.name}" is among the project's newest builds (${MAX_APP_BUILD_LIST_PAGES} pages). Pass the build id instead, as buildId: the BrowserStack SDK prints it as "testhub id" at the end of the run`);
293
+ }
294
+ /**
295
+ * The accessibility build of an App Automate build or session link. An SDK (Appium)
296
+ * session's test code carries the TestHub build id its results went to, which is the
297
+ * App Accessibility build when accessibility was on; a build link goes through any one
298
+ * of its sessions. A native (Espresso, XCUITest) build has no SDK session, and is found
299
+ * by name instead.
300
+ */
301
+ async buildIdFromAppAutomateLink(url) {
302
+ const ids = appAutomateIds(url);
303
+ if (!ids?.buildId && !ids?.sessionId)
304
+ return undefined;
305
+ if (ids.sessionId)
306
+ return this.buildOfSdkSession(ids.sessionId);
307
+ const session = await this.anySdkSession(ids.buildId);
308
+ return session
309
+ ? this.buildOfSdkSession(session)
310
+ : this.buildOfNativeBuild(ids.buildId);
311
+ }
312
+ async buildOfSdkSession(sessionId) {
313
+ const resp = await apiClient.get({
314
+ url: `${getAutomationBaseUrl()}/ext/v1/sessions/${encodeURIComponent(sessionId)}/testCode`,
315
+ headers: this.headers(),
316
+ raise_error: false,
317
+ });
318
+ // A session with no test code is a 404; any other failure is the request's, not the run's.
319
+ if (!resp.ok && resp.status !== 404) {
320
+ throw new Error(`Could not read App Automate session ${sessionId}: HTTP ${resp.status}`);
321
+ }
322
+ const buildId = resp.ok ? resp.data?.build_uuid : undefined;
323
+ if (!buildId) {
324
+ throw new Error(`App Automate session ${sessionId} has no accessibility report: accessibility was not on for this run, or this is not an SDK (Appium) session. Run it again with accessibility on (browserstack.yml \`accessibility: true\`)`);
325
+ }
326
+ return buildId;
327
+ }
328
+ /** The first SDK session of an App Automate build; a native build lists none. */
329
+ async anySdkSession(buildId) {
330
+ const resp = await apiClient.get({
331
+ url: `${sessionsListUrl(SessionType.AppAutomate, buildId)}?limit=1`,
332
+ headers: this.headers(),
333
+ raise_error: false,
334
+ });
335
+ return resp.ok ? resp.data?.[0]?.automation_session?.hashed_id : undefined;
336
+ }
337
+ /**
338
+ * A native build's details name its project and when it started; App Accessibility lists
339
+ * the run under the same project, created as it started, and named as the App Automate
340
+ * build list names it ("Build #N: app vX"). The start finds it among the project's newest
341
+ * builds; the name, read from the account's build list, tells runs that started together
342
+ * apart, and finds a run older than those.
343
+ */
344
+ async buildOfNativeBuild(buildId) {
345
+ const native = await this.nativeBuild(buildId);
346
+ if (!native?.project) {
347
+ throw new Error(`No session found for App Automate build ${buildId}, and it is not an Espresso or XCUITest build with a project, so it cannot be mapped to its accessibility report. Pass the build id the BrowserStack SDK printed as "testhub id" as buildId, or the build's App Accessibility dashboard link as reportUrl`);
348
+ }
349
+ const { project, startedAt } = native;
350
+ const projectId = await this.projectId(project);
351
+ const started = projectId !== undefined && startedAt !== undefined
352
+ ? await this.buildsStartedNear(projectId, startedAt)
353
+ : [];
354
+ if (started.length === 1)
355
+ return started[0].th_build_id;
356
+ const name = await this.appAutomateBuildName(buildId);
357
+ if (!name) {
358
+ throw new Error(`App Automate build ${buildId} is not among the account's newest builds (${MAX_APP_AUTOMATE_BUILD_PAGES} pages of ${APP_AUTOMATE_BUILDS_PAGE_SIZE}), so its accessibility report cannot be found by name. Pass the build's App Accessibility dashboard link as reportUrl instead`);
359
+ }
360
+ const found = projectId !== undefined &&
361
+ (started.find((row) => row.name === name)?.th_build_id ??
362
+ (await this.findBuild(projectId, name, (row) => row.name === name)));
363
+ if (!found) {
364
+ throw new Error(`No App Accessibility build named "${name}" in project "${project}": accessibility was not on for this run. Run it again with \`accessibility: true\` in the Espresso/XCUITest build request`);
365
+ }
366
+ return found;
367
+ }
368
+ /** A native build's project and start, from whichever framework API knows the build. */
369
+ async nativeBuild(buildId) {
370
+ for (const framework of ["espresso", "xcuitest"]) {
371
+ const resp = await apiClient.get({
372
+ url: `https://api-cloud.browserstack.com/app-automate/${framework}/v2/builds/${encodeURIComponent(buildId)}`,
373
+ headers: this.headers(),
374
+ raise_error: false,
375
+ });
376
+ if (!resp.ok)
377
+ continue;
378
+ // "2026-03-04 10:30:40 UTC"
379
+ const startedAt = Date.parse(resp.data?.start_time ?? "");
380
+ return {
381
+ project: resp.data?.input_capabilities?.project,
382
+ ...(Number.isNaN(startedAt) ? {} : { startedAt }),
383
+ };
384
+ }
385
+ return undefined;
386
+ }
387
+ /** The project's builds created within moments of a run's start, newest first. */
388
+ async buildsStartedNear(projectId, startedAt) {
389
+ const near = [];
390
+ const created = (row) => Date.parse(row.created_at ?? "");
391
+ for await (const { rows } of this.projectBuilds(projectId)) {
392
+ near.push(...rows.filter((row) => Math.abs(created(row) - startedAt) <= NATIVE_BUILD_START_WINDOW_MS));
393
+ // Newest first: a page that reaches back before the run leaves nothing to find after it.
394
+ const oldest = rows.length ? created(rows[rows.length - 1]) : NaN;
395
+ if (oldest < startedAt - NATIVE_BUILD_START_WINDOW_MS)
396
+ break;
397
+ }
398
+ return near;
399
+ }
400
+ /** The build's name in the account's App Automate build list, walked newest first. */
401
+ async appAutomateBuildName(buildId) {
402
+ for (let page = 0; page < MAX_APP_AUTOMATE_BUILD_PAGES; page++) {
403
+ const resp = await apiClient.get({
404
+ url: `${APP_AUTOMATE_BUILDS_URL}?limit=${APP_AUTOMATE_BUILDS_PAGE_SIZE}&offset=${page * APP_AUTOMATE_BUILDS_PAGE_SIZE}`,
405
+ headers: this.headers(),
406
+ raise_error: false,
407
+ });
408
+ const rows = resp.ok && Array.isArray(resp.data) ? resp.data : [];
409
+ const row = rows.find((r) => r.automation_build?.hashed_id === buildId);
410
+ if (row)
411
+ return row.automation_build?.name;
412
+ if (rows.length < APP_AUTOMATE_BUILDS_PAGE_SIZE)
413
+ break;
414
+ }
415
+ return undefined;
416
+ }
417
+ /** The App Accessibility project of that exact name, if any. */
418
+ async projectId(name) {
419
+ return (await this.projectsNamed(name)).find((p) => p.name === name)?.id;
420
+ }
421
+ /** The projects whose name contains `name`, as the API searches it. */
422
+ async projectsNamed(name) {
423
+ const resp = await apiClient.get({
424
+ url: `${APP_API_BASE}/project?name=${encodeURIComponent(name)}`,
425
+ headers: this.headers(),
426
+ raise_error: false,
427
+ });
428
+ return resp.ok ? (resp.data?.projects ?? []) : [];
429
+ }
430
+ /**
431
+ * A listing's page. The API answers an empty list with 404 ("No builds found") for
432
+ * access-key callers, which is not an error here; any other failure is, a 404 from a
433
+ * wrong route or a missing permission included.
434
+ */
435
+ async listing(url, what) {
436
+ const resp = await apiClient.get({
437
+ url,
438
+ headers: this.headers(),
439
+ raise_error: false,
440
+ });
441
+ if (resp.ok)
442
+ return resp.data ?? {};
443
+ if (resp.status === 404 && /^No .+ found/i.test(resp.data?.message ?? "")) {
444
+ return {};
445
+ }
446
+ throw new Error(`Could not list ${what}: HTTP ${resp.status}${resp.data?.message ? ` (${resp.data.message})` : ""}`);
447
+ }
448
+ /** The newest projects, as many as asked for. */
449
+ async listAppProjects(limit) {
450
+ const data = await this.listing(`${APP_API_BASE}/project?page_size=${limit}`, "App Accessibility projects");
451
+ const rows = (data.projects ?? []).map((p) => ({
452
+ id: p.id,
453
+ name: p.name,
454
+ ...(p.created_at ? { created_at: p.created_at } : {}),
455
+ }));
456
+ return { projects: rows, total: data.info?.count ?? rows.length };
457
+ }
458
+ /**
459
+ * A project's newest builds, by exact project name, optionally filtered by build name.
460
+ * A project name that matches none exactly names the near matches, so the agent can
461
+ * pick rather than guess.
462
+ */
463
+ async listAppBuilds(projectName, buildName, limit) {
464
+ const candidates = await this.projectsNamed(projectName);
465
+ const project = candidates.find((p) => p.name === projectName);
466
+ if (!project) {
467
+ const near = candidates.map((p) => `"${p.name}"`).join(", ");
468
+ throw new Error(`No project named "${projectName}"${near ? `. Did you mean: ${near}` : ""}. Call listAppAccessibilityReports without projectName to list the projects`);
469
+ }
470
+ const data = await this.listing(`${APP_API_BASE}/project/${project.id}/build?page_size=${limit}${buildName ? `&build_name=${encodeURIComponent(buildName)}` : ""}`, `the builds of "${project.name}"`);
471
+ const rows = data.builds ?? [];
472
+ const builds = rows.map((b) => {
473
+ const framework = b.session_data?.frameworkName;
474
+ const language = b.session_data?.language;
475
+ const app = b.app_details?.name ?? b.app_details?.package_name;
476
+ return {
477
+ buildId: b.th_build_id,
478
+ build_number: b.build_number,
479
+ name: b.name,
480
+ status: b.status,
481
+ report_status: b.report_status,
482
+ created_at: b.created_at,
483
+ scan_count: b.issue_summary?.scan_count ?? 0,
484
+ issues: severityCounts(b.issue_summary ?? {}),
485
+ ...(app ? { app } : {}),
486
+ ...(framework
487
+ ? { framework: language ? `${framework} (${language})` : framework }
488
+ : {}),
489
+ dashboard_link: `${APP_DASHBOARD}/automated-tests/projects/${project.id}/builds/${b.normalised_name}/${b.build_number}`,
490
+ };
491
+ });
492
+ return {
493
+ project: { id: project.id, name: project.name },
494
+ total: data.info?.count ?? builds.length,
495
+ builds,
496
+ };
497
+ }
498
+ /** The newest manual reports, optionally filtered by name. */
499
+ async listAppManualReports(name, limit) {
500
+ const data = await this.listing(`${MANUAL_API_BASE}/reports?page_size=${limit}${name ? `&report_name=${encodeURIComponent(name)}` : ""}`, "manual App Accessibility reports");
501
+ const rows = data.reports ?? [];
502
+ const reports = rows.map((r) => {
503
+ const d = r.app_details;
504
+ const app = d?.name
505
+ ? `${d.name}${d.version ? ` ${d.version}` : ""}${d.package_name ? ` (${d.package_name})` : ""}`
506
+ : undefined;
507
+ const sev = r.issue_summary?.severity_count ?? {};
508
+ return {
509
+ reportId: r.id,
510
+ name: r.name,
511
+ status: r.status,
512
+ created_at: r.created_at,
513
+ ...(app ? { app } : {}),
514
+ scan_count: r.issue_summary?.scan_count ?? 0,
515
+ issues: {
516
+ ...severityCounts(r.issue_summary ?? {}),
517
+ duplicates: sev.duplicate_issues ?? 0,
518
+ hidden: sev.hidden_issues ?? 0,
519
+ },
520
+ dashboard_link: `${APP_DASHBOARD}/report/${r.id}`,
521
+ };
522
+ });
523
+ return { total: data.info?.count ?? reports.length, reports };
524
+ }
525
+ /** A manual report's details, or undefined when the API does not know the id. */
526
+ async manualReport(reportId) {
527
+ const resp = await apiClient.get({
528
+ url: `${MANUAL_API_BASE}/reports/${encodeURIComponent(reportId)}`,
529
+ headers: this.headers(),
530
+ raise_error: false,
531
+ });
532
+ return resp.ok ? resp.data : undefined;
533
+ }
534
+ /**
535
+ * A manual report's issues, every occurrence the API lists, most severe first. The
536
+ * dashboard's own count comes from the report's summary: it folds duplicates that the
537
+ * issue rows do not mark, so the two numbers are both given.
538
+ */
539
+ async appReportIssues(reportId, cursor = 0, maxCharacterLength) {
540
+ const detail = await this.manualReport(reportId);
541
+ if (!detail) {
542
+ throw new Error(`No App Accessibility manual report with id "${reportId}". Pass the id from listAppAccessibilityReports (kind: manual), or the report's dashboard link (app-accessibility.browserstack.com/report/…) as reportUrl`);
543
+ }
544
+ const details = [];
545
+ let device;
546
+ for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) {
547
+ const resp = await apiClient.get({
548
+ url: `${MANUAL_API_BASE}/reports/${encodeURIComponent(reportId)}/issues?page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`,
549
+ headers: this.headers(),
550
+ });
551
+ device ??= resp.data?.device;
552
+ details.push(...(resp.data?.data ?? []));
553
+ if (!resp.data?.pagination?.next)
554
+ break;
555
+ }
556
+ const summary = detail.data?.issue_summary ?? {};
557
+ const all = flattenAppSnapshots(details);
558
+ const page = pageOf(all, { nextPage: cursor, maxCharacterLength });
559
+ return {
560
+ ...page,
561
+ total_issues: all.length,
562
+ summary: summarizeAppIssues(all),
563
+ dashboard_count: summary.total ?? all.length,
564
+ duplicate_occurrences: summary.severity_count?.duplicate_issues ?? 0,
565
+ hidden_issues: summary.severity_count?.hidden_issues ?? 0,
566
+ scan_count: summary.scan_count ?? details.length,
567
+ status: manualReportStatus(detail),
568
+ report_name: detail.meta_data?.name ?? reportId,
569
+ ...(device?.model
570
+ ? { device: `${device.model}, ${device.os} ${device.os_version}` }
571
+ : {}),
572
+ dashboard_link: `${APP_DASHBOARD}/report/${reportId}`,
573
+ };
574
+ }
575
+ /**
576
+ * The id of the project's build that `pick` accepts, newest first, within the first
577
+ * pages; `buildName` narrows the list to builds named like it, when the name is known
578
+ * to be the build's own.
579
+ */
580
+ async findBuild(projectId, buildName, pick) {
581
+ for await (const { rows } of this.projectBuilds(projectId, buildName)) {
582
+ const row = rows.find(pick);
583
+ if (row?.th_build_id)
584
+ return row.th_build_id;
585
+ }
586
+ return undefined;
587
+ }
588
+ /**
589
+ * The project's builds a page at a time, newest first, within the first pages; `more`
590
+ * says whether the list goes on past the page.
591
+ */
592
+ async *projectBuilds(projectId, buildName) {
593
+ const q = encodeURIComponent;
594
+ const named = buildName ? `build_name=${q(buildName)}&` : "";
595
+ for (let page = 1; page <= MAX_APP_BUILD_LIST_PAGES; page++) {
596
+ const resp = await apiClient.get({
597
+ url: `${APP_API_BASE}/project/${q(String(projectId))}/build?${named}page_size=${BUILD_LIST_PAGE_SIZE}&page=${page}`,
598
+ headers: this.headers(),
599
+ raise_error: false,
600
+ });
601
+ const more = Boolean(resp.ok && resp.data?.info?.next);
602
+ yield { rows: resp.data?.builds ?? [], more };
603
+ if (!more)
604
+ return;
605
+ }
606
+ }
607
+ /**
608
+ * The build before this one in its project: the newest earlier build of the same name,
609
+ * as an SDK suite keeps its name from run to run, or else the build just before it, as
610
+ * each native (Espresso, XCUITest) run is named apart. None for a project's first build.
611
+ */
612
+ async previousBuild(buildId) {
613
+ const meta = await this.requireBuildMeta(buildId);
614
+ const projectId = await this.projectId(meta.project_name);
615
+ if (projectId === undefined)
616
+ return undefined;
617
+ let reached = false;
618
+ let justBefore;
619
+ let more = false;
620
+ const ref = (row, same_name) => ({
621
+ buildId: row.th_build_id,
622
+ ...(row.name !== undefined ? { name: row.name } : {}),
623
+ ...(row.build_number !== undefined
624
+ ? { build_number: row.build_number }
625
+ : {}),
626
+ ...(row.created_at !== undefined ? { created_at: row.created_at } : {}),
627
+ same_name,
628
+ searched_all: !more,
629
+ });
630
+ for await (const page of this.projectBuilds(projectId)) {
631
+ more = page.more;
632
+ for (const row of page.rows) {
633
+ if (row.th_build_id === buildId) {
634
+ reached = true;
635
+ }
636
+ else if (reached) {
637
+ if (row.name === meta.name)
638
+ return ref(row, true);
639
+ justBefore ??= row;
640
+ }
641
+ }
642
+ }
643
+ // Stopping at the page limit is not the end of the list: say so rather than guess.
644
+ if (!reached && more) {
645
+ throw new Error(`App Accessibility build ${buildId} is not among its project's newest builds (${MAX_APP_BUILD_LIST_PAGES} pages), so the build before it cannot be found here. Pass the earlier build's id or link as compareWith instead`);
646
+ }
647
+ if (!justBefore && more) {
648
+ throw new Error(`App Accessibility build ${buildId} is the last of its project's newest builds read (${MAX_APP_BUILD_LIST_PAGES} pages): the build before it is older than the newest builds read. Pass the earlier build's id or link as compareWith instead`);
649
+ }
650
+ return justBefore && ref(justBefore, false);
651
+ }
652
+ /** The build's page on the App Accessibility dashboard, when its project can be found. */
653
+ async dashboardLink(meta) {
654
+ try {
655
+ const projectId = await this.projectId(meta.project_name);
656
+ if (projectId === undefined)
657
+ return undefined;
658
+ return `${APP_DASHBOARD}/automated-tests/projects/${projectId}/builds/${meta.normalised_name}/${meta.build_id}`;
659
+ }
660
+ catch {
661
+ return undefined;
662
+ }
663
+ }
664
+ /** Every scanned screen of the build, and how many issues the dashboard hides. */
665
+ async snapshots(buildId) {
666
+ const details = [];
667
+ let hidden = 0;
668
+ for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) {
669
+ const resp = await apiClient.get({
670
+ url: `${APP_API_BASE}/build/${encodeURIComponent(buildId)}/snapshot?hide_duplicate_issues=true&page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`,
671
+ headers: this.headers(),
672
+ });
673
+ const data = resp.data?.data;
674
+ details.push(...(data?.details ?? []));
675
+ hidden = data?.issue_counts?.hidden_issues ?? hidden;
676
+ if (!data?.info?.next)
677
+ break;
678
+ }
679
+ return { details, hidden };
680
+ }
681
+ async requireBuildMeta(buildId) {
682
+ const meta = await this.buildMeta(buildId);
683
+ if (!meta) {
684
+ throw new Error(`No App Accessibility build with id "${buildId}". Pass the id the BrowserStack SDK printed as "testhub id" at the end of the run, or the build's App Accessibility dashboard link as reportUrl`);
685
+ }
686
+ return meta;
687
+ }
688
+ /** The build's details and scanned screens; a build with no scans has none to read. */
689
+ async readBuild(buildId) {
690
+ const meta = await this.requireBuildMeta(buildId);
691
+ const scanCount = meta.issue_summary?.scan_count ?? 0;
692
+ const { details, hidden } = scanCount > 0
693
+ ? await this.snapshots(buildId)
694
+ : { details: [], hidden: 0 };
695
+ return { meta, details, hidden, scanCount };
696
+ }
697
+ /** Every issue of the build at once, to compare it with another. */
698
+ async buildIssues(buildId) {
699
+ const { meta, details, scanCount } = await this.readBuild(buildId);
700
+ const tests = new Set(details.map((d) => d.meta?.testcase_name).filter((t) => t !== undefined));
701
+ return {
702
+ buildId,
703
+ build_name: meta.name,
704
+ build_number: meta.build_id,
705
+ ...(meta.created_at ? { created_at: meta.created_at } : {}),
706
+ status: meta.status,
707
+ scan_count: scanCount,
708
+ tests: [...tests],
709
+ issues: flattenAppSnapshots(details),
710
+ };
711
+ }
712
+ async report(buildId, cursor = 0, maxCharacterLength) {
713
+ const { meta, details, hidden, scanCount } = await this.readBuild(buildId);
714
+ const all = flattenAppSnapshots(details);
715
+ const page = pageOf(all, { nextPage: cursor, maxCharacterLength });
716
+ return {
717
+ ...page,
718
+ total_issues: all.length,
719
+ summary: summarizeAppIssues(all),
720
+ hidden_issues: hidden,
721
+ scan_count: scanCount,
722
+ status: meta.status,
723
+ build_name: meta.name,
724
+ dashboard_link: await this.dashboardLink(meta),
725
+ fup_capped: Boolean(meta.fup?.capped || meta.fup?.tests_dropped),
726
+ ...(meta.session_data?.framework?.name
727
+ ? { framework: meta.session_data.framework.name }
728
+ : {}),
729
+ };
730
+ }
731
+ }