Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/TOOLS.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,11 +340,11 @@ searchCapability → describeCapability → invokeCapability

| Tool | What it does |
|---|---|
| `startAccessibilityScan` | Start a web accessibility scan of a page URL and retrieve a local CSV report path. Optional settings: `wcagVersion` (WCAG 2.0–2.2 at A/AA/AAA, or RGAA 4.1), `bestPractices`, `needsReview`, `advancedRules` and `experimentalRules`; the answer states the settings the scan ran with. Mobile apps are scanned through an App Automate run instead (`setupBrowserStackAppAutomateTests` with `accessibility: true`). |
| `startAccessibilityScan` | Start a web accessibility scan of a page URL and retrieve a local CSV report path. Optional settings: `wcagVersion` (WCAG 2.0–2.2 at A/AA/AAA, or RGAA 4.1), `bestPractices`, `needsReview`, `advancedRules` and `experimentalRules`; the answer states the settings the scan ran with. To scan the same page again (for example after a fix), pass the earlier scan's `scanId`: that starts a new run of that scan with its saved settings, which `fetchAccessibilityIssues` can compare with `compareWith: "previous"`. Mobile apps are scanned through an App Automate run instead (`setupBrowserStackAppAutomateTests` with `accessibility: true`). |
| `accessibilityExpert` | Ask the A11y Expert (WCAG 2.0/2.1/2.2, mobile/web usability, best practices). |
| `createAccessibilityAuthConfig` | Create an authentication configuration (form-based or basic) for accessibility scans behind a login. |
| `getAccessibilityAuthConfig` | Retrieve an existing accessibility authentication configuration by ID. |
| `fetchAccessibilityIssues` | Fetch accessibility issues, with pagination, from a Website Scanner run, an Automated Tests build (web) or App Accessibility build (mobile app, by the build id the SDK prints), a Workflow Analyzer report, an App Accessibility manual report (`appReportId`), or a report's dashboard link (a Website Scanner link is read by the run's build id, however old the scan; a Workflow Analyzer link by the report id it carries). Once the tests or scan have finished, waits while the report is still processing; a run still in progress is read as it is, with a note. For an app build with no scans, lists the likely causes. |
| `fetchAccessibilityIssues` | Fetch accessibility issues, with pagination, from a Website Scanner run, an Automated Tests build (web) or App Accessibility build (mobile app, by the build id the SDK prints), a Workflow Analyzer report, an App Accessibility manual report (`appReportId`), or a report's dashboard link (a Website Scanner link is read by the run's build id, however old the scan; a Workflow Analyzer link by the report id it carries). Once the tests or scan have finished, a report that is still processing is not waited on: the answer says to call again in a minute or two; a run still in progress is read as it is, with a note. For an app build with no scans, lists the likely causes. |
| `list_app_accessibility_builds` | List an App Accessibility project's builds newest first, with each build's id, status, app and issue counts - use this to find the latest run, a build by name, or one person's builds. |
| `list_app_accessibility_projects` | List the account's App Accessibility projects newest first, or find one by name - use this to get the project id that lists its builds. |
| `list_manual_app_accessibility_reports` | List manual App Accessibility reports (App Live sessions scanned with Workflow Analyzer) newest first, with each report's id, app, status and issue counts - use this to find a report QA shared or the latest manual scan of an app. |
Expand Down
70 changes: 68 additions & 2 deletions src/lib/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,56 @@ class ApiResponse<T = any> {
}
}

/**
* How long a connection gives each of a host's addresses before trying the next. Node's own
* default is 250 ms: on a slow route the IPv4 attempt is dropped that early and the IPv6 one
* fails where there is no IPv6 route, so the call fails with "connect ETIMEDOUT <IPv4>;
* connect ENETUNREACH <IPv6>" within a second. The hosted server saw exactly that.
*/
export const CONNECT_ATTEMPT_TIMEOUT_MS = 2000;

// Shared, so connections are reused as they are on Node's own global agent.
const directAgent = new https.Agent({
keepAlive: true,
autoSelectFamilyAttemptTimeout: CONNECT_ATTEMPT_TIMEOUT_MS,
});

/**
* A connect failure is retried only when it came within this long. A slower one means every
* address already timed out, so the host is down: failing now keeps loops (scan-list walks,
* polling) from stalling twice as long on it.
*/
export const CONNECT_RETRY_WINDOW_MS = 5000;

/** Failures to open the connection at all: nothing was sent, so a request can be repeated. */
const CONNECT_FAILURE =
/\bconnect (ETIMEDOUT|ENETUNREACH|EHOSTUNREACH|ECONNREFUSED)\b/;
const CONNECT_FAILURE_CODES = new Set([
"ETIMEDOUT",
"ENETUNREACH",
"EHOSTUNREACH",
"ECONNREFUSED",
]);

export function isConnectFailure(error: any): boolean {
if (!error || error.response) return false;
const causes = [error, error.cause, ...(error.errors ?? [])].filter(Boolean);
return causes.some(
(cause: any) =>
(cause.syscall === "connect" && CONNECT_FAILURE_CODES.has(cause.code)) ||
CONNECT_FAILURE.test(String(cause.message ?? "")),
);
}

/**
* A body read as a stream, such as a file upload's form-data. The first try reads it even
* when the connection never opens, so a second try would send only what is left of it and
* then wait for the rest with no time limit.
*/
function isStreamBody(body: unknown): boolean {
return typeof (body as { pipe?: unknown } | undefined)?.pipe === "function";
}

// Utility to create HTTPS agent if needed (proxy/CA)
function getAxiosAgent(): AxiosRequestConfig["httpsAgent"] | undefined {
const proxyHost = config.browserstackLocalOptions.proxyHost;
Expand All @@ -88,10 +138,11 @@ function getAxiosAgent(): AxiosRequestConfig["httpsAgent"] | undefined {
// CA only
return new https.Agent({
ca: fs.readFileSync(caCertPath),
autoSelectFamilyAttemptTimeout: CONNECT_ATTEMPT_TIMEOUT_MS,
});
}
// Default agent (no proxy, no CA)
return undefined;
return directAgent;
}

class ApiClient {
Expand Down Expand Up @@ -148,7 +199,22 @@ class ApiClient {
try {
this.validateUrl(url, config);

const res = await fn(this.axiosAgent);
let res: AxiosResponse<T>;
const started = Date.now();
try {
res = await fn(this.axiosAgent);
} catch (error: any) {
// A connection that never opened sent nothing, so one more try is safe even for a
// POST; a second failure is reported as before. Only a fast failure is retried, and
// never a stream body, which the first try has already used up.
if (
!isConnectFailure(error) ||
isStreamBody(config?.data) ||
Date.now() - started >= CONNECT_RETRY_WINDOW_MS
)
throw error;
res = await fn(this.axiosAgent);
}
return new ApiResponse<T>(res);
} catch (error: any) {
if (error.response && !raise_error) {
Expand Down
Loading
Loading