mattermost-mobile-upstream/detox/e2e/support/server_api/client.ts
yasser khan dfc94b3c3c
test(e2e): close manual release-test gaps (#10125)
Closes manual release-test gaps for mobile (Detox + Maestro), automating new test cases and hardening flaky/CI-only failures uncovered along the way.

### New coverage automated
- **MM-T416** offline/online post-retry (Android via airplane mode; iOS ultimately marked unsupported — see below)
- **MM-T1194 / MM-T1193** custom ToS accept/decline survives relaunch
- **MM-T3251** status change via account UI (cross-user visibility)
- **MM-T510** channel-wide/keyword mention delivery — later expanded to a full 5-channel mention matrix with exact server-side counts
- **MM-T3261 (_1/_2)** Report a Problem external hand-off (browser link mode, email mode)
- **MM-T2940** iOS managed-config (MDM) pre-fill/autoconnect
- **MM-T3271/3272** opening the app from a push notification (synthetic notification payloads, background/closed states)
- Un-skipped several tests that turned out to already pass (stale skips)

### Root-caused failing tests
- **Android SQLite corruption**: traced to native Kotlin DB connections not opening in WAL mode while JSI did (post #10109) — journal-mode mismatch corrupted the store on push-triggered writes. Fixed properly with a **WAL patch** (#10117) rather than the original destructive "wipe app database" workaround, which was reverted.
- **Fabric crash** (`addViewAt`/reparent) on `SlideUpPanelItem` taps — root-caused to view flattening racing sheet mounts; fixed with `collapsable={false}` (went through a revert/re-revert once CI reproduced the crash without it).
- **Search race**: stale in-flight search results could overwrite a cleared search box — fixed with request-id tagging.
- **iOS offline detection**: multiple iterations (AAAA sampling → Cloudflare-prefix check → final decision) landed on refusing iOS offline simulation outright, since all E2E servers are Cloudflare-fronted/anycast and the address space can't be enumerated/blocked.
- **MM-T3261 config patch** intermittently silently ignored by the server — root-caused to `ReportAProblemType` being environment-managed on PR Spinwicks (config PATCH returns 200 but is silently dropped); now detected and reported as a skip with proven cause, not a failure.
- **MM-T416 failed-post timing**: multiple rounds of tightening the wait bound to match the client's actual retry/backoff/timeout ceiling (ended at ~150s, derived from OkHttp/retry-interceptor math, not guessed).
- **MM-T5603 upload wait**: raised to match the client's real 180s upload timeout.
- **Classification banner suite**: fixed to verify config state directly instead of trusting a PATCH reply that the cloud sometimes drops.

### CI/reporting infra fixes
- Dedicated Maestro steps (MM-T3261, MM-T67856_4) weren't feeding into the merged JUnit report and couldn't gate pass/fail — fixed report merging/naming so they now count.
- Android Maestro logcat capture added (previously iOS-only diagnostics).
- Jest retry logic no longer discards passing results from a shard killed by timeout.
- Sharding raised 24→28.
- Removed dead Detox Pilot/Playbooks scaffolding (unused, uncovered by CI).

### Cleanup
- Comment/docs pass stripping narrative session-archaeology from long explanatory comments, keeping only load-bearing facts and CI references.
2026-09-11 09:48:02 +05:30

326 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import http from 'http';
import https from 'https';
import {adminPassword, adminUsername} from '@support/test_config';
import axios, {type InternalAxiosRequestConfig} from 'axios';
import {wrapper} from 'axios-cookiejar-support';
import {CookieJar} from 'tough-cookie';
import {logError} from '../../../provision/log';
// Force IPv4 — axios-cookiejar-support v5 uses global agents.
(http.globalAgent as any).options.family = 4;
(https.globalAgent as any).options.family = 4;
const jar = new CookieJar();
// Bound hung TCP / silent Cloudflare stalls. Without a client timeout, apiCreatePost
// can sit in test_fn until Jest's 300s cap with zero Detox UI actions.
const REQUEST_TIMEOUT_MS = 45_000;
export const RETRY_BUDGET_MS = 90_000;
type RetryBudgetConfig = {_retryDeadlineAt?: number};
/**
* True when there is room for another whole attempt. Stamps the deadline on first
* use so the budget covers the retry sequence, not each retry in isolation.
*/
const hasRetryBudget = (config: RetryBudgetConfig, attemptCostMs = REQUEST_TIMEOUT_MS): boolean => {
const now = Date.now();
if (config._retryDeadlineAt === undefined) {
config._retryDeadlineAt = now + RETRY_BUDGET_MS;
}
return config._retryDeadlineAt - now >= attemptCostMs;
};
const baseClient = wrapper(axios.create({
headers: {'X-Requested-With': 'XMLHttpRequest'},
jar,
timeout: REQUEST_TIMEOUT_MS,
}));
// Add request interceptor to handle CSRF tokens
baseClient.interceptors.request.use(async (config) => {
// Extract CSRF token from MMCSRF cookie and add as header
const cookies = jar.getCookiesSync(config.url || '');
const csrfCookie = cookies.find((cookie) => cookie.key === 'MMCSRF');
if (csrfCookie && csrfCookie.value) {
config.headers = config.headers || {};
config.headers['X-CSRF-Token'] = csrfCookie.value;
}
return config;
});
// Auto-retry on 401 by re-logging in as admin.
let isRetrying = false;
baseClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Retry once on 401.
if (
error.response?.status === 401 &&
!originalRequest._retried &&
!isRetrying &&
!originalRequest.url?.endsWith('/api/v4/users/login')
) {
originalRequest._retried = true;
isRetrying = true;
try {
await clearCookies();
await baseClient.post(
`${originalRequest.url?.split('/api/')[0]}/api/v4/users/login`,
{login_id: adminUsername, password: adminPassword},
);
console.info('🔄 Admin session refreshed after 401'); // eslint-disable-line no-console
return baseClient(originalRequest);
} catch (retryErr) {
return Promise.reject(retryErr);
} finally {
isRetrying = false;
}
}
return Promise.reject(error);
},
);
/**
* A timed-out request produced no response, so we cannot tell whether the server
* committed it. Replaying is only safe for idempotent methods; a POST that timed
* out may well have created the team/channel/post already, and replaying it
* duplicates the record. Non-idempotent callers that want a retry ask for one
* explicitly (see withTransportRetry's allowDuplicateWrites).
*
* A non-replayable body (a stream or FormData) is single-use: it has already been
* consumed by the failed attempt, so a replay would send an empty or truncated
* request. Those are never retried regardless of method.
*/
const isReplayableBody = (data: unknown): boolean => {
if (data === undefined || data === null) {
return true;
}
if (typeof data === 'string' || typeof data === 'object') {
const ctor = (data as {constructor?: {name?: string}}).constructor?.name ?? '';
if (ctor === 'FormData' || ctor === 'ReadStream' || ctor === 'Readable') {
return false;
}
return typeof (data as {pipe?: unknown}).pipe !== 'function';
}
return true;
};
// Retry client-side timeouts (no HTTP response). A 30–45s hang is a dropped
// TCP/Cloudflare stall, not a committed write we can observe.
baseClient.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config as typeof error.config & {_timeoutRetries?: number; _retryDeadlineAt?: number};
const timedOut = !error.response && (
error.code === 'ECONNABORTED' ||
String(error.message || '').includes('timeout')
);
const replayable = Boolean(config) &&
IDEMPOTENT_METHODS.has((config.method ?? 'get').toLowerCase()) &&
isReplayableBody(config.data);
if (timedOut && replayable && hasRetryBudget(config)) {
config._timeoutRetries = (config._timeoutRetries ?? 0) + 1;
const delay = config._timeoutRetries * 1000;
logError(`[client] request timeout — retry ${config._timeoutRetries} for ${config.method} ${config.url} in ${delay}ms`);
await new Promise((r) => setTimeout(r, delay)); // eslint-disable-line no-promise-executor-return
return baseClient(config);
}
return Promise.reject(error);
},
);
/**
* Failures raised before any byte of the request left the process: the hostname did not
* resolve, or the TCP connection was refused / unroutable. Unlike a timeout, these leave no
* doubt about server-side state — nothing was sent, so nothing can have been committed —
* which is why a replay is safe for every method, POST included. The one exception is a
* single-use body (see isReplayableBody), which the failed attempt may already have consumed.
*
* Seen on GitHub macOS runners as `getaddrinfo ENOTFOUND <site>.test.mattermost.cloud`
* mid-spec, between requests to the same host that succeeded (MM-T4876_2 on PR #10122).
* Without this the error reaches getResponseFromError as `{status: 0}` and the spec
* dereferences the missing fixture.
*/
const PRE_CONNECTION_ERROR_CODES: ReadonlySet<string> = new Set([
'ENOTFOUND',
'EAI_AGAIN',
'ECONNREFUSED',
'EHOSTUNREACH',
'ENETUNREACH',
]);
export const PRE_CONNECTION_MAX_RETRIES = 3;
export const PRE_CONNECTION_RETRY_DELAY_MS = 1000;
export const isPreConnectionFailure = (error: {response?: unknown; code?: unknown}): boolean =>
!error.response && typeof error.code === 'string' && PRE_CONNECTION_ERROR_CODES.has(error.code);
baseClient.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config as (typeof error.config & {_preConnectionRetries?: number; _retryDeadlineAt?: number}) | undefined;
if (!config || !isPreConnectionFailure(error) || !isReplayableBody(config.data)) {
return Promise.reject(error);
}
const retries = config._preConnectionRetries ?? 0;
if (retries >= PRE_CONNECTION_MAX_RETRIES || !hasRetryBudget(config, 0)) {
return Promise.reject(error);
}
config._preConnectionRetries = retries + 1;
const delay = config._preConnectionRetries * PRE_CONNECTION_RETRY_DELAY_MS;
logError(`[client] ${error.code} before the request was sent — retry ${config._preConnectionRetries}/${PRE_CONNECTION_MAX_RETRIES} for ${config.method} ${config.url} in ${delay}ms`);
await new Promise((r) => setTimeout(r, delay)); // eslint-disable-line no-promise-executor-return
return baseClient(config);
},
);
/** Cloudflare edge failures — 520–524 stay retryable alongside the gateway 5xx below. */
const CLOUDFLARE_EDGE_STATUSES: ReadonlySet<number> = new Set([520, 521, 522, 523, 524]);
const TRANSIENT_HTTP_STATUSES: ReadonlySet<number> = new Set([502, 503, 504, ...CLOUDFLARE_EDGE_STATUSES]);
/**
* Cloudflare could not reach the origin, so the request provably had no side effect.
* 522 is excluded: the connection can also time out after the origin accepted the request.
*/
const PRE_ORIGIN_STATUSES: ReadonlySet<number> = new Set([521, 523]);
const IDEMPOTENT_METHODS: ReadonlySet<string> = new Set(['get', 'head', 'options']);
/** Gateway 5xx plus Cloudflare edge — shared with the apiInit retry layer in setup.ts. */
export const isTransientHttpStatus = (status?: number): status is number =>
status !== undefined && TRANSIENT_HTTP_STATUSES.has(status);
/**
* Upper bound for an honored Cloudflare `retry_after`; the advertised tens of seconds blow
* Detox's 300s beforeAll budget. Shared with the apiInit retry layer in setup.ts.
*/
export const MAX_RETRY_AFTER_SEC = 3;
// Retry transient gateway / Cloudflare edge 5xx with short backoff.
baseClient.interceptors.response.use(
(response) => response,
async (error) => {
const config = error.config as typeof error.config & {_5xxRetries?: number; _retryDeadlineAt?: number};
const status = error.response?.status;
// A write may already have reached the origin behind 502/503/504 and CF 520/522/524, so
// only idempotent methods are retried on those; CF 521/523 never reach the origin at all.
const isSafeToRetry = isTransientHttpStatus(status) &&
(IDEMPOTENT_METHODS.has((config.method ?? 'get').toLowerCase()) || PRE_ORIGIN_STATUSES.has(status));
if (isSafeToRetry && hasRetryBudget(config, 0) && (config._5xxRetries ?? 0) < 3) {
config._5xxRetries = (config._5xxRetries ?? 0) + 1;
const retryAfterSec = Number(error.response?.data?.retry_after);
const cappedRetryAfterMs = Number.isFinite(retryAfterSec) && retryAfterSec > 0 ?
Math.min(retryAfterSec, MAX_RETRY_AFTER_SEC) * 1000 :
0;
const delay = cappedRetryAfterMs || (config._5xxRetries * 1000);
logError(`[client] ${status} from server — retry ${config._5xxRetries}/3 in ${delay}ms`);
await new Promise((r) => setTimeout(r, delay)); // eslint-disable-line no-promise-executor-return
return baseClient(config);
}
return Promise.reject(error);
},
);
/**
* HTML the edge returns *in place of* the API. Two kinds appear against the ephemeral test
* servers: the cloud cold-start page, and Cloudflare's bot check ("Just a moment…"). Like the
* DNS block above — and unlike a timeout — both are provably side-effect-free: the request was
* answered at the edge and never reached Mattermost, so replaying a POST is as safe as a GET.
*
* Matched on the body, not the status. The cold-start page can arrive with a 2xx while a
* challenge is usually 403 or 503, so no status set covers both; 403 in particular is not, and
* should not become, a globally retryable status. Only `cloud/inaccessible` used to be matched
* here, which is how a challenge reached callers as though it were a response body — surfacing
* downstream as "server is not healthy", "Failed to create team", and TypeErrors dereferencing
* fixtures the API had never created.
*/
const HTML_INTERSTITIAL_MARKERS: readonly string[] = [
'cloud/inaccessible',
'_cf_chl_opt',
'cf-browser-verification',
'captcha challenge',
'Just a moment',
];
const MAX_INTERSTITIAL_RETRIES = 3;
type InterstitialConfig = InternalAxiosRequestConfig & {_htmlInterstitialRetries?: number; _retryDeadlineAt?: number};
const interstitialMarkerIn = (data: unknown): string | undefined => (
typeof data === 'string' ? HTML_INTERSTITIAL_MARKERS.find((marker) => data.includes(marker)) : undefined
);
/**
* True for the error `retryInterstitial` throws once its own attempts are spent. Exported so
* the apiInit retry layer in setup.ts can treat an edge interstitial as transient and spend
* its own budget on it — a challenge can outlast this interceptor's ~18s of backoff.
*/
export const isHtmlInterstitialError = (error: unknown): boolean => {
const message = typeof error === 'string' ? error : String((error as {message?: unknown})?.message ?? '');
return message.startsWith('Server returned "') && message.includes('HTML for ');
};
const retryInterstitial = async (config: InterstitialConfig, marker: string) => {
const attempts = (config._htmlInterstitialRetries ?? 0) + 1;
if (attempts > MAX_INTERSTITIAL_RETRIES || !hasRetryBudget(config, 0)) {
throw new Error(`Server returned "${marker}" HTML for ${config.url} (retries exhausted or retry budget spent)`);
}
config._htmlInterstitialRetries = attempts;
// A managed challenge clears in seconds once the edge is satisfied, so back off in whole
// seconds rather than the sub-second steps used for gateway 5xx.
const delay = attempts * 3000;
logError(`[client] "${marker}" HTML from server — retry ${attempts}/${MAX_INTERSTITIAL_RETRIES} in ${delay}ms for ${config.url}`);
await new Promise((r) => setTimeout(r, delay)); // eslint-disable-line no-promise-executor-return
return baseClient(config);
};
// Both axios paths are covered: a 2xx interstitial resolves and lands on the success handler,
// while a 403/503 challenge rejects and lands on the error handler.
baseClient.interceptors.response.use(
async (response) => {
const marker = interstitialMarkerIn(response.data);
if (!marker) {
return response;
}
return retryInterstitial(response.config as InterstitialConfig, marker);
},
async (error) => {
const marker = interstitialMarkerIn(error.response?.data);
if (!marker || !error.config) {
return Promise.reject(error);
}
return retryInterstitial(error.config as InterstitialConfig, marker);
},
);
export const clearCookies = async (): Promise<void> => {
await jar.removeAllCookies();
};
export const client = baseClient;
export default client;