@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.
- package/dist/lib/accessibility-routing.d.ts +6 -0
- package/dist/lib/accessibility-routing.js +6 -0
- package/dist/server-factory.js +6 -1
- package/dist/tools/accessibility.js +680 -69
- package/dist/tools/accessiblity-utils/app-report-fetcher.d.ts +412 -0
- package/dist/tools/accessiblity-utils/app-report-fetcher.js +926 -0
- package/dist/tools/accessiblity-utils/paginate.d.ts +27 -0
- package/dist/tools/accessiblity-utils/paginate.js +50 -0
- package/dist/tools/accessiblity-utils/report-fetcher.d.ts +116 -7
- package/dist/tools/accessiblity-utils/report-fetcher.js +268 -29
- package/dist/tools/accessiblity-utils/report-parser.d.ts +41 -1
- package/dist/tools/accessiblity-utils/report-parser.js +142 -43
- package/dist/tools/accessiblity-utils/scanner.d.ts +15 -1
- package/dist/tools/accessiblity-utils/scanner.js +3 -4
- package/dist/tools/appautomate-utils/appium-sdk/config-generator.d.ts +2 -0
- package/dist/tools/appautomate-utils/appium-sdk/config-generator.js +15 -5
- package/dist/tools/appautomate-utils/appium-sdk/constants.d.ts +2 -1
- package/dist/tools/appautomate-utils/appium-sdk/constants.js +6 -1
- package/dist/tools/appautomate-utils/appium-sdk/handler.js +12 -1
- package/dist/tools/appautomate-utils/appium-sdk/instructions.d.ts +3 -1
- package/dist/tools/appautomate-utils/appium-sdk/instructions.js +2 -2
- package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.d.ts +4 -0
- package/dist/tools/appautomate-utils/appium-sdk/languages/accessibility.js +47 -0
- package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.d.ts +3 -1
- package/dist/tools/appautomate-utils/appium-sdk/languages/nodejs.js +19 -6
- package/dist/tools/appautomate-utils/native-execution/appautomate.d.ts +7 -2
- package/dist/tools/appautomate-utils/native-execution/appautomate.js +15 -12
- package/dist/tools/appautomate-utils/native-execution/constants.d.ts +2 -1
- package/dist/tools/appautomate-utils/native-execution/constants.js +6 -1
- package/dist/tools/appautomate.js +29 -16
- package/dist/tools/testmanagement-utils/create-testrun.d.ts +1 -1
- package/dist/tools/testmanagement-utils/update-testrun.d.ts +1 -1
- package/dist/tools/tool-handoff.d.ts +3 -2
- package/dist/tools/tool-handoff.js +3 -2
- package/package.json +1 -1
- package/dist/tools/accessiblity-utils/accessibility-rag.d.ts +0 -12
- 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
|
-
|
|
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.
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
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.
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
for (let 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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 =
|
|
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;
|