Skip to content

Commit 4f5f141

Browse files
authored
Merge branch 'main' into rn-upgrade/0.81.5-no-unit-tests
2 parents 6e96377 + dba2c63 commit 4f5f141

13 files changed

Lines changed: 479 additions & 67 deletions

File tree

.github/workflows/build.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ jobs:
125125
uses: actions/setup-node@v4
126126
with:
127127
node-version-file: '.nvmrc'
128+
cache: 'yarn'
128129
- run: yarn install --immutable
129130
- run: node scripts/validate-build-config.js
130131

.github/workflows/run-performance-e2e.yml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ jobs:
142142
needs: [determine-branch-name]
143143
outputs:
144144
android_matrix: ${{ steps.read-matrix.outputs.android_matrix }}
145+
android_mm_connect_matrix: ${{ steps.read-matrix.outputs.android_mm_connect_matrix }}
145146
ios_matrix: ${{ steps.read-matrix.outputs.ios_matrix }}
146147
steps:
147148
- name: Checkout code
@@ -165,18 +166,23 @@ jobs:
165166
fi
166167
167168
ANDROID_MATRIX=$(jq ".android_devices | $FILTER" "$FILE")
169+
ANDROID_MM_CONNECT_MATRIX=$(jq '[.android_devices[] | select(.name | contains("Samsung"))]' "$FILE")
168170
IOS_MATRIX=$(jq ".ios_devices | $FILTER" "$FILE")
169171
170172
{
171173
echo "android_matrix<<EOF"
172174
echo "$ANDROID_MATRIX"
173175
echo "EOF"
176+
echo "android_mm_connect_matrix<<EOF"
177+
echo "$ANDROID_MM_CONNECT_MATRIX"
178+
echo "EOF"
174179
echo "ios_matrix<<EOF"
175180
echo "$IOS_MATRIX"
176181
echo "EOF"
177182
} >> "$GITHUB_OUTPUT"
178183
179184
echo "Selected: $(echo "$ANDROID_MATRIX" | jq length) Android, $(echo "$IOS_MATRIX" | jq length) iOS"
185+
echo "Selected for Android MM-Connect: $(echo "$ANDROID_MM_CONNECT_MATRIX" | jq length)"
180186
181187
set-build-names:
182188
name: Set Unified BrowserStack Build Names
@@ -333,7 +339,7 @@ jobs:
333339
name: Fetch RN Playground APK and Upload to BrowserStack
334340
runs-on: ubuntu-latest
335341
needs: [wait-for-onboarding-completion]
336-
if: always() && !cancelled()
342+
if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc'
337343
outputs:
338344
browserstack-playground-url: ${{ steps.upload-playground.outputs.browserstack-url }}
339345
steps:
@@ -376,13 +382,13 @@ jobs:
376382
set-build-names,
377383
determine-branch-name,
378384
]
379-
if: always() && !cancelled() && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '')
385+
if: always() && !cancelled() && (inputs.build_variant || 'rc') == 'rc' && (needs.trigger-android-dual-versions.result == 'skipped' || needs.trigger-android-dual-versions.result == 'success') && (inputs.browserstack_app_url_android_imported_wallet != '' || needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url != '')
380386
with:
381387
platform: android
382388
build_type: mm-connect
383389
sentry_target: ${{ inputs.sentry_target || 'test' }}
384390
build_variant: ${{ inputs.build_variant || 'rc' }}
385-
device_matrix: ${{ needs.read-device-matrix.outputs.android_matrix }}
391+
device_matrix: ${{ needs.read-device-matrix.outputs.android_mm_connect_matrix }}
386392
browserstack_app_url: ${{ needs.trigger-android-dual-versions.outputs.with-srp-browserstack-url || inputs.browserstack_app_url_android_imported_wallet }}
387393
app_version: ${{ needs.trigger-android-dual-versions.outputs.with-srp-version || 'Manual-Input' }}
388394
branch_name: ${{ needs.determine-branch-name.outputs.branch_name }}

app/components/UI/Ramp/Views/BuildQuote/BuildQuote.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ import {
6969

7070
import TruncatedError from '../../components/TruncatedError';
7171
import { PROVIDER_LINKS } from '../../Aggregator/types';
72+
import { failSession } from '../../headless/sessionRegistry';
7273
const BAILED_ORDER_STATUSES = new Set<RampsOrderStatus>([
7374
RampsOrderStatus.Precreated,
7475
RampsOrderStatus.IdExpired,
@@ -159,10 +160,24 @@ function BuildQuote() {
159160

160161
useEffect(() => {
161162
if (params?.nativeFlowError) {
163+
if (
164+
params.headlessSessionId &&
165+
failSession(
166+
params.headlessSessionId,
167+
{
168+
code: 'AUTH_FAILED',
169+
message: params.nativeFlowError,
170+
},
171+
'AUTH_FAILED',
172+
)
173+
) {
174+
navigation.setParams({ nativeFlowError: undefined });
175+
return;
176+
}
162177
setRampsError(params.nativeFlowError);
163178
navigation.setParams({ nativeFlowError: undefined });
164179
}
165-
}, [params?.nativeFlowError, navigation]);
180+
}, [params?.headlessSessionId, params?.nativeFlowError, navigation]);
166181

167182
const {
168183
userRegion,
@@ -627,6 +642,9 @@ function BuildQuote() {
627642
assetId: selectedToken?.assetId ?? '',
628643
});
629644
} catch (err) {
645+
if (failSession(params?.headlessSessionId, err)) {
646+
return;
647+
}
630648
setRampsError((err as Error).message);
631649
} finally {
632650
setIsContinueLoading(false);
@@ -642,6 +660,7 @@ function BuildQuote() {
642660
selectedPaymentMethod?.id,
643661
rampRoutingDecision,
644662
userRegion?.regionCode,
663+
params?.headlessSessionId,
645664
trackEvent,
646665
createEventBuilder,
647666
continueWithQuote,

app/components/UI/Ramp/Views/Checkout/Checkout.test.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ jest.mock('../../utils/v2OrderToast', () => ({
5858
jest.mock('../../headless/sessionRegistry', () => ({
5959
getSession: jest.fn(),
6060
closeSession: jest.fn(),
61+
failSession: jest.fn(),
6162
}));
6263

6364
jest.mock('../../../../../util/Logger', () => ({
@@ -618,6 +619,8 @@ describe('Checkout', () => {
618619
.getSession as jest.Mock;
619620
const mockCloseSession = jest.requireMock('../../headless/sessionRegistry')
620621
.closeSession as jest.Mock;
622+
const mockFailSession = jest.requireMock('../../headless/sessionRegistry')
623+
.failSession as jest.Mock;
621624
const showV2OrderToastMock = jest.requireMock('../../utils/v2OrderToast')
622625
.showV2OrderToast as jest.Mock;
623626

@@ -641,6 +644,7 @@ describe('Checkout', () => {
641644
beforeEach(() => {
642645
mockGetSession.mockReset();
643646
mockCloseSession.mockReset();
647+
mockFailSession.mockReset();
644648
mockParentPop = jest.fn();
645649
mockNavigation.getParent.mockReturnValue({ pop: mockParentPop });
646650
mockGetOrderFromCallback.mockResolvedValue(mockOrder);
@@ -740,6 +744,52 @@ describe('Checkout', () => {
740744
expect(mockParentPop).toHaveBeenCalled();
741745
});
742746

747+
it('surfaces callback processing failures through onError and skips the ErrorView', async () => {
748+
mockUseParams.mockReturnValue(callbackFlowParams);
749+
mockGetOrderFromCallback.mockRejectedValueOnce(
750+
new Error('callback failed'),
751+
);
752+
mockFailSession.mockReturnValue({
753+
code: 'UNKNOWN',
754+
message: 'callback failed',
755+
});
756+
757+
const { getByTestId, queryByText } = renderWithProvider(
758+
<Checkout />,
759+
{},
760+
true,
761+
false,
762+
);
763+
764+
await act(async () => {
765+
fireEvent.press(getByTestId('trigger-callback-navigation'));
766+
});
767+
768+
await waitFor(() => {
769+
expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error));
770+
});
771+
expect(mockParentPop).toHaveBeenCalled();
772+
expect(showV2OrderToastMock).not.toHaveBeenCalled();
773+
expect(queryByText('callback failed')).toBeNull();
774+
});
775+
776+
it('surfaces provider WebView HTTP errors through onError when headless', async () => {
777+
mockUseParams.mockReturnValue(callbackFlowParams);
778+
mockFailSession.mockReturnValue({
779+
code: 'UNKNOWN',
780+
message: 'fiat_on_ramp_aggregator.webview_received_error',
781+
});
782+
783+
const { getByTestId } = renderWithProvider(<Checkout />, {}, true, false);
784+
785+
await act(async () => {
786+
fireEvent.press(getByTestId('trigger-http-error-main-uri'));
787+
});
788+
789+
expect(mockFailSession).toHaveBeenCalledWith('hs-1', expect.any(Error));
790+
expect(mockParentPop).toHaveBeenCalled();
791+
});
792+
743793
it('falls back to the regular reset + toast when session id is present but session is missing from registry', async () => {
744794
mockGetSession.mockReturnValue(undefined);
745795
mockUseParams.mockReturnValue(callbackFlowParams);

app/components/UI/Ramp/Views/Checkout/Checkout.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,11 @@ import {
2727
import HeaderCompactStandard from '../../../../../component-library/components-temp/HeaderCompactStandard';
2828
import useRampsUnifiedV2Enabled from '../../hooks/useRampsUnifiedV2Enabled';
2929
import { showV2OrderToast } from '../../utils/v2OrderToast';
30-
import { closeSession, getSession } from '../../headless/sessionRegistry';
30+
import {
31+
closeSession,
32+
failSession,
33+
getSession,
34+
} from '../../headless/sessionRegistry';
3135
import { useStyles } from '../../../../hooks/useStyles';
3236
import styleSheet from './Checkout.styles';
3337
import Device from '../../../../../util/device';
@@ -120,6 +124,18 @@ const Checkout = () => {
120124
}
121125
}, [uri, createEventBuilder, trackEvent, rampRoutingDecision]);
122126

127+
const failHeadlessCheckout = useCallback(
128+
(checkoutError: unknown) => {
129+
if (!failSession(headlessSessionId, checkoutError)) {
130+
return false;
131+
}
132+
// @ts-expect-error navigation prop mismatch
133+
navigation.getParent()?.pop();
134+
return true;
135+
},
136+
[headlessSessionId, navigation],
137+
);
138+
123139
useEffect(() => {
124140
// For external-browser flows (e.g. PayPal), addPrecreatedOrder is called in
125141
// BuildQuote; the user never reaches Checkout. For WebView flows,
@@ -234,6 +250,9 @@ const Checkout = () => {
234250
Logger.error(navError as Error, {
235251
message: 'UnifiedCheckout: error handling callback',
236252
});
253+
if (failHeadlessCheckout(navError)) {
254+
return;
255+
}
237256
setError((navError as Error)?.message);
238257
}
239258
},
@@ -248,6 +267,7 @@ const Checkout = () => {
248267
isV2Enabled,
249268
params?.cryptocurrency,
250269
headlessSessionId,
270+
failHeadlessCheckout,
251271
],
252272
);
253273

@@ -344,6 +364,9 @@ const Checkout = () => {
344364
'fiat_on_ramp_aggregator.webview_received_error',
345365
{ code: nativeEvent.statusCode },
346366
);
367+
if (failHeadlessCheckout(new Error(webviewHttpError))) {
368+
return;
369+
}
347370
setError(webviewHttpError);
348371
} else {
349372
Logger.log(

app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,25 @@ describe('HeadlessHost', () => {
348348
expect(screen.getByText('quote expired')).toBeOnTheScreen();
349349
});
350350

351+
it('surfaces limit failures as onError(LIMIT_EXCEEDED, ...)', async () => {
352+
const limitError = new Error('Daily limit exceeded');
353+
limitError.name = 'LimitExceededError';
354+
mockContinueWithQuote.mockRejectedValueOnce(limitError);
355+
const quote = buildNativeQuote();
356+
const session = seedSession(quote);
357+
const callbacks = session.callbacks;
358+
renderHost({ headlessSessionId: session.id });
359+
await waitFor(() => {
360+
expect(callbacks.onError).toHaveBeenCalledWith({
361+
code: 'LIMIT_EXCEEDED',
362+
message: 'Daily limit exceeded',
363+
});
364+
});
365+
expect(callbacks.onClose).toHaveBeenCalledWith({ reason: 'unknown' });
366+
expect(getSession(session.id)).toBeUndefined();
367+
expect(screen.getByText('Daily limit exceeded')).toBeOnTheScreen();
368+
});
369+
351370
it('does not run the continueWithQuote rejection path after unmount', async () => {
352371
let rejectDeferred: ((error: Error) => void) | undefined;
353372
mockContinueWithQuote.mockImplementation(

app/components/UI/Ramp/Views/HeadlessHost/HeadlessHost.tsx

Lines changed: 11 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import Logger from '../../../../../util/Logger';
2727
// Going through the barrel would leave the registry exports `undefined`
2828
// at evaluation time inside this module.
2929
import {
30-
closeSession,
30+
failSession,
3131
getSession,
3232
setStatus,
3333
} from '../../headless/sessionRegistry';
@@ -132,26 +132,17 @@ function HeadlessHost() {
132132
if (!nativeFlowError) {
133133
return;
134134
}
135-
const liveSession = getSession(headlessSessionId);
136-
if (!liveSession) {
137-
return;
138-
}
139-
setErrorMessage(nativeFlowError);
140-
try {
141-
liveSession.callbacks.onError({
142-
code: 'AUTH_FAILED',
143-
message: nativeFlowError,
144-
});
145-
} catch (e) {
146-
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
147-
}
148-
closeSession(
135+
const headlessError = failSession(
149136
headlessSessionId,
150-
{ reason: 'unknown' },
151137
{
152-
terminalStatus: 'failed',
138+
code: 'AUTH_FAILED',
139+
message: nativeFlowError,
153140
},
141+
'AUTH_FAILED',
154142
);
143+
if (headlessError) {
144+
setErrorMessage(headlessError.message ?? nativeFlowError);
145+
}
155146
}, [nativeFlowError, headlessSessionId]);
156147

157148
// Process the session. Uses `useEffect` (not `useFocusEffect`) so that
@@ -202,25 +193,11 @@ function HeadlessHost() {
202193
if (!chainId) {
203194
const message = `HeadlessHost: invalid assetId "${currentSession.params.assetId}"`;
204195
Logger.error(new Error(message));
205-
try {
206-
currentSession.callbacks.onError({
207-
code: 'UNKNOWN',
208-
message,
209-
});
210-
} catch (e) {
211-
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
212-
}
213196
// closeSession alone does not trigger a re-render; without setState the
214197
// render-time `session` ref stays truthy and the loader would spin
215198
// forever. Surface the same message in UI as other error paths.
216199
setErrorMessage(message);
217-
closeSession(
218-
headlessSessionId,
219-
{ reason: 'unknown' },
220-
{
221-
terminalStatus: 'failed',
222-
},
223-
);
200+
failSession(headlessSessionId, { code: 'UNKNOWN', message });
224201
return;
225202
}
226203
// Defer until walletAddress resolves — avoids calling continueWithQuote
@@ -270,22 +247,8 @@ function HeadlessHost() {
270247
if (!liveSession) {
271248
return;
272249
}
273-
setErrorMessage(message);
274-
try {
275-
liveSession.callbacks.onError({
276-
code: 'UNKNOWN',
277-
message,
278-
});
279-
} catch (e) {
280-
Logger.error(e as Error, 'HeadlessHost: onError callback threw');
281-
}
282-
closeSession(
283-
headlessSessionId,
284-
{ reason: 'unknown' },
285-
{
286-
terminalStatus: 'failed',
287-
},
288-
);
250+
const headlessError = failSession(headlessSessionId, error);
251+
setErrorMessage(headlessError?.message ?? message);
289252
});
290253
return () => {
291254
cancelled = true;

app/components/UI/Ramp/headless/PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
- [x] **Phase 5 (revised)** — Quote-first headless start path — `startHeadlessBuy({ quote, redirectUrl? })` creates a session carrying the quote, navigates to Headless Host, Host calls `continueWithQuote(quote, ctx)` and re-orchestrates after auth loops
1515
- [ ] **Phase 5b (deferred)**`startHeadlessBuy({ assetId, amount, paymentMethodId, providerId? })` "open BuildQuote / Host fetches quotes" mode — picked up after the quote-first path is stable
1616
- [x] **Phase 6** — Bypass order-processing redirect in Transak/aggregator routing when headless; fire `onOrderCreated` and end session
17-
- [ ] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
17+
- [x] **Phase 7** — Extract UI-coupled error/limit surfacing; route errors through `onError` as typed `HeadlessBuyError`
1818
- [ ] **Phase 8** — Cancellation + `onClose` semantics (including user-dismissed detection)
1919
- [ ] **Phase 9** — Expose `getOrder` / `refreshOrder` from hook and show in playground
2020
- [ ] **Phase 10** — Playground polish — event log, input persistence, aggregator/native presets

0 commit comments

Comments
 (0)