@browserstack/mcp-server 2.2.6-beta.1 → 2.2.6-beta.2

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 (37) hide show
  1. package/dist/lib/accessibility-routing.d.ts +6 -0
  2. package/dist/lib/accessibility-routing.js +6 -0
  3. package/dist/server-factory.js +6 -1
  4. package/dist/tools/accessibility.js +680 -69
  5. package/dist/tools/accessiblity-utils/app-report-fetcher.d.ts +412 -0
  6. package/dist/tools/accessiblity-utils/app-report-fetcher.js +926 -0
  7. package/dist/tools/accessiblity-utils/paginate.d.ts +27 -0
  8. package/dist/tools/accessiblity-utils/paginate.js +50 -0
  9. package/dist/tools/accessiblity-utils/report-fetcher.d.ts +116 -7
  10. package/dist/tools/accessiblity-utils/report-fetcher.js +268 -29
  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/accessiblity-utils/scanner.d.ts +15 -1
  14. package/dist/tools/accessiblity-utils/scanner.js +3 -4
  15. package/dist/tools/appautomate-utils/appium-sdk/config-generator.d.ts +2 -0
  16. package/dist/tools/appautomate-utils/appium-sdk/config-generator.js +15 -5
  17. package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +2 -1
  18. package/dist/tools/appautomate-utils/appium-sdk/constants.js +6 -1
  19. package/dist/tools/appautomate-utils/appium-sdk/handler.js +12 -1
  20. package/dist/tools/appautomate-utils/appium-sdk/instructions.d.ts +3 -1
  21. package/dist/tools/appautomate-utils/appium-sdk/instructions.js +2 -2
  22. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.d.ts +4 -0
  23. package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.js +47 -0
  24. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.d.ts +3 -1
  25. package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.js +19 -6
  26. package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +7 -2
  27. package/dist/tools/appautomate-utils/native-execution/appautomate.js +15 -12
  28. package/dist/tools/appautomate-utils/native-execution/constants.d.ts +2 -1
  29. package/dist/tools/appautomate-utils/native-execution/constants.js +6 -1
  30. package/dist/tools/appautomate.js +29 -16
  31. package/dist/tools/testmanagement-utils/create-testrun.d.ts +1 -1
  32. package/dist/tools/testmanagement-utils/update-testrun.d.ts +1 -1
  33. package/dist/tools/tool-handoff.d.ts +3 -2
  34. package/dist/tools/tool-handoff.js +3 -2
  35. package/package.json +1 -1
  36. package/dist/tools/accessiblity-utils/accessibility-rag.d.ts +0 -12
  37. package/dist/tools/accessiblity-utils/accessibility-rag.js +0 -59
@@ -0,0 +1,27 @@
1
+ /**
2
+ * How a report's issues are handed out: most severe first, in pages measured by the length
3
+ * of their JSON, so that a page fits a client's context whatever the rows contain. The
4
+ * cursor an agent passes back is the character offset the next page starts at.
5
+ */
6
+ /** Most severe first; an unknown severity sorts last. */
7
+ export declare function sortBySeverity<T extends {
8
+ severity: string;
9
+ }>(rows: T[]): T[];
10
+ /**
11
+ * How many characters of issue JSON one call hands out by default: about 2,500 tokens, so
12
+ * a page stays small next to a client's own limit on tool output while a large report
13
+ * still takes few calls.
14
+ */
15
+ export declare const PAGE_CHARACTERS = 10000;
16
+ export interface Page<T> {
17
+ records: T[];
18
+ page_length: number;
19
+ /** Character offset of the next page, or null when this was the last. */
20
+ next_page: number | null;
21
+ /** How many rows the pages after this one hold. */
22
+ remaining: number;
23
+ }
24
+ export declare function pageOf<T>(all: T[], { maxCharacterLength, nextPage }?: {
25
+ maxCharacterLength?: number | undefined;
26
+ nextPage?: number | undefined;
27
+ }): Page<T>;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * How a report's issues are handed out: most severe first, in pages measured by the length
3
+ * of their JSON, so that a page fits a client's context whatever the rows contain. The
4
+ * cursor an agent passes back is the character offset the next page starts at.
5
+ */
6
+ const SEVERITY_ORDER = {
7
+ critical: 0,
8
+ serious: 1,
9
+ moderate: 2,
10
+ minor: 3,
11
+ };
12
+ /** Most severe first; an unknown severity sorts last. */
13
+ export function sortBySeverity(rows) {
14
+ return rows.sort((a, b) => (SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99));
15
+ }
16
+ /**
17
+ * How many characters of issue JSON one call hands out by default: about 2,500 tokens, so
18
+ * a page stays small next to a client's own limit on tool output while a large report
19
+ * still takes few calls.
20
+ */
21
+ export const PAGE_CHARACTERS = 10_000;
22
+ export function pageOf(all, { maxCharacterLength = PAGE_CHARACTERS, nextPage = 0 } = {}) {
23
+ // Walk to the starting offset
24
+ let charCursor = 0;
25
+ let idx = 0;
26
+ for (; idx < all.length; idx++) {
27
+ const len = JSON.stringify(all[idx]).length;
28
+ if (charCursor + len > nextPage)
29
+ break;
30
+ charCursor += len;
31
+ }
32
+ // Collect up to maxCharacterLength; a row larger than that still makes a page of its
33
+ // own, or the cursor would never move past it
34
+ const page = [];
35
+ for (let i = idx; i < all.length; i++) {
36
+ const recStr = JSON.stringify(all[i]);
37
+ const fits = charCursor - nextPage + recStr.length <= maxCharacterLength;
38
+ if (!fits && page.length > 0)
39
+ break;
40
+ page.push(all[i]);
41
+ charCursor += recStr.length;
42
+ }
43
+ const remaining = all.length - idx - page.length;
44
+ return {
45
+ records: page,
46
+ page_length: page.length,
47
+ next_page: remaining > 0 ? charCursor : null,
48
+ remaining,
49
+ };
50
+ }
@@ -1,4 +1,9 @@
1
- /** What to read issues from: a Website Scanner run, an Automated Tests build or a Workflow Analyzer report. */
1
+ import { type AppBuildIssues, type AppBuildRef, type AppBuildReport, type AppBuildRow, type AppManualReport, type AppManualReportRow, type AppProjectRow } from "./app-report-fetcher.js";
2
+ export { MAX_APP_BUILD_LIST_PAGES } from "./app-report-fetcher.js";
3
+ /**
4
+ * What to read issues from: a Website Scanner run, an Automated Tests build, a Workflow
5
+ * Analyzer report, or an App Accessibility build (a mobile app run through App Automate).
6
+ */
2
7
  export type IssueSource = {
3
8
  kind: "scan";
4
9
  scanId: string;
@@ -9,6 +14,12 @@ export type IssueSource = {
9
14
  } | {
10
15
  kind: "workflow";
11
16
  reportId: string;
17
+ } | {
18
+ kind: "appBuild";
19
+ buildId: string;
20
+ } | {
21
+ kind: "appReport";
22
+ reportId: string;
12
23
  };
13
24
  /**
14
25
  * How far a report link's scan is searched for. The scan list has no name or link filter and
@@ -17,8 +28,46 @@ export type IssueSource = {
17
28
  * at limit=50 or 100 the cursor skips scans, so a larger page cannot be used to go faster.
18
29
  */
19
30
  export declare const MAX_SCAN_LIST_PAGES = 15;
31
+ /**
32
+ * How long one call waits on a CSV export. Export time grows with the report's rows: a few
33
+ * seconds for hundreds, ~17 s for 18k rows, over 2 minutes for 30k issues. A call stays well
34
+ * under the 60 s request timeout MCP clients default to; an export still running after this
35
+ * is handed back to be continued by the next call rather than started again.
36
+ */
37
+ export declare const EXPORT_WAIT_MS = 45000;
38
+ /**
39
+ * Exports per report, kept for the life of the server: the finished link to reuse for later
40
+ * pages, or the task of an export still running, to continue instead of starting another.
41
+ * Keyed by the account and the report, so one account never sees another's.
42
+ */
43
+ export declare const reportExports: Map<string, {
44
+ link?: string;
45
+ expiresAt?: number;
46
+ taskId?: string;
47
+ }>;
48
+ /** An export still being generated when a call stopped waiting on it. */
49
+ export declare class ExportPendingError extends Error {
50
+ readonly taskId: string;
51
+ readonly waitedMs: number;
52
+ constructor(taskId: string, waitedMs: number);
53
+ }
54
+ /** A run in a scan's history, newest first in the API's list. */
55
+ export interface ScanRun {
56
+ id: number;
57
+ scanDate: string;
58
+ issues?: number;
59
+ }
60
+ /** What the dashboard counts between a run and the run before it. */
61
+ export interface RunChanges {
62
+ newIssues: number;
63
+ retainedIssues: number;
64
+ resolvedIssues: number;
65
+ }
20
66
  export declare class AccessibilityReportFetcher {
21
67
  private auth;
68
+ private readonly app;
69
+ /** Web builds whose overview was read, by id, so a status check need not read it again. */
70
+ private readonly webBuildProjects;
22
71
  setAuth(auth: {
23
72
  username: string;
24
73
  password: string;
@@ -26,18 +75,78 @@ export declare class AccessibilityReportFetcher {
26
75
  private authHeaders;
27
76
  /**
28
77
  * The source a dashboard report link points at. An Automated Tests link carries the build
29
- * id itself. A Website Scanner link carries the thBuildId of one run, which only the scan
30
- * list can map to ids: each scan there carries its latest run's link. A link to an older
31
- * run therefore matches no scan, and is reported as such rather than guessed at.
78
+ * id itself. An Automate build or session link is mapped to that build through a session
79
+ * (see buildOfAutomateSession). A Website Scanner link carries the thBuildId of one run,
80
+ * which only the scan list can map to ids: each scan there carries its latest run's link.
81
+ * A link to an older run therefore matches no scan, and is reported as such rather than
82
+ * guessed at. A scanner share link carries the dashboard link it shows. An App
83
+ * Accessibility link names its build by project, name and number, which the project's
84
+ * build list maps to the id. An App Automate build or session link maps to the
85
+ * accessibility build through a session's test code.
32
86
  */
33
87
  sourceFromReportUrl(reportUrl: string): Promise<IssueSource>;
88
+ /**
89
+ * A build id names a web build or a mobile app build: both are TestHub ids, and only the
90
+ * API that ran the build knows it. The web API is asked first; a build it does not know
91
+ * that the App Accessibility API does is an app build. One neither knows is refused
92
+ * here, naming both.
93
+ */
94
+ resolveBuild(source: IssueSource): Promise<IssueSource>;
95
+ /** An app build's issues, read inline from its scanned screens. */
96
+ appBuildReport(buildId: string, cursor?: number, maxCharacterLength?: number): Promise<AppBuildReport>;
97
+ /** Every issue of an app build at once, to compare it with another. */
98
+ appBuildIssues(buildId: string): Promise<AppBuildIssues>;
99
+ /** The build before an app build in its project, if it has one. */
100
+ previousAppBuild(buildId: string): Promise<AppBuildRef | undefined>;
101
+ /** A manual (Workflow Analyzer) app report's issues, read inline from its screens. */
102
+ appReportIssues(reportId: string, cursor?: number, maxCharacterLength?: number): Promise<AppManualReport>;
103
+ listAppProjects(limit: number): Promise<{
104
+ projects: AppProjectRow[];
105
+ total: number;
106
+ }>;
107
+ listAppBuilds(projectName: string, buildName: string | undefined, limit: number): Promise<{
108
+ project: {
109
+ id: number;
110
+ name: string;
111
+ };
112
+ total: number;
113
+ builds: AppBuildRow[];
114
+ }>;
115
+ listAppManualReports(name: string | undefined, limit: number): Promise<{
116
+ total: number;
117
+ reports: AppManualReportRow[];
118
+ }>;
119
+ /** Any one session of an Automate build; every session of an SDK run names the same build. */
120
+ private anyAutomateSession;
121
+ /**
122
+ * The Accessibility build an Automate session ran in. A session run through the BrowserStack
123
+ * SDK belongs to the SDK's build, whose id is also the Accessibility build id, and the
124
+ * session's test-code record names it (build_uuid). A session run without the SDK has no
125
+ * such record, and an SDK run without accessibility testing has a build the Accessibility
126
+ * API does not know; both are said plainly instead of surfacing later as a bare 404.
127
+ */
128
+ private buildOfAutomateSession;
34
129
  /** Walk the newest scans for the one whose own link carries this thBuildId. */
35
130
  private findScanRun;
36
- getReportLink(source: IssueSource): Promise<string>;
131
+ /**
132
+ * The CSV link of a report's issues. A finished export is reused until its link expires.
133
+ * Otherwise an export is started, or continued (`exportTaskId`, or the one an earlier call
134
+ * left running), and waited on for up to EXPORT_WAIT_MS; one still running then is handed
135
+ * back as an ExportPendingError carrying its task id.
136
+ */
137
+ getReportLink(source: IssueSource, options?: {
138
+ exportTaskId?: string;
139
+ onWait?: (waitedMs: number) => Promise<void>;
140
+ }): Promise<string>;
141
+ /** The run before `scanRunId` in its scan's history, or undefined for the first run. */
142
+ previousScanRun(scanId: string, scanRunId: string): Promise<ScanRun | undefined>;
143
+ /** The dashboard's new / retained / resolved counts against the run before this one. */
144
+ changesSinceLastRun(scanId: string, scanRunId: string): Promise<RunChanges | undefined>;
37
145
  /**
38
146
  * Where the report is in its processing, as the report's own status reads it: `completed`,
39
- * or `queued` / `running` / `processing` / `finalizing` while issues are still being added.
40
- * Undefined when it cannot be read. A build's overview carries no status, so a build's comes
147
+ * or `queued` / `running` / `processing` / `finalizing` while issues are still being added
148
+ * (an app build reads `running` / `rule_processing` until its rules have run). Undefined
149
+ * when it cannot be read. A web build's overview carries no status, so a build's comes
41
150
  * from its project's build list (newest first), found by id.
42
151
  */
43
152
  reportStatus(source: IssueSource): Promise<string | undefined>;
@@ -1,4 +1,9 @@
1
1
  import { apiClient } from "../../lib/apiClient.js";
2
+ import { AppAccessibilityApi, manualReportIdFromDashboardLink, manualReportStatus, } from "./app-report-fetcher.js";
3
+ export { MAX_APP_BUILD_LIST_PAGES } from "./app-report-fetcher.js";
4
+ import { SessionType } from "../../lib/constants.js";
5
+ import { sessionsListUrl } from "../automate-utils/list-session-ids.js";
6
+ import { getAutomationBaseUrl } from "../rca-agent-utils/constants.js";
2
7
  const API_BASE = "https://api-accessibility.browserstack.com/api";
3
8
  /**
4
9
  * How far a report link's scan is searched for. The scan list has no name or link filter and
@@ -7,8 +12,50 @@ const API_BASE = "https://api-accessibility.browserstack.com/api";
7
12
  * at limit=50 or 100 the cursor skips scans, so a larger page cannot be used to go faster.
8
13
  */
9
14
  export const MAX_SCAN_LIST_PAGES = 15;
15
+ /** How many pages of a scan's runs are read to find the run before a given one. */
16
+ const MAX_SCAN_RUN_PAGES = 10;
10
17
  /** A build whose status is checked is one just run, so it sits at the top of its project's list. */
11
18
  const MAX_BUILD_LIST_PAGES = 3;
19
+ /**
20
+ * How long one call waits on a CSV export. Export time grows with the report's rows: a few
21
+ * seconds for hundreds, ~17 s for 18k rows, over 2 minutes for 30k issues. A call stays well
22
+ * under the 60 s request timeout MCP clients default to; an export still running after this
23
+ * is handed back to be continued by the next call rather than started again.
24
+ */
25
+ export const EXPORT_WAIT_MS = 45_000;
26
+ const EXPORT_POLL_MS = [1000, 2000, 3000, 5000];
27
+ /** A finished export's link is reused until shortly before it expires (about 12 hours). */
28
+ const LINK_EXPIRY_MARGIN_MS = 10 * 60 * 1000;
29
+ const LINK_FALLBACK_TTL_MS = 60 * 60 * 1000;
30
+ /**
31
+ * Exports per report, kept for the life of the server: the finished link to reuse for later
32
+ * pages, or the task of an export still running, to continue instead of starting another.
33
+ * Keyed by the account and the report, so one account never sees another's.
34
+ */
35
+ export const reportExports = new Map();
36
+ /** An export still being generated when a call stopped waiting on it. */
37
+ export class ExportPendingError extends Error {
38
+ taskId;
39
+ waitedMs;
40
+ constructor(taskId, waitedMs) {
41
+ super(`The report's CSV export is still being generated after ${Math.round(waitedMs / 1000)}s (export task ${taskId}).`);
42
+ this.taskId = taskId;
43
+ this.waitedMs = waitedMs;
44
+ this.name = "ExportPendingError";
45
+ }
46
+ }
47
+ /** When a presigned export link stops working, from its Expires parameter. */
48
+ function linkExpiry(link) {
49
+ try {
50
+ const expires = Number(new URL(link).searchParams.get("Expires"));
51
+ if (expires > 0)
52
+ return expires * 1000 - LINK_EXPIRY_MARGIN_MS;
53
+ }
54
+ catch {
55
+ // Not a URL we can read; fall back below.
56
+ }
57
+ return Date.now() + LINK_FALLBACK_TTL_MS;
58
+ }
12
59
  /** The thBuildId a dashboard report link carries, if any. */
13
60
  function thBuildIdOf(link) {
14
61
  try {
@@ -47,6 +94,9 @@ function exportUrls(source) {
47
94
  poll: (taskId) => `${route}?taskId=${q(taskId)}`,
48
95
  };
49
96
  }
97
+ case "appBuild":
98
+ case "appReport":
99
+ throw new Error("An App Accessibility build or report has no CSV export; its issues are read inline");
50
100
  }
51
101
  }
52
102
  /** Why an export did not start, in the API's words, and where a build's id comes from when it is not found. */
@@ -56,12 +106,24 @@ function startFailure(source, status, body) {
56
106
  `HTTP ${status}`;
57
107
  // The API takes only its own build id; the Automate build id is a 404 "Build not found".
58
108
  const hint = status === 404 && source.kind === "build"
59
- ? ". 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"
109
+ ? ". 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"
60
110
  : "";
61
111
  return `Failed to initiate report: ${reason}${hint}`;
62
112
  }
113
+ /** The build and session ids an Automate dashboard link carries, if it is one. */
114
+ function automateIds(url) {
115
+ if (url.hostname !== "automate.browserstack.com")
116
+ return undefined;
117
+ return {
118
+ buildId: url.pathname.match(/\/builds\/([0-9a-f]{40})(?:\/|$)/i)?.[1],
119
+ sessionId: url.pathname.match(/\/sessions\/([0-9a-f]{40})(?:\/|$)/i)?.[1],
120
+ };
121
+ }
63
122
  export class AccessibilityReportFetcher {
64
123
  auth;
124
+ app = new AppAccessibilityApi(() => this.authHeaders());
125
+ /** Web builds whose overview was read, by id, so a status check need not read it again. */
126
+ webBuildProjects = new Map();
65
127
  setAuth(auth) {
66
128
  this.auth = auth;
67
129
  }
@@ -75,9 +137,14 @@ export class AccessibilityReportFetcher {
75
137
  }
76
138
  /**
77
139
  * The source a dashboard report link points at. An Automated Tests link carries the build
78
- * id itself. A Website Scanner link carries the thBuildId of one run, which only the scan
79
- * list can map to ids: each scan there carries its latest run's link. A link to an older
80
- * run therefore matches no scan, and is reported as such rather than guessed at.
140
+ * id itself. An Automate build or session link is mapped to that build through a session
141
+ * (see buildOfAutomateSession). A Website Scanner link carries the thBuildId of one run,
142
+ * which only the scan list can map to ids: each scan there carries its latest run's link.
143
+ * A link to an older run therefore matches no scan, and is reported as such rather than
144
+ * guessed at. A scanner share link carries the dashboard link it shows. An App
145
+ * Accessibility link names its build by project, name and number, which the project's
146
+ * build list maps to the id. An App Automate build or session link maps to the
147
+ * accessibility build through a session's test code.
81
148
  */
82
149
  async sourceFromReportUrl(reportUrl) {
83
150
  let url;
@@ -87,6 +154,26 @@ export class AccessibilityReportFetcher {
87
154
  catch {
88
155
  url = undefined;
89
156
  }
157
+ // A scanner share link (scanner.browserstack.com/.../a11y-report) carries the dashboard
158
+ // link it shows as its reportUrl parameter.
159
+ const shared = url?.searchParams.get("reportUrl");
160
+ if (shared && shared !== reportUrl)
161
+ return this.sourceFromReportUrl(shared);
162
+ const manualReportId = url && manualReportIdFromDashboardLink(url);
163
+ if (manualReportId)
164
+ return { kind: "appReport", reportId: manualReportId };
165
+ const appBuildId = url &&
166
+ ((await this.app.buildIdFromDashboardLink(url)) ??
167
+ (await this.app.buildIdFromAppAutomateLink(url)));
168
+ if (appBuildId)
169
+ return { kind: "appBuild", buildId: appBuildId };
170
+ const automate = url && automateIds(url);
171
+ if (automate?.sessionId) {
172
+ return this.buildOfAutomateSession(automate.sessionId);
173
+ }
174
+ if (automate?.buildId) {
175
+ return this.buildOfAutomateSession(await this.anyAutomateSession(automate.buildId));
176
+ }
90
177
  const thBuildId = url?.searchParams.get("thBuildId");
91
178
  if (url && thBuildId && url.pathname.includes("/automated-tests/")) {
92
179
  return { kind: "build", buildId: thBuildId };
@@ -95,7 +182,107 @@ export class AccessibilityReportFetcher {
95
182
  if (thBuildId && scanName) {
96
183
  return this.findScanRun(thBuildId, decodeURIComponent(scanName));
97
184
  }
98
- 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), or the ids: scanId with scanRunId, buildId, or workflowReportId");
185
+ 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) or a scanner share link, an Automate build or session link, an App Accessibility build link (automated-tests/projects/…/builds/…), an App Automate build or session link, an App Accessibility manual report link (report/…), or the ids: scanId with scanRunId, buildId, workflowReportId, or appReportId");
186
+ }
187
+ /**
188
+ * A build id names a web build or a mobile app build: both are TestHub ids, and only the
189
+ * API that ran the build knows it. The web API is asked first; a build it does not know
190
+ * that the App Accessibility API does is an app build. One neither knows is refused
191
+ * here, naming both.
192
+ */
193
+ async resolveBuild(source) {
194
+ if (source.kind !== "build")
195
+ return source;
196
+ const web = await this.getData(`${API_BASE}/automated-tests/v1/builds/${encodeURIComponent(source.buildId)}`);
197
+ if (web) {
198
+ if (web.projectName) {
199
+ this.webBuildProjects.set(source.buildId, web.projectName);
200
+ }
201
+ return source;
202
+ }
203
+ const meta = await this.app.buildMeta(source.buildId);
204
+ if (meta)
205
+ return { kind: "appBuild", buildId: source.buildId };
206
+ throw new Error(`No Automated Tests (web) or App Accessibility build with id "${source.buildId}". Pass the id the BrowserStack SDK printed as "testhub id" at the end of the run (40 lowercase letters and digits), or the report's dashboard link as reportUrl`);
207
+ }
208
+ /** An app build's issues, read inline from its scanned screens. */
209
+ appBuildReport(buildId, cursor = 0, maxCharacterLength) {
210
+ return this.app.report(buildId, cursor, maxCharacterLength);
211
+ }
212
+ /** Every issue of an app build at once, to compare it with another. */
213
+ appBuildIssues(buildId) {
214
+ return this.app.buildIssues(buildId);
215
+ }
216
+ /** The build before an app build in its project, if it has one. */
217
+ previousAppBuild(buildId) {
218
+ return this.app.previousBuild(buildId);
219
+ }
220
+ /** A manual (Workflow Analyzer) app report's issues, read inline from its screens. */
221
+ appReportIssues(reportId, cursor = 0, maxCharacterLength) {
222
+ return this.app.appReportIssues(reportId, cursor, maxCharacterLength);
223
+ }
224
+ listAppProjects(limit) {
225
+ return this.app.listAppProjects(limit);
226
+ }
227
+ listAppBuilds(projectName, buildName, limit) {
228
+ return this.app.listAppBuilds(projectName, buildName, limit);
229
+ }
230
+ listAppManualReports(name, limit) {
231
+ return this.app.listAppManualReports(name, limit);
232
+ }
233
+ /** Any one session of an Automate build; every session of an SDK run names the same build. */
234
+ async anyAutomateSession(automateBuildId) {
235
+ const resp = await apiClient.get({
236
+ url: `${sessionsListUrl(SessionType.Automate, automateBuildId)}?limit=1`,
237
+ headers: this.authHeaders(),
238
+ raise_error: false,
239
+ });
240
+ if (resp.status === 404) {
241
+ throw new Error(`No Automate build with id ${automateBuildId}`);
242
+ }
243
+ if (!resp.ok) {
244
+ throw new Error(`Could not list the sessions of Automate build ${automateBuildId}: HTTP ${resp.status}`);
245
+ }
246
+ const sessionId = resp.data?.[0]?.automation_session?.hashed_id;
247
+ if (!sessionId) {
248
+ throw new Error(`Automate build ${automateBuildId} has no sessions`);
249
+ }
250
+ return sessionId;
251
+ }
252
+ /**
253
+ * The Accessibility build an Automate session ran in. A session run through the BrowserStack
254
+ * SDK belongs to the SDK's build, whose id is also the Accessibility build id, and the
255
+ * session's test-code record names it (build_uuid). A session run without the SDK has no
256
+ * such record, and an SDK run without accessibility testing has a build the Accessibility
257
+ * API does not know; both are said plainly instead of surfacing later as a bare 404.
258
+ */
259
+ async buildOfAutomateSession(sessionId) {
260
+ const resp = await apiClient.get({
261
+ url: `${getAutomationBaseUrl()}/ext/v1/sessions/${encodeURIComponent(sessionId)}/testCode`,
262
+ headers: this.authHeaders(),
263
+ raise_error: false,
264
+ });
265
+ if (!resp.ok && resp.status !== 404) {
266
+ throw new Error(`Could not read Automate session ${sessionId}: HTTP ${resp.status}`);
267
+ }
268
+ const buildId = resp.ok
269
+ ? resp.data?.build_uuid
270
+ : undefined;
271
+ if (!buildId) {
272
+ 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`);
273
+ }
274
+ const build = await apiClient.get({
275
+ url: `${API_BASE}/automated-tests/v1/builds/${encodeURIComponent(buildId)}`,
276
+ headers: this.authHeaders(),
277
+ raise_error: false,
278
+ });
279
+ if (build.status === 404) {
280
+ throw new Error(`This Automate run belongs to build ${buildId}, which has no accessibility results: accessibility testing was not on for it`);
281
+ }
282
+ if (!build.ok) {
283
+ throw new Error(`Could not read accessibility build ${buildId} of this Automate run: HTTP ${build.status}`);
284
+ }
285
+ return { kind: "build", buildId };
99
286
  }
100
287
  /** Walk the newest scans for the one whose own link carries this thBuildId. */
101
288
  async findScanRun(thBuildId, scanName) {
@@ -130,7 +317,7 @@ export class AccessibilityReportFetcher {
130
317
  nextPage = data.pagination.nextPage;
131
318
  }
132
319
  if (sameName !== undefined) {
133
- 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}`);
320
+ 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}, or read the latest run with compareWith: "previous" to compare it with the run before it`);
134
321
  }
135
322
  // nextPage is still set only when the walk stopped at the page cap, not at the end.
136
323
  const searched = nextPage
@@ -138,44 +325,89 @@ export class AccessibilityReportFetcher {
138
325
  : "";
139
326
  throw new Error(`No Website Scanner scan${searched} matches this link. Pass scanId and scanRunId instead`);
140
327
  }
141
- async getReportLink(source) {
328
+ /**
329
+ * The CSV link of a report's issues. A finished export is reused until its link expires.
330
+ * Otherwise an export is started, or continued (`exportTaskId`, or the one an earlier call
331
+ * left running), and waited on for up to EXPORT_WAIT_MS; one still running then is handed
332
+ * back as an ExportPendingError carrying its task id.
333
+ */
334
+ async getReportLink(source, options = {}) {
335
+ const key = `${this.auth?.username ?? ""}|${JSON.stringify(source)}`;
336
+ const known = reportExports.get(key);
337
+ if (known?.link && (known.expiresAt ?? 0) > Date.now())
338
+ return known.link;
142
339
  const urls = exportUrls(source);
143
- // Initiate CSV link generation
144
- const initResp = await apiClient.get({
145
- url: urls.start,
146
- headers: this.authHeaders(),
147
- raise_error: false,
148
- });
149
- const initData = initResp.data;
150
- if (!initResp.ok || !initData?.success) {
151
- throw new Error(startFailure(source, initResp.status, initData));
152
- }
153
- const taskId = initData.data.task_id;
154
- // Poll for the generated CSV link (task is async, may take a few seconds)
155
- const reportUrl = urls.poll(taskId);
156
- const maxAttempts = 3;
157
- const pollIntervalMs = 2000;
158
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
340
+ let taskId = options.exportTaskId ?? known?.taskId;
341
+ if (!taskId) {
342
+ const initResp = await apiClient.get({
343
+ url: urls.start,
344
+ headers: this.authHeaders(),
345
+ raise_error: false,
346
+ });
347
+ const initData = initResp.data;
348
+ if (!initResp.ok || !initData?.success) {
349
+ throw new Error(startFailure(source, initResp.status, initData));
350
+ }
351
+ taskId = initData.data.task_id;
352
+ }
353
+ reportExports.set(key, { taskId });
354
+ const started = Date.now();
355
+ for (let attempt = 0;; attempt++) {
159
356
  const reportResp = await apiClient.get({
160
- url: reportUrl,
357
+ url: urls.poll(taskId),
161
358
  headers: this.authHeaders(),
162
359
  });
163
360
  const reportData = reportResp.data;
164
361
  if (!reportData.success) {
362
+ reportExports.delete(key);
165
363
  throw new Error(`Failed to fetch report: ${reportData.error}`);
166
364
  }
167
365
  const link = reportData.data?.reportLink;
168
366
  if (link) {
367
+ reportExports.set(key, { link, expiresAt: linkExpiry(link) });
169
368
  return link;
170
369
  }
171
- await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
370
+ const message = reportData.data?.message ?? "";
371
+ if (/generation failed/i.test(message)) {
372
+ reportExports.delete(key);
373
+ throw new Error(`The report's CSV export failed: ${message}`);
374
+ }
375
+ const waited = Date.now() - started;
376
+ if (waited >= EXPORT_WAIT_MS)
377
+ throw new ExportPendingError(taskId, waited);
378
+ await options.onWait?.(waited);
379
+ const pause = EXPORT_POLL_MS[Math.min(attempt, EXPORT_POLL_MS.length - 1)];
380
+ await new Promise((resolve) => setTimeout(resolve, pause));
172
381
  }
173
- throw new Error(`Report link was not available after ${maxAttempts} attempts. The CSV generation may still be in progress.`);
382
+ }
383
+ /** The run before `scanRunId` in its scan's history, or undefined for the first run. */
384
+ async previousScanRun(scanId, scanRunId) {
385
+ // The runs come a page at a time; the one before may be on a later page.
386
+ const runs = [];
387
+ const base = `${API_BASE}/website-scanner/v1/scans/${encodeURIComponent(scanId)}/scan_runs`;
388
+ let nextPage;
389
+ for (let page = 0; page < MAX_SCAN_RUN_PAGES; page++) {
390
+ const data = await this.getData(nextPage ? `${base}?nextPage=${encodeURIComponent(nextPage)}` : base);
391
+ runs.push(...(data?.scanRuns ?? []));
392
+ if (!data?.pagination?.hasNext || !data.pagination.nextPage)
393
+ break;
394
+ nextPage = data.pagination.nextPage;
395
+ }
396
+ const newestFirst = [...runs].sort((a, b) => b.scanDate.localeCompare(a.scanDate));
397
+ const at = newestFirst.findIndex((run) => String(run.id) === scanRunId);
398
+ return at >= 0 ? newestFirst[at + 1] : undefined;
399
+ }
400
+ /** The dashboard's new / retained / resolved counts against the run before this one. */
401
+ async changesSinceLastRun(scanId, scanRunId) {
402
+ const q = encodeURIComponent;
403
+ const changes = (await this.getData(`${API_BASE}/website-scanner/v1/scans/${q(scanId)}/scan_runs/${q(scanRunId)}`))?.changesSinceLastRun;
404
+ return changes ?? undefined;
174
405
  }
175
406
  /**
176
407
  * Where the report is in its processing, as the report's own status reads it: `completed`,
177
- * or `queued` / `running` / `processing` / `finalizing` while issues are still being added.
178
- * Undefined when it cannot be read. A build's overview carries no status, so a build's comes
408
+ * or `queued` / `running` / `processing` / `finalizing` while issues are still being added
409
+ * (an app build reads `running` / `rule_processing` until its rules have run). Undefined
410
+ * when it cannot be read. A web build's overview carries no status, so a build's comes
179
411
  * from its project's build list (newest first), found by id.
180
412
  */
181
413
  async reportStatus(source) {
@@ -188,6 +420,12 @@ export class AccessibilityReportFetcher {
188
420
  return (await this.getData(`${API_BASE}/workflow-analyzer/v1/reports/${q(source.reportId)}`))?.summary?.status;
189
421
  case "build":
190
422
  return await this.buildStatus(source.buildId);
423
+ case "appBuild":
424
+ return (await this.app.buildMeta(source.buildId))?.status;
425
+ case "appReport": {
426
+ const detail = await this.app.manualReport(source.reportId);
427
+ return detail ? manualReportStatus(detail) : undefined;
428
+ }
191
429
  }
192
430
  }
193
431
  catch {
@@ -196,7 +434,8 @@ export class AccessibilityReportFetcher {
196
434
  }
197
435
  async buildStatus(buildId) {
198
436
  const q = encodeURIComponent;
199
- const project = (await this.getData(`${API_BASE}/automated-tests/v1/builds/${q(buildId)}`))?.projectName;
437
+ const project = this.webBuildProjects.get(buildId) ??
438
+ (await this.getData(`${API_BASE}/automated-tests/v1/builds/${q(buildId)}`))?.projectName;
200
439
  if (!project)
201
440
  return undefined;
202
441
  let nextPage;