@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,926 @@
|
|
|
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
|
+
/**
|
|
28
|
+
* Whether a project build can be a native (Espresso, XCUITest) run's. An SDK suite started
|
|
29
|
+
* at the same moment in the same project is not, and taking it would read its issues as the
|
|
30
|
+
* native run's.
|
|
31
|
+
*/
|
|
32
|
+
function maybeNative(row) {
|
|
33
|
+
const framework = row.session_data?.frameworkName;
|
|
34
|
+
return !framework || /espresso|xcui/i.test(framework);
|
|
35
|
+
}
|
|
36
|
+
/** The element details worth repeating; the rest are the rule's own inputs. */
|
|
37
|
+
const ELEMENT_DETAILS = new Set([
|
|
38
|
+
"className",
|
|
39
|
+
"viewId",
|
|
40
|
+
"resourceId",
|
|
41
|
+
"accessibilityIdentifier",
|
|
42
|
+
"accessibilityLabel",
|
|
43
|
+
"text",
|
|
44
|
+
"contentDescription",
|
|
45
|
+
"frame",
|
|
46
|
+
]);
|
|
47
|
+
/** A detail's value is JSON-encoded ('"android.widget.Button"', '{"x":63,…}') when it can be. */
|
|
48
|
+
function detailValue(value) {
|
|
49
|
+
if (typeof value !== "string")
|
|
50
|
+
return value;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(value);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** An iOS element's details, under the names an Android element's go by. */
|
|
59
|
+
const IOS_DETAILS = {
|
|
60
|
+
class: "className",
|
|
61
|
+
identifier: "accessibilityIdentifier",
|
|
62
|
+
visibleText: "text",
|
|
63
|
+
};
|
|
64
|
+
function elementOf(issue) {
|
|
65
|
+
const element = {};
|
|
66
|
+
for (const { label, value } of issue.issue_details ?? []) {
|
|
67
|
+
const name = label && (IOS_DETAILS[label] ?? label);
|
|
68
|
+
if (!name || !ELEMENT_DETAILS.has(name))
|
|
69
|
+
continue;
|
|
70
|
+
const parsed = detailValue(value);
|
|
71
|
+
if (parsed === "" || parsed === null || parsed === undefined)
|
|
72
|
+
continue;
|
|
73
|
+
element[name] = parsed;
|
|
74
|
+
}
|
|
75
|
+
// The class is the element's kind even when the details leave it out.
|
|
76
|
+
const className = issue.issue_class_android || issue.issue_class_ios;
|
|
77
|
+
if (className && element.className === undefined) {
|
|
78
|
+
element.className = className;
|
|
79
|
+
}
|
|
80
|
+
return element;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The fix text is light markup (`<document>`, `<code>`); the words are what matter. Text
|
|
84
|
+
* inside angle brackets is dropped, and so is every bracket, character by character:
|
|
85
|
+
* nested markup such as `<scr<script>ipt>` cannot leave a tag behind, as a regex
|
|
86
|
+
* replacement of whole tags could.
|
|
87
|
+
*/
|
|
88
|
+
function plainText(markup) {
|
|
89
|
+
let text = "";
|
|
90
|
+
let inTag = false;
|
|
91
|
+
for (const ch of markup ?? "") {
|
|
92
|
+
if (ch === "<")
|
|
93
|
+
inTag = true;
|
|
94
|
+
else if (ch === ">")
|
|
95
|
+
inTag = false;
|
|
96
|
+
else if (!inTag)
|
|
97
|
+
text += ch;
|
|
98
|
+
}
|
|
99
|
+
return text.replace(/\s+/g, " ").trim();
|
|
100
|
+
}
|
|
101
|
+
/** A rule's inputs that say what it expects of the element; the rest describe the run. */
|
|
102
|
+
const EXPECTED = /^(expected|desired|minimum)_/;
|
|
103
|
+
function measuredOf(issue) {
|
|
104
|
+
const measured = {};
|
|
105
|
+
for (const { label, value, important } of issue.issue_details ?? []) {
|
|
106
|
+
if (!important || !label)
|
|
107
|
+
continue;
|
|
108
|
+
if (ELEMENT_DETAILS.has(IOS_DETAILS[label] ?? label))
|
|
109
|
+
continue;
|
|
110
|
+
measured[label] = detailValue(value);
|
|
111
|
+
}
|
|
112
|
+
for (const [key, value] of Object.entries(issue.rule_dynamic_meta ?? {})) {
|
|
113
|
+
// The ratio as "4.5:1" says what expected_contrast_ratio's 4.5 does.
|
|
114
|
+
if (EXPECTED.test(key) && key !== "expected_contrast_ratio") {
|
|
115
|
+
measured[key] = value;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return Object.keys(measured).length ? measured : undefined;
|
|
119
|
+
}
|
|
120
|
+
/** Issues the dashboard does not count: hidden by the user, or marked as not an issue. */
|
|
121
|
+
function isLeftOut(issue) {
|
|
122
|
+
return issue.status === "hidden" || issue.status === "non_issue";
|
|
123
|
+
}
|
|
124
|
+
/** One row per element with an issue, most severe first. Screens are numbered in order. */
|
|
125
|
+
export function flattenAppSnapshots(details) {
|
|
126
|
+
const rows = [];
|
|
127
|
+
details.forEach((snapshot, index) => {
|
|
128
|
+
const test = snapshot.meta?.testcase_name;
|
|
129
|
+
const screen = `Screen ${index + 1}${test ? ` (${test})` : ""}`;
|
|
130
|
+
const rules = snapshot.aggregated_results ?? snapshot.meta?.aggregated_results ?? [];
|
|
131
|
+
for (const rule of rules) {
|
|
132
|
+
const wcag = (rule.guidelines ?? [])
|
|
133
|
+
.map((g) => g.message)
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join("; ");
|
|
136
|
+
for (const issue of rule.rule_issues ?? []) {
|
|
137
|
+
if (isLeftOut(issue))
|
|
138
|
+
continue;
|
|
139
|
+
const element = elementOf(issue);
|
|
140
|
+
rows.push({
|
|
141
|
+
issue_type: rule.label,
|
|
142
|
+
rule_id: rule.rule_id,
|
|
143
|
+
severity: (rule.severity ?? "unknown").toLowerCase(),
|
|
144
|
+
screen,
|
|
145
|
+
// An iOS element's id is often only in its details, and its class in issue_type_ios.
|
|
146
|
+
component: String(issue.issue_id_android ||
|
|
147
|
+
issue.issue_id_ios ||
|
|
148
|
+
element.accessibilityIdentifier ||
|
|
149
|
+
issue.issue_class_android ||
|
|
150
|
+
issue.issue_class_ios ||
|
|
151
|
+
issue.issue_type_ios ||
|
|
152
|
+
rule.label),
|
|
153
|
+
element,
|
|
154
|
+
issue_description: rule.description ?? "",
|
|
155
|
+
how_to_fix: plainText(issue.how_to_fix_desc),
|
|
156
|
+
wcag,
|
|
157
|
+
...(snapshot.meta?.screenshot_url
|
|
158
|
+
? { screenshot: snapshot.meta.screenshot_url }
|
|
159
|
+
: {}),
|
|
160
|
+
...(issue.duplication_count
|
|
161
|
+
? { duplicate_count: issue.duplication_count }
|
|
162
|
+
: {}),
|
|
163
|
+
...(measuredOf(issue) ? { measured: measuredOf(issue) } : {}),
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
return sortBySeverity(rows);
|
|
169
|
+
}
|
|
170
|
+
/** The value most rows give, the first of them on a tie. */
|
|
171
|
+
function mostCommon(values) {
|
|
172
|
+
const counts = new Map();
|
|
173
|
+
for (const v of values)
|
|
174
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
175
|
+
let best = "";
|
|
176
|
+
let bestCount = 0;
|
|
177
|
+
for (const [v, n] of counts) {
|
|
178
|
+
if (n > bestCount)
|
|
179
|
+
[best, bestCount] = [v, n];
|
|
180
|
+
}
|
|
181
|
+
return best;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* An issue's name, description, WCAG criteria and fix are its rule's, and its screenshot is
|
|
185
|
+
* its screen's: two thirds of an issue's length, repeated on every one. Said once, under
|
|
186
|
+
* the page's rules and screens, a page holds about three times as many issues. An issue
|
|
187
|
+
* keeps its own fix only when it differs from its rule's usual one.
|
|
188
|
+
*/
|
|
189
|
+
export function compactAppIssues(all) {
|
|
190
|
+
const byRule = new Map();
|
|
191
|
+
for (const row of all) {
|
|
192
|
+
byRule.set(row.rule_id, [...(byRule.get(row.rule_id) ?? []), row]);
|
|
193
|
+
}
|
|
194
|
+
const rules = {};
|
|
195
|
+
for (const [ruleId, rows] of byRule) {
|
|
196
|
+
rules[ruleId] = {
|
|
197
|
+
issue_type: rows[0].issue_type,
|
|
198
|
+
issue_description: rows[0].issue_description,
|
|
199
|
+
wcag: rows[0].wcag,
|
|
200
|
+
how_to_fix: mostCommon(rows.map((r) => r.how_to_fix)),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const screens = {};
|
|
204
|
+
const rows = all.map((row) => {
|
|
205
|
+
const { issue_type, issue_description, wcag, screenshot, how_to_fix, component, ...own } = row;
|
|
206
|
+
void issue_description;
|
|
207
|
+
void wcag;
|
|
208
|
+
if (screenshot)
|
|
209
|
+
screens[own.screen] = screenshot;
|
|
210
|
+
return {
|
|
211
|
+
...own,
|
|
212
|
+
...(component !== issue_type ? { component } : {}),
|
|
213
|
+
...(how_to_fix && how_to_fix !== rules[own.rule_id].how_to_fix
|
|
214
|
+
? { how_to_fix }
|
|
215
|
+
: {}),
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
return { rows, context: { rules, screens } };
|
|
219
|
+
}
|
|
220
|
+
/** The rules and screens a page's issues name, and nothing else. */
|
|
221
|
+
export function contextOf(records, { rules, screens }) {
|
|
222
|
+
return {
|
|
223
|
+
rules: Object.fromEntries([...new Set(records.map((r) => r.rule_id))].map((id) => [id, rules[id]])),
|
|
224
|
+
screens: Object.fromEntries([...new Set(records.map((r) => r.screen))]
|
|
225
|
+
.filter((screen) => screens[screen])
|
|
226
|
+
.map((screen) => [screen, screens[screen]])),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
function countsOf(rows, keyOf) {
|
|
230
|
+
const counts = new Map();
|
|
231
|
+
for (const row of rows) {
|
|
232
|
+
counts.set(keyOf(row), (counts.get(keyOf(row)) ?? 0) + 1);
|
|
233
|
+
}
|
|
234
|
+
return counts;
|
|
235
|
+
}
|
|
236
|
+
/** Most first; ties keep the order the rows came in, which is most severe first. */
|
|
237
|
+
function largestFirst(counts) {
|
|
238
|
+
return [...counts].sort((a, b) => b[1] - a[1]);
|
|
239
|
+
}
|
|
240
|
+
/** The issues counted by severity, by rule and by screen, largest first. */
|
|
241
|
+
export function summarizeAppIssues(rows) {
|
|
242
|
+
const ruleNames = new Map(rows.map((r) => [r.rule_id, r.issue_type]));
|
|
243
|
+
return {
|
|
244
|
+
by_severity: Object.fromEntries(countsOf(rows, (r) => r.severity)),
|
|
245
|
+
by_rule: largestFirst(countsOf(rows, (r) => r.rule_id)).map(([rule_id, count]) => ({
|
|
246
|
+
rule_id,
|
|
247
|
+
issue_type: ruleNames.get(rule_id) ?? rule_id,
|
|
248
|
+
count,
|
|
249
|
+
})),
|
|
250
|
+
by_screen: largestFirst(countsOf(rows, (r) => r.screen)).map(([screen, count]) => ({ screen, count })),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The same issue in two builds: the same rule on the same element. An element is known by
|
|
255
|
+
* its id; one without an id, by its class (or, with no class either, as an iOS element can
|
|
256
|
+
* be, only by its rule) and its text or label. The screen is left out, as screens are
|
|
257
|
+
* numbered in the order they were scanned, which a fix can shift.
|
|
258
|
+
*/
|
|
259
|
+
function appIssueKey(row) {
|
|
260
|
+
const unnamed = row.component === row.element.className || row.component === row.issue_type;
|
|
261
|
+
const label = row.element.text ?? row.element.accessibilityLabel ?? "";
|
|
262
|
+
return [row.rule_id, row.component, unnamed ? label : ""]
|
|
263
|
+
.map(String)
|
|
264
|
+
.join("|");
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* The element an issue is on, whatever its rule: its id or class and its text or label. An
|
|
268
|
+
* element known only by its rule, with no text either, cannot be told apart from another.
|
|
269
|
+
*/
|
|
270
|
+
function elementIdentity(row) {
|
|
271
|
+
const named = row.component !== row.issue_type;
|
|
272
|
+
const text = row.element.text ??
|
|
273
|
+
row.element.accessibilityLabel ??
|
|
274
|
+
row.element.contentDescription ??
|
|
275
|
+
"";
|
|
276
|
+
if (!named && !text)
|
|
277
|
+
return undefined;
|
|
278
|
+
return `${named ? row.component : ""}|${String(text)}`;
|
|
279
|
+
}
|
|
280
|
+
/** A screen is the same screen in another build when they share this much of its elements. */
|
|
281
|
+
const SAME_SCREEN_SHARE = 0.25;
|
|
282
|
+
/**
|
|
283
|
+
* The screens of one build no screen of the other resembles. Screens are named by scan
|
|
284
|
+
* order, so a screen is recognised by what is on it: one that shares under a quarter of its
|
|
285
|
+
* elements with every screen of the other build was not reached there, or changed entirely.
|
|
286
|
+
* A screen whose elements cannot be told apart is not set apart.
|
|
287
|
+
*/
|
|
288
|
+
function screensWithoutCounterpart(from, to) {
|
|
289
|
+
const elementsBy = (rows) => {
|
|
290
|
+
const screens = new Map();
|
|
291
|
+
for (const row of rows) {
|
|
292
|
+
const id = elementIdentity(row);
|
|
293
|
+
if (id === undefined)
|
|
294
|
+
continue;
|
|
295
|
+
screens.set(row.screen, (screens.get(row.screen) ?? new Set()).add(id));
|
|
296
|
+
}
|
|
297
|
+
return screens;
|
|
298
|
+
};
|
|
299
|
+
const others = [...elementsBy(to).values()];
|
|
300
|
+
const without = new Set();
|
|
301
|
+
for (const [screen, elements] of elementsBy(from)) {
|
|
302
|
+
const shared = Math.max(0, ...others.map((other) => [...elements].filter((e) => other.has(e)).length));
|
|
303
|
+
if (shared / elements.size < SAME_SCREEN_SHARE)
|
|
304
|
+
without.add(screen);
|
|
305
|
+
}
|
|
306
|
+
return without;
|
|
307
|
+
}
|
|
308
|
+
const sameFrame = (a, b) => JSON.stringify(a.element.frame ?? null) ===
|
|
309
|
+
JSON.stringify(b.element.frame ?? null);
|
|
310
|
+
/**
|
|
311
|
+
* Which issues of two builds are the same issue. Issues that look alike (one rule on
|
|
312
|
+
* elements known by neither id nor label) are paired one for one, those on the same screen
|
|
313
|
+
* and spot first, then on the same screen, then any; what is left over in either build is
|
|
314
|
+
* new or resolved. When the builds scanned a different number of screens, a screen one of
|
|
315
|
+
* them has no counterpart for is set apart: an earlier screen's leftovers were not seen
|
|
316
|
+
* again (`unconfirmed`), and a later screen's were not looked for before (`first_scanned`).
|
|
317
|
+
* With as many screens in each, they correspond, and a screen with nothing left was fixed.
|
|
318
|
+
* So new, first scanned and still open add up to the later build's issues, and resolved,
|
|
319
|
+
* unconfirmed and still open to the earlier one's.
|
|
320
|
+
*/
|
|
321
|
+
export function compareAppIssues(before, after, { sameScreens = false } = {}) {
|
|
322
|
+
const earlierByKey = new Map();
|
|
323
|
+
for (const row of before) {
|
|
324
|
+
const key = appIssueKey(row);
|
|
325
|
+
earlierByKey.set(key, [...(earlierByKey.get(key) ?? []), row]);
|
|
326
|
+
}
|
|
327
|
+
const matched = new Set();
|
|
328
|
+
const passes = [
|
|
329
|
+
(a, b) => a.screen === b.screen && sameFrame(a, b),
|
|
330
|
+
(a, b) => a.screen === b.screen,
|
|
331
|
+
() => true,
|
|
332
|
+
];
|
|
333
|
+
for (const pairs of passes) {
|
|
334
|
+
for (const row of after) {
|
|
335
|
+
if (matched.has(row))
|
|
336
|
+
continue;
|
|
337
|
+
const twin = earlierByKey
|
|
338
|
+
.get(appIssueKey(row))
|
|
339
|
+
?.find((earlier) => !matched.has(earlier) && pairs(row, earlier));
|
|
340
|
+
if (twin) {
|
|
341
|
+
matched.add(row);
|
|
342
|
+
matched.add(twin);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const added = after.filter((row) => !matched.has(row));
|
|
347
|
+
const resolved = before.filter((row) => !matched.has(row));
|
|
348
|
+
const unreached = sameScreens
|
|
349
|
+
? new Set()
|
|
350
|
+
: screensWithoutCounterpart(before, after);
|
|
351
|
+
const unseen = sameScreens
|
|
352
|
+
? new Set()
|
|
353
|
+
: screensWithoutCounterpart(after, before);
|
|
354
|
+
return {
|
|
355
|
+
added: added.filter((row) => !unseen.has(row.screen)),
|
|
356
|
+
first_scanned: added.filter((row) => unseen.has(row.screen)),
|
|
357
|
+
resolved: resolved.filter((row) => !unreached.has(row.screen)),
|
|
358
|
+
unconfirmed: resolved.filter((row) => unreached.has(row.screen)),
|
|
359
|
+
retained: after.length - added.length,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const APP_AUTOMATE_HOST = "app-automate.browserstack.com";
|
|
363
|
+
/** The App Automate build and, when the link names one, the session (both 40-hex ids). */
|
|
364
|
+
function appAutomateIds(url) {
|
|
365
|
+
if (url.hostname !== APP_AUTOMATE_HOST)
|
|
366
|
+
return undefined;
|
|
367
|
+
return {
|
|
368
|
+
buildId: url.pathname.match(/\/builds\/([0-9a-f]{40})(?:\/|$)/i)?.[1],
|
|
369
|
+
sessionId: url.pathname.match(/\/sessions\/([0-9a-f]{40})(?:\/|$)/i)?.[1],
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
/** The id a manual report's dashboard link names (`/report/{uuid}`), if it is one. */
|
|
373
|
+
export function manualReportIdFromDashboardLink(url) {
|
|
374
|
+
if (url.hostname !== new URL(APP_DASHBOARD).hostname)
|
|
375
|
+
return undefined;
|
|
376
|
+
return url.pathname.match(/^\/report\/([0-9a-f-]{36})\/?$/i)?.[1];
|
|
377
|
+
}
|
|
378
|
+
/** A manual report has no status of its own; it is complete once it has ended. */
|
|
379
|
+
export function manualReportStatus(detail) {
|
|
380
|
+
return detail.meta_data?.ended_at ? "complete" : "in_progress";
|
|
381
|
+
}
|
|
382
|
+
/** The severity counts of an issue summary, each defaulting to 0. */
|
|
383
|
+
function severityCounts(summary) {
|
|
384
|
+
const sev = summary.severity_count ?? {};
|
|
385
|
+
return {
|
|
386
|
+
total: summary.total ?? 0,
|
|
387
|
+
critical: sev.critical ?? 0,
|
|
388
|
+
serious: sev.serious ?? 0,
|
|
389
|
+
moderate: sev.moderate ?? 0,
|
|
390
|
+
minor: sev.minor ?? 0,
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
/** A link's CGI-escaped name ("+" for a space), read the same however it was escaped. */
|
|
394
|
+
function unescapedName(name) {
|
|
395
|
+
try {
|
|
396
|
+
return decodeURIComponent(name.replace(/\+/g, " "));
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
return name;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/** The build an App Accessibility dashboard link names: project id, build name and number. */
|
|
403
|
+
function buildOfDashboardLink(url) {
|
|
404
|
+
const match = url.pathname.match(/\/automated-tests\/projects\/(\d+)\/builds\/([^/]+)\/(\d+)\/?$/);
|
|
405
|
+
if (!match)
|
|
406
|
+
return undefined;
|
|
407
|
+
// The name in the link is the build's normalised name (TestHub's, which can differ from
|
|
408
|
+
// the build's own), and the number counts up per name: a project's first build of each
|
|
409
|
+
// name is number 1. The two together name one build.
|
|
410
|
+
return {
|
|
411
|
+
projectId: match[1],
|
|
412
|
+
name: unescapedName(match[2]),
|
|
413
|
+
number: Number(match[3]),
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
export class AppAccessibilityApi {
|
|
417
|
+
headers;
|
|
418
|
+
constructor(headers) {
|
|
419
|
+
this.headers = headers;
|
|
420
|
+
}
|
|
421
|
+
/** The build's details, or undefined when the API does not know the id. */
|
|
422
|
+
async buildMeta(buildId) {
|
|
423
|
+
const resp = await apiClient.get({
|
|
424
|
+
url: `${APP_API_BASE}/build/${encodeURIComponent(buildId)}`,
|
|
425
|
+
headers: this.headers(),
|
|
426
|
+
raise_error: false,
|
|
427
|
+
});
|
|
428
|
+
return resp.ok ? resp.data?.meta_data : undefined;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* The id of the build an App Accessibility dashboard link points at, if it is one: the
|
|
432
|
+
* project's newest builds, by number. (The list's name filter would not do: the link
|
|
433
|
+
* carries TestHub's name for the build, which the filter does not match.)
|
|
434
|
+
*/
|
|
435
|
+
async buildIdFromDashboardLink(url) {
|
|
436
|
+
if (url.hostname !== new URL(APP_DASHBOARD).hostname)
|
|
437
|
+
return undefined;
|
|
438
|
+
const build = buildOfDashboardLink(url);
|
|
439
|
+
if (!build)
|
|
440
|
+
return undefined;
|
|
441
|
+
const buildId = await this.findBuild(build.projectId, undefined, (row) => row.build_number === build.number &&
|
|
442
|
+
(row.normalised_name === undefined ||
|
|
443
|
+
unescapedName(row.normalised_name) === build.name));
|
|
444
|
+
if (buildId)
|
|
445
|
+
return buildId;
|
|
446
|
+
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`);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The accessibility build of an App Automate build or session link. An SDK (Appium)
|
|
450
|
+
* session's test code carries the TestHub build id its results went to, which is the
|
|
451
|
+
* App Accessibility build when accessibility was on; a build link goes through any one
|
|
452
|
+
* of its sessions. A native (Espresso, XCUITest) build has no SDK session, and is found
|
|
453
|
+
* by name instead.
|
|
454
|
+
*/
|
|
455
|
+
async buildIdFromAppAutomateLink(url) {
|
|
456
|
+
const ids = appAutomateIds(url);
|
|
457
|
+
if (!ids?.buildId && !ids?.sessionId)
|
|
458
|
+
return undefined;
|
|
459
|
+
if (ids.sessionId)
|
|
460
|
+
return this.buildOfSdkSession(ids.sessionId);
|
|
461
|
+
const session = await this.anySdkSession(ids.buildId);
|
|
462
|
+
return session
|
|
463
|
+
? this.buildOfSdkSession(session)
|
|
464
|
+
: this.buildOfNativeBuild(ids.buildId);
|
|
465
|
+
}
|
|
466
|
+
async buildOfSdkSession(sessionId) {
|
|
467
|
+
const resp = await apiClient.get({
|
|
468
|
+
url: `${getAutomationBaseUrl()}/ext/v1/sessions/${encodeURIComponent(sessionId)}/testCode`,
|
|
469
|
+
headers: this.headers(),
|
|
470
|
+
raise_error: false,
|
|
471
|
+
});
|
|
472
|
+
// A session with no test code is a 404; any other failure is the request's, not the run's.
|
|
473
|
+
if (!resp.ok && resp.status !== 404) {
|
|
474
|
+
throw new Error(`Could not read App Automate session ${sessionId}: HTTP ${resp.status}`);
|
|
475
|
+
}
|
|
476
|
+
const buildId = resp.ok ? resp.data?.build_uuid : undefined;
|
|
477
|
+
if (!buildId) {
|
|
478
|
+
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\`)`);
|
|
479
|
+
}
|
|
480
|
+
return buildId;
|
|
481
|
+
}
|
|
482
|
+
/** The first SDK session of an App Automate build; a native build lists none. */
|
|
483
|
+
async anySdkSession(buildId) {
|
|
484
|
+
const resp = await apiClient.get({
|
|
485
|
+
url: `${sessionsListUrl(SessionType.AppAutomate, buildId)}?limit=1`,
|
|
486
|
+
headers: this.headers(),
|
|
487
|
+
raise_error: false,
|
|
488
|
+
});
|
|
489
|
+
return resp.ok ? resp.data?.[0]?.automation_session?.hashed_id : undefined;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* A native build's details name its project and when it started; App Accessibility lists
|
|
493
|
+
* the run under the same project, created as it started, and named as the App Automate
|
|
494
|
+
* build list names it ("Build #N: app vX"). The start finds it among the project's newest
|
|
495
|
+
* builds; the name, read from the account's build list, tells runs that started together
|
|
496
|
+
* apart, and finds a run older than those.
|
|
497
|
+
*/
|
|
498
|
+
async buildOfNativeBuild(buildId) {
|
|
499
|
+
const native = await this.nativeBuild(buildId);
|
|
500
|
+
if (!native?.project) {
|
|
501
|
+
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`);
|
|
502
|
+
}
|
|
503
|
+
const { project, startedAt } = native;
|
|
504
|
+
const projectId = await this.projectId(project);
|
|
505
|
+
const started = projectId !== undefined && startedAt !== undefined
|
|
506
|
+
? await this.buildsStartedNear(projectId, startedAt)
|
|
507
|
+
: [];
|
|
508
|
+
if (started.length === 1)
|
|
509
|
+
return started[0].th_build_id;
|
|
510
|
+
const name = await this.appAutomateBuildName(buildId);
|
|
511
|
+
if (!name) {
|
|
512
|
+
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`);
|
|
513
|
+
}
|
|
514
|
+
const found = projectId !== undefined &&
|
|
515
|
+
(started.find((row) => row.name === name)?.th_build_id ??
|
|
516
|
+
(await this.findBuild(projectId, name, (row) => row.name === name)));
|
|
517
|
+
if (!found) {
|
|
518
|
+
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`);
|
|
519
|
+
}
|
|
520
|
+
return found;
|
|
521
|
+
}
|
|
522
|
+
/** A native build's project and start, from whichever framework API knows the build. */
|
|
523
|
+
async nativeBuild(buildId) {
|
|
524
|
+
for (const framework of ["espresso", "xcuitest"]) {
|
|
525
|
+
const resp = await apiClient.get({
|
|
526
|
+
url: `https://api-cloud.browserstack.com/app-automate/${framework}/v2/builds/${encodeURIComponent(buildId)}`,
|
|
527
|
+
headers: this.headers(),
|
|
528
|
+
raise_error: false,
|
|
529
|
+
});
|
|
530
|
+
if (!resp.ok)
|
|
531
|
+
continue;
|
|
532
|
+
// "2026-03-04 10:30:40 UTC"
|
|
533
|
+
const startedAt = Date.parse(resp.data?.start_time ?? "");
|
|
534
|
+
return {
|
|
535
|
+
project: resp.data?.input_capabilities?.project,
|
|
536
|
+
...(Number.isNaN(startedAt) ? {} : { startedAt }),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
/** The project's builds created within moments of a run's start, newest first. */
|
|
542
|
+
async buildsStartedNear(projectId, startedAt) {
|
|
543
|
+
const near = [];
|
|
544
|
+
const created = (row) => Date.parse(row.created_at ?? "");
|
|
545
|
+
for await (const { rows } of this.projectBuilds(projectId)) {
|
|
546
|
+
near.push(...rows.filter((row) => maybeNative(row) &&
|
|
547
|
+
Math.abs(created(row) - startedAt) <= NATIVE_BUILD_START_WINDOW_MS));
|
|
548
|
+
// Newest first: a page that reaches back before the run leaves nothing to find after it.
|
|
549
|
+
const oldest = rows.length ? created(rows[rows.length - 1]) : NaN;
|
|
550
|
+
if (oldest < startedAt - NATIVE_BUILD_START_WINDOW_MS)
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
return near;
|
|
554
|
+
}
|
|
555
|
+
/** The build's name in the account's App Automate build list, walked newest first. */
|
|
556
|
+
async appAutomateBuildName(buildId) {
|
|
557
|
+
for (let page = 0; page < MAX_APP_AUTOMATE_BUILD_PAGES; page++) {
|
|
558
|
+
const resp = await apiClient.get({
|
|
559
|
+
url: `${APP_AUTOMATE_BUILDS_URL}?limit=${APP_AUTOMATE_BUILDS_PAGE_SIZE}&offset=${page * APP_AUTOMATE_BUILDS_PAGE_SIZE}`,
|
|
560
|
+
headers: this.headers(),
|
|
561
|
+
raise_error: false,
|
|
562
|
+
});
|
|
563
|
+
const rows = resp.ok && Array.isArray(resp.data) ? resp.data : [];
|
|
564
|
+
const row = rows.find((r) => r.automation_build?.hashed_id === buildId);
|
|
565
|
+
if (row)
|
|
566
|
+
return row.automation_build?.name;
|
|
567
|
+
if (rows.length < APP_AUTOMATE_BUILDS_PAGE_SIZE)
|
|
568
|
+
break;
|
|
569
|
+
}
|
|
570
|
+
return undefined;
|
|
571
|
+
}
|
|
572
|
+
/** The App Accessibility project of that exact name, if any. */
|
|
573
|
+
async projectId(name) {
|
|
574
|
+
return (await this.projectsNamed(name)).find((p) => p.name === name)?.id;
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* The projects whose name contains `name`, as the API searches it. A failed search is an
|
|
578
|
+
* error, not an empty list, or a correct name would read as one that matches nothing.
|
|
579
|
+
*/
|
|
580
|
+
async projectsNamed(name) {
|
|
581
|
+
const data = await this.listing(`${APP_API_BASE}/project?name=${encodeURIComponent(name)}`, `the App Accessibility projects named "${name}"`);
|
|
582
|
+
return data.projects ?? [];
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* A listing's page. The API answers an empty list with 404 ("No builds found") for
|
|
586
|
+
* access-key callers, which is not an error here; any other failure is, a 404 from a
|
|
587
|
+
* wrong route or a missing permission included.
|
|
588
|
+
*/
|
|
589
|
+
async listing(url, what) {
|
|
590
|
+
const resp = await apiClient.get({
|
|
591
|
+
url,
|
|
592
|
+
headers: this.headers(),
|
|
593
|
+
raise_error: false,
|
|
594
|
+
});
|
|
595
|
+
if (resp.ok)
|
|
596
|
+
return resp.data ?? {};
|
|
597
|
+
if (resp.status === 404 && /^No .+ found/i.test(resp.data?.message ?? "")) {
|
|
598
|
+
return {};
|
|
599
|
+
}
|
|
600
|
+
throw new Error(`Could not list ${what}: HTTP ${resp.status}${resp.data?.message ? ` (${resp.data.message})` : ""}`);
|
|
601
|
+
}
|
|
602
|
+
/** The newest projects, as many as asked for. */
|
|
603
|
+
async listAppProjects(limit) {
|
|
604
|
+
const data = await this.listing(`${APP_API_BASE}/project?page_size=${limit}`, "App Accessibility projects");
|
|
605
|
+
const rows = (data.projects ?? []).map((p) => ({
|
|
606
|
+
id: p.id,
|
|
607
|
+
name: p.name,
|
|
608
|
+
...(p.created_at ? { created_at: p.created_at } : {}),
|
|
609
|
+
}));
|
|
610
|
+
return { projects: rows, total: data.info?.count ?? rows.length };
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* A project's newest builds, by exact project name, optionally filtered by build name.
|
|
614
|
+
* A project name that matches none exactly names the near matches, so the agent can
|
|
615
|
+
* pick rather than guess.
|
|
616
|
+
*/
|
|
617
|
+
async listAppBuilds(projectName, buildName, limit) {
|
|
618
|
+
const candidates = await this.projectsNamed(projectName);
|
|
619
|
+
const project = candidates.find((p) => p.name === projectName);
|
|
620
|
+
if (!project) {
|
|
621
|
+
const near = candidates.map((p) => `"${p.name}"`).join(", ");
|
|
622
|
+
throw new Error(`No project named "${projectName}"${near ? `. Did you mean: ${near}` : ""}. Call listAppAccessibilityReports without projectName to list the projects`);
|
|
623
|
+
}
|
|
624
|
+
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}"`);
|
|
625
|
+
const rows = data.builds ?? [];
|
|
626
|
+
const builds = rows.map((b) => {
|
|
627
|
+
const framework = b.session_data?.frameworkName;
|
|
628
|
+
const language = b.session_data?.language;
|
|
629
|
+
const app = b.app_details?.name ?? b.app_details?.package_name;
|
|
630
|
+
return {
|
|
631
|
+
buildId: b.th_build_id,
|
|
632
|
+
build_number: b.build_number,
|
|
633
|
+
name: b.name,
|
|
634
|
+
status: b.status,
|
|
635
|
+
report_status: b.report_status,
|
|
636
|
+
created_at: b.created_at,
|
|
637
|
+
scan_count: b.issue_summary?.scan_count ?? 0,
|
|
638
|
+
issues: severityCounts(b.issue_summary ?? {}),
|
|
639
|
+
...(app ? { app } : {}),
|
|
640
|
+
...(framework
|
|
641
|
+
? { framework: language ? `${framework} (${language})` : framework }
|
|
642
|
+
: {}),
|
|
643
|
+
dashboard_link: `${APP_DASHBOARD}/automated-tests/projects/${project.id}/builds/${b.normalised_name}/${b.build_number}`,
|
|
644
|
+
};
|
|
645
|
+
});
|
|
646
|
+
return {
|
|
647
|
+
project: { id: project.id, name: project.name },
|
|
648
|
+
total: data.info?.count ?? builds.length,
|
|
649
|
+
builds,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/** The newest manual reports, optionally filtered by name. */
|
|
653
|
+
async listAppManualReports(name, limit) {
|
|
654
|
+
const data = await this.listing(`${MANUAL_API_BASE}/reports?page_size=${limit}${name ? `&report_name=${encodeURIComponent(name)}` : ""}`, "manual App Accessibility reports");
|
|
655
|
+
const rows = data.reports ?? [];
|
|
656
|
+
const reports = rows.map((r) => {
|
|
657
|
+
const d = r.app_details;
|
|
658
|
+
const app = d?.name
|
|
659
|
+
? `${d.name}${d.version ? ` ${d.version}` : ""}${d.package_name ? ` (${d.package_name})` : ""}`
|
|
660
|
+
: undefined;
|
|
661
|
+
const sev = r.issue_summary?.severity_count ?? {};
|
|
662
|
+
return {
|
|
663
|
+
reportId: r.id,
|
|
664
|
+
name: r.name,
|
|
665
|
+
status: r.status,
|
|
666
|
+
created_at: r.created_at,
|
|
667
|
+
...(app ? { app } : {}),
|
|
668
|
+
scan_count: r.issue_summary?.scan_count ?? 0,
|
|
669
|
+
issues: {
|
|
670
|
+
...severityCounts(r.issue_summary ?? {}),
|
|
671
|
+
duplicates: sev.duplicate_issues ?? 0,
|
|
672
|
+
hidden: sev.hidden_issues ?? 0,
|
|
673
|
+
},
|
|
674
|
+
dashboard_link: `${APP_DASHBOARD}/report/${r.id}`,
|
|
675
|
+
};
|
|
676
|
+
});
|
|
677
|
+
return { total: data.info?.count ?? reports.length, reports };
|
|
678
|
+
}
|
|
679
|
+
/** A manual report's details, or undefined when the API does not know the id. */
|
|
680
|
+
async manualReport(reportId) {
|
|
681
|
+
const resp = await apiClient.get({
|
|
682
|
+
url: `${MANUAL_API_BASE}/reports/${encodeURIComponent(reportId)}`,
|
|
683
|
+
headers: this.headers(),
|
|
684
|
+
raise_error: false,
|
|
685
|
+
});
|
|
686
|
+
return resp.ok ? resp.data : undefined;
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* A manual report's screens as the dashboard reads them, duplicates left out and each
|
|
690
|
+
* issue with its element, fix and measurements; undefined while that route answers an
|
|
691
|
+
* access key with a login redirect, as it does until it takes one.
|
|
692
|
+
*/
|
|
693
|
+
async dashboardScreens(reportId) {
|
|
694
|
+
const details = [];
|
|
695
|
+
for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) {
|
|
696
|
+
const resp = await apiClient
|
|
697
|
+
.get({
|
|
698
|
+
url: `${MANUAL_API_BASE}/app_scanner/${encodeURIComponent(reportId)}/snapshot?hide_duplicate_issues=true&page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`,
|
|
699
|
+
headers: this.headers(),
|
|
700
|
+
raise_error: false,
|
|
701
|
+
})
|
|
702
|
+
.catch(() => undefined);
|
|
703
|
+
const data = resp?.ok && resp.data?.success ? resp.data.data : undefined;
|
|
704
|
+
if (!Array.isArray(data?.details))
|
|
705
|
+
return page === 1 ? undefined : details;
|
|
706
|
+
details.push(...data.details);
|
|
707
|
+
if (!data.info?.next)
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
return details;
|
|
711
|
+
}
|
|
712
|
+
/** Every occurrence the Manual Reports API lists, and the device the report ran on. */
|
|
713
|
+
async manualApiScreens(reportId) {
|
|
714
|
+
const details = [];
|
|
715
|
+
let device;
|
|
716
|
+
for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) {
|
|
717
|
+
const resp = await apiClient.get({
|
|
718
|
+
url: `${MANUAL_API_BASE}/reports/${encodeURIComponent(reportId)}/issues?page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`,
|
|
719
|
+
headers: this.headers(),
|
|
720
|
+
});
|
|
721
|
+
device ??= resp.data?.device;
|
|
722
|
+
details.push(...(resp.data?.data ?? []));
|
|
723
|
+
if (!resp.data?.pagination?.next)
|
|
724
|
+
break;
|
|
725
|
+
}
|
|
726
|
+
return { details, device };
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* A manual report's issues, most severe first: from the dashboard's own screens when the
|
|
730
|
+
* route takes the access key, so the count is the dashboard's and each issue names its
|
|
731
|
+
* element; else every occurrence the Manual Reports API lists, which marks neither
|
|
732
|
+
* duplicates nor elements, with the dashboard's count stated next to it.
|
|
733
|
+
*/
|
|
734
|
+
async appReportIssues(reportId, cursor = 0, maxCharacterLength) {
|
|
735
|
+
const detail = await this.manualReport(reportId);
|
|
736
|
+
if (!detail) {
|
|
737
|
+
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`);
|
|
738
|
+
}
|
|
739
|
+
const dashboard = await this.dashboardScreens(reportId);
|
|
740
|
+
const { details, device } = dashboard
|
|
741
|
+
? { details: dashboard, device: undefined }
|
|
742
|
+
: await this.manualApiScreens(reportId);
|
|
743
|
+
const summary = detail.data?.issue_summary ?? {};
|
|
744
|
+
const all = flattenAppSnapshots(details);
|
|
745
|
+
const { rows, context } = compactAppIssues(all);
|
|
746
|
+
const page = pageOf(rows, { nextPage: cursor, maxCharacterLength });
|
|
747
|
+
return {
|
|
748
|
+
...page,
|
|
749
|
+
...contextOf(page.records, context),
|
|
750
|
+
total_issues: all.length,
|
|
751
|
+
summary: summarizeAppIssues(all),
|
|
752
|
+
deduplicated: dashboard !== undefined,
|
|
753
|
+
dashboard_count: summary.total ?? all.length,
|
|
754
|
+
duplicate_occurrences: summary.severity_count?.duplicate_issues ?? 0,
|
|
755
|
+
hidden_issues: summary.severity_count?.hidden_issues ?? 0,
|
|
756
|
+
scan_count: summary.scan_count ?? details.length,
|
|
757
|
+
status: manualReportStatus(detail),
|
|
758
|
+
report_name: detail.meta_data?.name ?? reportId,
|
|
759
|
+
...(device?.model
|
|
760
|
+
? { device: `${device.model}, ${device.os} ${device.os_version}` }
|
|
761
|
+
: {}),
|
|
762
|
+
dashboard_link: `${APP_DASHBOARD}/report/${reportId}`,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* The id of the project's build that `pick` accepts, newest first, within the first
|
|
767
|
+
* pages; `buildName` narrows the list to builds named like it, when the name is known
|
|
768
|
+
* to be the build's own.
|
|
769
|
+
*/
|
|
770
|
+
async findBuild(projectId, buildName, pick) {
|
|
771
|
+
for await (const { rows } of this.projectBuilds(projectId, buildName)) {
|
|
772
|
+
const row = rows.find(pick);
|
|
773
|
+
if (row?.th_build_id)
|
|
774
|
+
return row.th_build_id;
|
|
775
|
+
}
|
|
776
|
+
return undefined;
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* The project's builds a page at a time, newest first, within the first pages; `more`
|
|
780
|
+
* says whether the list goes on past the page. A page that fails is an error, not the end
|
|
781
|
+
* of the list, or a build with earlier ones would read as its project's first.
|
|
782
|
+
*/
|
|
783
|
+
async *projectBuilds(projectId, buildName) {
|
|
784
|
+
const q = encodeURIComponent;
|
|
785
|
+
const named = buildName ? `build_name=${q(buildName)}&` : "";
|
|
786
|
+
for (let page = 1; page <= MAX_APP_BUILD_LIST_PAGES; page++) {
|
|
787
|
+
const data = await this.listing(`${APP_API_BASE}/project/${q(String(projectId))}/build?${named}page_size=${BUILD_LIST_PAGE_SIZE}&page=${page}`, `page ${page} of App Accessibility project ${projectId}'s builds`);
|
|
788
|
+
const more = Boolean(data.info?.next);
|
|
789
|
+
yield { rows: data.builds ?? [], more };
|
|
790
|
+
if (!more)
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* The build before this one in its project: the newest earlier build of the same name,
|
|
796
|
+
* as an SDK suite keeps its name from run to run, or else the build just before it, as
|
|
797
|
+
* each native (Espresso, XCUITest) run is named apart. None for a project's first build.
|
|
798
|
+
*/
|
|
799
|
+
async previousBuild(buildId) {
|
|
800
|
+
const meta = await this.requireBuildMeta(buildId);
|
|
801
|
+
const projectId = await this.projectId(meta.project_name);
|
|
802
|
+
if (projectId === undefined)
|
|
803
|
+
return undefined;
|
|
804
|
+
let reached = false;
|
|
805
|
+
let justBefore;
|
|
806
|
+
let more = false;
|
|
807
|
+
const ref = (row, same_name) => ({
|
|
808
|
+
buildId: row.th_build_id,
|
|
809
|
+
...(row.name !== undefined ? { name: row.name } : {}),
|
|
810
|
+
...(row.build_number !== undefined
|
|
811
|
+
? { build_number: row.build_number }
|
|
812
|
+
: {}),
|
|
813
|
+
...(row.created_at !== undefined ? { created_at: row.created_at } : {}),
|
|
814
|
+
same_name,
|
|
815
|
+
searched_all: !more,
|
|
816
|
+
});
|
|
817
|
+
for await (const page of this.projectBuilds(projectId)) {
|
|
818
|
+
more = page.more;
|
|
819
|
+
for (const row of page.rows) {
|
|
820
|
+
if (row.th_build_id === buildId) {
|
|
821
|
+
reached = true;
|
|
822
|
+
}
|
|
823
|
+
else if (reached) {
|
|
824
|
+
if (row.name === meta.name)
|
|
825
|
+
return ref(row, true);
|
|
826
|
+
justBefore ??= row;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// Stopping at the page limit is not the end of the list: say so rather than guess.
|
|
831
|
+
if (!reached && more) {
|
|
832
|
+
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`);
|
|
833
|
+
}
|
|
834
|
+
if (!justBefore && more) {
|
|
835
|
+
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`);
|
|
836
|
+
}
|
|
837
|
+
return justBefore && ref(justBefore, false);
|
|
838
|
+
}
|
|
839
|
+
/** The build's page on the App Accessibility dashboard, when its project can be found. */
|
|
840
|
+
async dashboardLink(meta) {
|
|
841
|
+
try {
|
|
842
|
+
const projectId = await this.projectId(meta.project_name);
|
|
843
|
+
if (projectId === undefined)
|
|
844
|
+
return undefined;
|
|
845
|
+
return `${APP_DASHBOARD}/automated-tests/projects/${projectId}/builds/${meta.normalised_name}/${meta.build_id}`;
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
return undefined;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Every scanned screen of the build, up to the page limit, how many issues the dashboard
|
|
853
|
+
* hides, and whether more screens lie past the limit.
|
|
854
|
+
*/
|
|
855
|
+
async snapshots(buildId) {
|
|
856
|
+
const details = [];
|
|
857
|
+
let hidden = 0;
|
|
858
|
+
let more = false;
|
|
859
|
+
for (let page = 1; page <= MAX_SNAPSHOT_PAGES; page++) {
|
|
860
|
+
const resp = await apiClient.get({
|
|
861
|
+
url: `${APP_API_BASE}/build/${encodeURIComponent(buildId)}/snapshot?hide_duplicate_issues=true&page_size=${SNAPSHOT_PAGE_SIZE}&page=${page}`,
|
|
862
|
+
headers: this.headers(),
|
|
863
|
+
});
|
|
864
|
+
const data = resp.data?.data;
|
|
865
|
+
details.push(...(data?.details ?? []));
|
|
866
|
+
hidden = data?.issue_counts?.hidden_issues ?? hidden;
|
|
867
|
+
more = Boolean(data?.info?.next);
|
|
868
|
+
if (!more)
|
|
869
|
+
break;
|
|
870
|
+
}
|
|
871
|
+
return { details, hidden, more };
|
|
872
|
+
}
|
|
873
|
+
async requireBuildMeta(buildId) {
|
|
874
|
+
const meta = await this.buildMeta(buildId);
|
|
875
|
+
if (!meta) {
|
|
876
|
+
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`);
|
|
877
|
+
}
|
|
878
|
+
return meta;
|
|
879
|
+
}
|
|
880
|
+
/** The build's details and scanned screens; a build with no scans has none to read. */
|
|
881
|
+
async readBuild(buildId) {
|
|
882
|
+
const meta = await this.requireBuildMeta(buildId);
|
|
883
|
+
const scanCount = meta.issue_summary?.scan_count ?? 0;
|
|
884
|
+
const { details, hidden, more } = scanCount > 0
|
|
885
|
+
? await this.snapshots(buildId)
|
|
886
|
+
: { details: [], hidden: 0, more: false };
|
|
887
|
+
return { meta, details, hidden, more, scanCount };
|
|
888
|
+
}
|
|
889
|
+
/** Every issue of the build at once, to compare it with another. */
|
|
890
|
+
async buildIssues(buildId) {
|
|
891
|
+
const { meta, details, scanCount } = await this.readBuild(buildId);
|
|
892
|
+
const tests = new Set(details.map((d) => d.meta?.testcase_name).filter((t) => t !== undefined));
|
|
893
|
+
return {
|
|
894
|
+
buildId,
|
|
895
|
+
build_name: meta.name,
|
|
896
|
+
build_number: meta.build_id,
|
|
897
|
+
...(meta.created_at ? { created_at: meta.created_at } : {}),
|
|
898
|
+
status: meta.status,
|
|
899
|
+
scan_count: scanCount,
|
|
900
|
+
tests: [...tests],
|
|
901
|
+
issues: flattenAppSnapshots(details),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
async report(buildId, cursor = 0, maxCharacterLength) {
|
|
905
|
+
const { meta, details, hidden, more, scanCount } = await this.readBuild(buildId);
|
|
906
|
+
const all = flattenAppSnapshots(details);
|
|
907
|
+
const { rows, context } = compactAppIssues(all);
|
|
908
|
+
const page = pageOf(rows, { nextPage: cursor, maxCharacterLength });
|
|
909
|
+
return {
|
|
910
|
+
...page,
|
|
911
|
+
...contextOf(page.records, context),
|
|
912
|
+
total_issues: all.length,
|
|
913
|
+
summary: summarizeAppIssues(all),
|
|
914
|
+
hidden_issues: hidden,
|
|
915
|
+
scan_count: scanCount,
|
|
916
|
+
status: meta.status,
|
|
917
|
+
build_name: meta.name,
|
|
918
|
+
dashboard_link: await this.dashboardLink(meta),
|
|
919
|
+
fup_capped: Boolean(meta.fup?.capped || meta.fup?.tests_dropped),
|
|
920
|
+
...(meta.session_data?.framework?.name
|
|
921
|
+
? { framework: meta.session_data.framework.name }
|
|
922
|
+
: {}),
|
|
923
|
+
...(more ? { screens_read: details.length } : {}),
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
}
|