diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx index dc45415b30a..d7cc7d22e43 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx @@ -316,6 +316,80 @@ describe('PredictActionButtons', () => { expect(screen.getAllByText('35¢')).toHaveLength(1); }); + it('uses token-matched teams when a game moneyline returns home-away tokens', () => { + const outcome = createMockOutcome({ + sportsMarketType: 'moneyline', + tokens: [ + { + id: 'token-ivashka', + title: 'Ilya Ivashka', + shortTitle: 'IVASHKA', + price: 0.63, + }, + { + id: 'token-stewart', + title: 'Hamish Stewart', + shortTitle: 'STEWART', + price: 0.38, + }, + ], + }); + const market = createMockMarket({ + outcomes: [outcome], + game: { + id: 'game-atp-1', + startTime: '2026-05-22T07:30:00Z', + status: 'scheduled', + league: 'atp', + elapsed: null, + period: null, + score: null, + awayTeam: { + id: 'stewart', + name: 'Hamish Stewart', + logo: 'https://example.com/stewart.png', + abbreviation: 'STEWART', + color: TEST_HEX_COLORS.TEAM_SEA, + alias: 'H. Stewart', + }, + homeTeam: { + id: 'ivashka', + name: 'Ilya Ivashka', + logo: 'https://example.com/ivashka.png', + abbreviation: 'IVASHKA', + color: TEST_HEX_COLORS.TEAM_DEN, + alias: 'I. Ivashka', + }, + }, + }); + + const mockOnBetPress = jest.fn(); + const props = createDefaultProps({ + market, + outcome, + onBetPress: mockOnBetPress, + }); + + renderWithProvider(); + + expect(screen.getByText('IVASHKA')).toBeOnTheScreen(); + expect(screen.getByText('STEWART')).toBeOnTheScreen(); + expect(screen.getAllByText('63¢')).toHaveLength(1); + expect(screen.getAllByText('38¢')).toHaveLength(1); + + fireEvent.press(screen.getByTestId('action-buttons-bet-yes')); + fireEvent.press(screen.getByTestId('action-buttons-bet-no')); + + expect(mockOnBetPress).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ id: 'token-ivashka' }), + ); + expect(mockOnBetPress).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ id: 'token-stewart' }), + ); + }); + it('calls onBetPress with correct token for away team', () => { const mockOnBetPress = jest.fn(); const outcome = createMockOutcome(); @@ -379,6 +453,49 @@ describe('PredictActionButtons', () => { expect.objectContaining({ id: 'token-draw' }), ); }); + + it('ignores extended non-moneyline outcomes for draw-capable leagues', () => { + const market = createMockDrawCapableGameMarket(); + const [awayOutcome, drawOutcome, homeOutcome] = market.outcomes; + const extendedMarket = { + ...market, + outcomes: [ + createMockOutcome({ + id: 'outcome-spread', + sportsMarketType: 'spreads', + groupItemThreshold: -2.5, + tokens: [{ id: 'token-spread', title: 'Spread', price: 0.16 }], + }), + { ...awayOutcome, sportsMarketType: 'moneyline' }, + createMockOutcome({ + id: 'outcome-halftime', + sportsMarketType: 'soccer_halftime_result', + groupItemThreshold: 1, + tokens: [{ id: 'token-halftime', title: 'Draw', price: 0.2 }], + }), + { ...drawOutcome, sportsMarketType: 'moneyline' }, + { ...homeOutcome, sportsMarketType: 'moneyline' }, + ], + }; + + const props = createDefaultProps({ + market: extendedMarket, + outcome: extendedMarket.outcomes[0], + }); + + renderWithProvider(); + + expect(screen.getByText('ARS')).toBeOnTheScreen(); + expect(screen.getByText('DRAW')).toBeOnTheScreen(); + expect(screen.getByText('PSG')).toBeOnTheScreen(); + expect(screen.getAllByText('42¢')).toHaveLength(1); + expect(screen.getAllByText('30¢')).toHaveLength(1); + expect(screen.getAllByText('28¢')).toHaveLength(1); + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith( + ['token-home', 'token-draw', 'token-away'], + { enabled: true }, + ); + }); }); describe('priority order', () => { diff --git a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx index c784d1b4f63..31aed30b061 100644 --- a/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx +++ b/app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx @@ -7,9 +7,16 @@ import { PredictActionButtonsProps, PredictBetButtonLayout, } from './PredictActionButtons.types'; -import { PredictMarketStatus, PredictOutcomeToken } from '../../types'; +import { + PredictMarketGame, + PredictMarketStatus, + PredictOutcomeToken, +} from '../../types'; import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; -import { isDrawCapableLeague } from '../../constants/sports'; +import { + getPrimaryMoneylineOutcomes, + isDrawCapableLeague, +} from '../../constants/sports'; import { BASE_PREDICT_ACTION_BUTTONS_TEST_IDS, PREDICT_ACTION_BUTTONS_TEST_IDS, @@ -29,6 +36,38 @@ interface ButtonConfig { drawToken?: PredictOutcomeToken; } +type GameTeam = PredictMarketGame['homeTeam']; + +const normalizeLabel = (value?: string): string | undefined => + value?.trim().toLowerCase(); + +const teamMatchesToken = ( + team: GameTeam, + token: PredictOutcomeToken, +): boolean => { + const tokenLabels = [token.shortTitle, token.title] + .map(normalizeLabel) + .filter((label): label is string => Boolean(label)); + const teamLabels = [team.abbreviation, team.name, team.alias] + .map(normalizeLabel) + .filter((label): label is string => Boolean(label)); + + return tokenLabels.some((tokenLabel) => teamLabels.includes(tokenLabel)); +}; + +const getTokenTeam = ( + token: PredictOutcomeToken, + game: PredictMarketGame, +): GameTeam | undefined => { + if (teamMatchesToken(game.homeTeam, token)) { + return game.homeTeam; + } + if (teamMatchesToken(game.awayTeam, token)) { + return game.awayTeam; + } + return undefined; +}; + const PredictActionButtons: React.FC = ({ market, outcome, @@ -45,21 +84,33 @@ const PredictActionButtons: React.FC = ({ }) => { const isGameMarket = Boolean(market.game); const isMarketOpen = market.status === PredictMarketStatus.OPEN; + const moneylineOutcomes = useMemo( + () => getPrimaryMoneylineOutcomes(market.outcomes), + [market.outcomes], + ); + const hasMainMoneylineOutcomes = moneylineOutcomes.some( + (marketOutcome) => + marketOutcome.sportsMarketType?.toLowerCase() === 'moneyline', + ); + const primaryOutcome = + hasMainMoneylineOutcomes && !moneylineOutcomes.includes(outcome) + ? (moneylineOutcomes[0] ?? outcome) + : outcome; const isDrawCapable = isGameMarket && market.game && isDrawCapableLeague(market.game.league) && - market.outcomes.length >= 3; + moneylineOutcomes.length >= 3; const sortedOutcomes = useMemo(() => { if (!isDrawCapable) { return null; } - return [...market.outcomes].sort( + return [...moneylineOutcomes].sort( (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0), ); - }, [isDrawCapable, market.outcomes]); + }, [isDrawCapable, moneylineOutcomes]); const tokenIds = useMemo(() => { if (sortedOutcomes) { @@ -68,8 +119,8 @@ const PredictActionButtons: React.FC = ({ .filter((tokenId): tokenId is string => Boolean(tokenId)); } - return outcome.tokens.map((token) => token.id); - }, [sortedOutcomes, outcome.tokens]); + return primaryOutcome.tokens.map((token) => token.id); + }, [sortedOutcomes, primaryOutcome.tokens]); const { getPrice } = useLiveMarketPrices(tokenIds, { enabled: isMarketOpen && !isLoading, @@ -110,7 +161,7 @@ const PredictActionButtons: React.FC = ({ }; } - const tokens = outcome.tokens; + const tokens = primaryOutcome.tokens; if (tokens.length < 2) { return null; } @@ -126,14 +177,17 @@ const PredictActionButtons: React.FC = ({ if (isGameMarket && market.game) { const { awayTeam, homeTeam } = market.game; + const yesTeam = getTokenTeam(yesToken, market.game) ?? awayTeam; + const noTeam = getTokenTeam(noToken, market.game) ?? homeTeam; + return { - yesLabel: awayTeam.abbreviation, + yesLabel: yesTeam.abbreviation, yesPrice: Math.round(yesPrice * 100), - yesTeamColor: awayTeam.color, + yesTeamColor: yesTeam.color, yesToken, - noLabel: homeTeam.abbreviation, + noLabel: noTeam.abbreviation, noPrice: Math.round(noPrice * 100), - noTeamColor: homeTeam.color, + noTeamColor: noTeam.color, noToken, }; } @@ -148,7 +202,13 @@ const PredictActionButtons: React.FC = ({ noTeamColor: undefined, noToken, }; - }, [outcome.tokens, isGameMarket, market.game, sortedOutcomes, getPrice]); + }, [ + primaryOutcome.tokens, + isGameMarket, + market.game, + sortedOutcomes, + getPrice, + ]); if (isLoading) { return ( diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts index 0bbe19e2701..ece1d738cd8 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.constants.ts @@ -1,4 +1,7 @@ export const CHART_HEIGHT = 200; +export const TIMEFRAME_SELECTOR_RESERVED_HEIGHT = 56; +export const CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT = + CHART_HEIGHT + TIMEFRAME_SELECTOR_RESERVED_HEIGHT; export const FONT_SIZE_LABEL = 14; export const FONT_SIZE_VALUE = 24; export const LABEL_HEIGHT = 40; diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx index 09c40a62ed3..7f085d05ba1 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.test.tsx @@ -4,6 +4,7 @@ import renderWithProvider from '../../../../../util/test/renderWithProvider'; import PredictGameChartContent from './PredictGameChartContent'; import { GameChartSeries } from './PredictGameChart.types'; import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; +import { CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT } from './PredictGameChart.constants'; jest.mock('react-native-svg-charts', () => { const { View, Text } = jest.requireActual('react-native'); @@ -313,6 +314,35 @@ describe('PredictGameChartContent (Chart UI)', () => { expect(getByText('Live')).toBeOnTheScreen(); }); + + it('reserves chart height only while loading', () => { + const onTimeframeChange = jest.fn(); + + const { getByTestId, rerender } = renderWithProvider( + , + ); + + expect(getByTestId('chart')).toHaveStyle({ + minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT, + }); + + rerender( + , + ); + + expect(getByTestId('chart')).not.toHaveStyle({ + minHeight: CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT, + }); + }); }); describe('Data Processing', () => { diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx index 868229ca7b4..e451134046e 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx @@ -8,7 +8,10 @@ import React, { import { PredictGameStatus, PredictPriceHistoryInterval } from '../../types'; import { usePredictPriceHistory } from '../../hooks/usePredictPriceHistory'; import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices'; -import { isDrawCapableLeague } from '../../constants/sports'; +import { + getPrimaryMoneylineOutcomes, + isDrawCapableLeague, +} from '../../constants/sports'; import { useTheme } from '../../../../../util/theme'; import PredictGameChartContent from './PredictGameChartContent'; import { @@ -72,27 +75,31 @@ const PredictGameChart: React.FC = ({ const gameStatus = game?.status; const isGameEnded = gameStatus === 'ended'; const isGameOngoing = gameStatus === 'ongoing'; + const moneylineOutcomes = useMemo( + () => getPrimaryMoneylineOutcomes(market.outcomes), + [market.outcomes], + ); const tokenIds = useMemo(() => { if ( game?.league && isDrawCapableLeague(game.league) && - market.outcomes.length >= 3 + moneylineOutcomes.length >= 3 ) { - return [...market.outcomes] + return [...moneylineOutcomes] .sort( (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0), ) .map((o) => o.tokens[0]?.id) .filter((id): id is string => Boolean(id)); } - const tokens = market.outcomes[0]?.tokens ?? []; + const tokens = moneylineOutcomes[0]?.tokens ?? []; return tokens.map((t) => t.id); - }, [market.outcomes, game?.league]); + }, [moneylineOutcomes, game?.league]); const seriesConfig: GameChartSeriesConfig[] | null = useMemo(() => { if (!game) return null; - if (isDrawCapableLeague(game.league) && market.outcomes.length >= 3) { + if (isDrawCapableLeague(game.league) && moneylineOutcomes.length >= 3) { return [ { label: game.homeTeam.abbreviation, color: game.homeTeam.color }, { label: 'DRAW', color: colors.icon.muted }, @@ -103,7 +110,7 @@ const PredictGameChart: React.FC = ({ { label: game.awayTeam.abbreviation, color: game.awayTeam.color }, { label: game.homeTeam.abbreviation, color: game.homeTeam.color }, ]; - }, [game, market.outcomes.length, colors.icon.muted]); + }, [game, moneylineOutcomes.length, colors.icon.muted]); const [timeframe, setTimeframe] = useState(() => getDefaultTimeframe(gameStatus), diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx index 0d595714504..3d77d7f691a 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChart.wrapper.test.tsx @@ -9,6 +9,7 @@ import { PredictMarketStatus, PredictPriceHistoryInterval, PredictGameStatus, + PredictOutcome, } from '../../types'; import { POLYMARKET_PROVIDER_ID } from '../../providers/polymarket/constants'; @@ -123,6 +124,20 @@ const createMockMarket = ( ...overrides, }) as PredictMarket; +const createChartOutcome = ( + overrides: Partial, +): PredictOutcome => + ({ + id: 'outcome', + marketId: 'test-market-id', + title: 'Outcome', + groupItemTitle: 'Outcome', + status: 'open', + volume: 1000, + tokens: [{ id: 'token', title: 'Outcome', price: 0.5 }], + ...overrides, + }) as PredictOutcome; + const defaultTokenIds: [string, string] = ['token-a', 'token-b']; const defaultMarket = createMockMarket(); @@ -194,6 +209,60 @@ describe('PredictGameChart Wrapper', () => { }), ); }); + + it('uses only main moneyline tokens for draw-capable extended sports markets', () => { + const market = createMockMarket({ + game: { + ...mockBaseGame, + league: 'fifwc', + }, + outcomes: [ + createChartOutcome({ + id: 'spread', + sportsMarketType: 'spreads', + groupItemThreshold: -2.5, + tokens: [{ id: 'token-spread', title: 'MEX -2.5', price: 0.16 }], + }), + createChartOutcome({ + id: 'away', + sportsMarketType: 'moneyline', + groupItemThreshold: 2, + tokens: [{ id: 'token-away', title: 'Ghana', price: 0.12 }], + }), + createChartOutcome({ + id: 'halftime', + sportsMarketType: 'soccer_halftime_result', + groupItemThreshold: 1, + tokens: [{ id: 'token-halftime', title: 'Draw', price: 0.2 }], + }), + createChartOutcome({ + id: 'draw', + sportsMarketType: 'moneyline', + groupItemThreshold: 1, + tokens: [{ id: 'token-draw', title: 'Draw', price: 0.19 }], + }), + createChartOutcome({ + id: 'home', + sportsMarketType: 'moneyline', + groupItemThreshold: 0, + tokens: [{ id: 'token-home', title: 'Mexico', price: 0.7 }], + }), + ], + }); + + render(); + + expect(mockUsePredictPriceHistory).toHaveBeenCalledWith( + expect.objectContaining({ + marketIds: ['token-home', 'token-draw', 'token-away'], + enabled: true, + }), + ); + expect(mockUseLiveMarketPrices).toHaveBeenCalledWith( + ['token-home', 'token-draw', 'token-away'], + { enabled: true }, + ); + }); }); describe('Data Transformation', () => { diff --git a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx index 329a49b0b6a..d9e735bb812 100644 --- a/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx +++ b/app/components/UI/Predict/components/PredictGameChart/PredictGameChartContent.tsx @@ -20,7 +20,10 @@ import { PredictGameChartContentProps } from './PredictGameChart.types'; import TimeframeSelector from './TimeframeSelector'; import ChartTooltip from './ChartTooltip'; import EndpointDots from './EndpointDots'; -import { CHART_HEIGHT } from './PredictGameChart.constants'; +import { + CHART_HEIGHT, + CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT, +} from './PredictGameChart.constants'; import { PREDICT_GAME_CHART_CONTENT_TEST_IDS } from './PredictGameChartContent.testIds'; const CHART_CONTENT_INSET = { top: 30, bottom: 20, left: 0, right: 80 }; @@ -41,6 +44,9 @@ const PredictGameChartContent: React.FC = ({ const [activeIndex, setActiveIndex] = useState(-1); const chartWidthRef = useRef(0); const primaryDataLengthRef = useRef(0); + const loadingContainerMinHeight = onTimeframeChange + ? CHART_WITH_TIMEFRAME_SELECTOR_HEIGHT + : CHART_HEIGHT; const seriesToRender = data; const nonEmptySeries = useMemo( @@ -139,7 +145,11 @@ const PredictGameChartContent: React.FC = ({ if (isLoading) { return ( - + diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx index 7dcc0b4c590..8f177d7b2c0 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsContent.tsx @@ -253,6 +253,7 @@ const PredictGameDetailsContent: React.FC = ({ claimablePositions={claimablePositions} groupMap={groupMap} activeChipKey={activeChipKey} + onBetPress={onBetPress} /> diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx index f82f4803390..491c2cb9880 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.test.tsx @@ -11,24 +11,6 @@ import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsConte import { TEST_HEX_COLORS } from '../../testUtils/mockColors'; import type { PredictMarketDetailsTabKey } from '../../Predict.testIds'; -jest.mock('@react-navigation/native', () => ({ - useNavigation: () => ({ navigate: jest.fn() }), -})); - -jest.mock('../../hooks/usePredictActionGuard', () => ({ - usePredictActionGuard: () => ({ - executeGuardedAction: (action: () => void) => action(), - isEligible: true, - }), -})); - -const mockNavigateToBuyPreview = jest.fn(); -jest.mock('../../hooks/usePredictNavigation', () => ({ - usePredictNavigation: () => ({ - navigateToBuyPreview: mockNavigateToBuyPreview, - }), -})); - jest.mock('./PredictGameOutcomesTab', () => { const { View, Pressable, Text } = jest.requireActual('react-native'); const { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS: IDS } = jest.requireActual( @@ -148,6 +130,8 @@ const positionsTabs: { label: string; key: PredictMarketDetailsTabKey }[] = [ ]; describe('PredictGameDetailsTabs', () => { + const mockOnBetPress = jest.fn(); + beforeEach(() => { jest.clearAllMocks(); }); @@ -167,6 +151,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -187,6 +172,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -207,6 +193,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -232,6 +219,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -256,6 +244,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -278,6 +267,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -300,6 +290,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -308,7 +299,7 @@ describe('PredictGameDetailsTabs', () => { ).not.toBeOnTheScreen(); }); - it('calls navigateToBuyPreview when buy button is pressed', () => { + it('calls onBetPress when buy button is pressed', () => { const market = createMockMarket(); const { getByTestId } = render( @@ -322,18 +313,16 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); fireEvent.press(getByTestId('mock-buy-button')); - expect(mockNavigateToBuyPreview).toHaveBeenCalledWith( - expect.objectContaining({ - market, - outcome: { id: 'outcome-1', title: 'Test' }, - outcomeToken: { id: 'token-1', title: 'Yes' }, - }), - ); + expect(mockOnBetPress).toHaveBeenCalledWith({ + id: 'token-1', + title: 'Yes', + }); }); }); @@ -352,6 +341,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -381,6 +371,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -403,6 +394,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); @@ -428,6 +420,7 @@ describe('PredictGameDetailsTabs', () => { claimablePositions={[]} groupMap={emptyGroupMap} activeChipKey="" + onBetPress={mockOnBetPress} />, ); diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx index 6ffec17d1e9..e6948736371 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameDetailsTabsContent.tsx @@ -1,6 +1,5 @@ import React, { memo, useCallback } from 'react'; import { Box, Text, TextVariant } from '@metamask/design-system-react-native'; -import { NavigationProp, useNavigation } from '@react-navigation/native'; import { strings } from '../../../../../../locales/i18n'; import type { PredictMarket, @@ -9,12 +8,8 @@ import type { PredictOutcomeToken, PredictPosition, } from '../../types'; -import type { PredictNavigationParamList } from '../../types/navigation'; import type { PredictMarketDetailsTabKey } from '../../Predict.testIds'; import PredictPicks from '../PredictPicks/PredictPicks'; -import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; -import { usePredictNavigation } from '../../hooks/usePredictNavigation'; -import { PredictEventValues } from '../../constants/eventNames'; import { PREDICT_GAME_DETAILS_CONTENT_TEST_IDS } from './PredictGameDetailsContent.testIds'; import PredictGameOutcomesTab from './PredictGameOutcomesTab'; @@ -28,6 +23,7 @@ interface PredictGameDetailsTabsContentProps { claimablePositions: PredictPosition[]; groupMap: Map; activeChipKey: string; + onBetPress: (token: PredictOutcomeToken) => void; } const PredictGameDetailsTabsContent = memo( @@ -41,29 +37,13 @@ const PredictGameDetailsTabsContent = memo( claimablePositions, groupMap, activeChipKey, + onBetPress, }: PredictGameDetailsTabsContentProps) => { - const navigation = - useNavigation>(); - const { executeGuardedAction } = usePredictActionGuard({ navigation }); - const { navigateToBuyPreview } = usePredictNavigation(); - const handleBuyPress = useCallback( - (outcome: PredictOutcome, token: PredictOutcomeToken) => { - executeGuardedAction( - () => { - navigateToBuyPreview({ - market, - outcome, - outcomeToken: token, - entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_MARKET_DETAILS, - }); - }, - { - attemptedAction: PredictEventValues.ATTEMPTED_ACTION.PREDICT, - }, - ); + (_outcome: PredictOutcome, token: PredictOutcomeToken) => { + onBetPress(token); }, - [market, executeGuardedAction, navigateToBuyPreview], + [onBetPress], ); const hasPositions = diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx index a76c824f3fa..b662428fecb 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.test.tsx @@ -20,6 +20,12 @@ jest.mock('../../../../../../locales/i18n', () => ({ 'predict.sports_market_types.spreads': 'Spreads', 'predict.sports_market_types.totals': 'Totals', 'predict.sports_market_types.points': 'Points', + 'predict.sports_market_types.tennis_set_totals': 'Total Sets', + 'predict.sports_market_types.tennis_match_totals': 'Total Games', + 'predict.sports_market_types.tennis_first_set_totals': + '1st Set Total Games', + 'predict.sports_market_types.tennis_first_set_winner': '1st Set Winner', + 'predict.sports_market_types.tennis_completed_match': 'Completed Match', }; return translations[key] ?? key; }), @@ -194,6 +200,15 @@ describe('PredictGameOutcomesTab', () => { expect(getSportsMarketTypeLabel('moneyline')).toBe('Moneyline'); }); + it('returns translated label for tennis market types', () => { + expect(getSportsMarketTypeLabel('tennis_match_totals')).toBe( + 'Total Games', + ); + expect(getSportsMarketTypeLabel('tennis_first_set_winner')).toBe( + '1st Set Winner', + ); + }); + it('returns title-cased fallback for unknown type', () => { expect(getSportsMarketTypeLabel('unknown_type')).toBe('Unknown Type'); }); @@ -626,6 +641,67 @@ describe('PredictGameOutcomesTab', () => { ); }); + it('assigns tennis first set winner team colors from normalized token labels', () => { + const tennisGame: PredictMarketGame = { + ...mockGame, + league: 'atp', + homeTeam: { + ...mockGame.homeTeam, + name: 'Ilya Ivashka', + abbreviation: 'ivashka', + alias: 'I. Ivashka', + color: TEST_HEX_COLORS.PURE_RED, + }, + awayTeam: { + ...mockGame.awayTeam, + name: 'Hamish Stewart', + abbreviation: 'stewart', + alias: 'H. Stewart', + color: TEST_HEX_COLORS.PURE_BLUE, + }, + }; + const outcome = createOutcome({ + sportsMarketType: 'tennis_first_set_winner', + tokens: [ + createToken({ shortTitle: 'IVASHKA' }), + createToken({ shortTitle: 'STEWART' }), + ], + }); + const subgroups: PredictOutcomeGroup[] = [ + createGroup({ + key: 'tennis_first_set_winner', + outcomes: [outcome], + }), + ]; + const groups = [ + createGroup({ key: 'first_set', outcomes: [], subgroups }), + ]; + + render( + , + ); + + expect(mockCapturedCards[0].buttons[0]).toEqual( + expect.objectContaining({ + label: 'IVASHKA', + variant: 'yes', + teamColor: TEST_HEX_COLORS.PURE_RED, + }), + ); + expect(mockCapturedCards[0].buttons[1]).toEqual( + expect.objectContaining({ + label: 'STEWART', + variant: 'no', + teamColor: TEST_HEX_COLORS.PURE_BLUE, + }), + ); + }); + it('assigns draw variant and no team colors for non-moneyline types', () => { const outcome = createOutcome({ sportsMarketType: 'spreads', diff --git a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx index efce4d19531..a9ee358c8b7 100644 --- a/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx +++ b/app/components/UI/Predict/components/PredictGameDetailsContent/PredictGameOutcomesTab.tsx @@ -51,8 +51,25 @@ const getTeamColor = ( game?: PredictMarketGame, ): string | undefined => { if (!game) return undefined; - if (tokenTitle === game.homeTeam.abbreviation) return game.homeTeam.color; - if (tokenTitle === game.awayTeam.abbreviation) return game.awayTeam.color; + + const normalizedTokenTitle = tokenTitle.trim().toLowerCase(); + const homeLabels = [ + game.homeTeam.abbreviation, + game.homeTeam.name, + game.homeTeam.alias, + ] + .filter((label): label is string => Boolean(label)) + .map((label) => label.trim().toLowerCase()); + const awayLabels = [ + game.awayTeam.abbreviation, + game.awayTeam.name, + game.awayTeam.alias, + ] + .filter((label): label is string => Boolean(label)) + .map((label) => label.trim().toLowerCase()); + + if (homeLabels.includes(normalizedTokenTitle)) return game.homeTeam.color; + if (awayLabels.includes(normalizedTokenTitle)) return game.awayTeam.color; return undefined; }; diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx index 9aac66a2777..19c9d69b8b8 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.test.tsx @@ -158,6 +158,43 @@ describe('PredictMarketSportCard', () => { expect(getByText('ENG 62¢')).toBeOnTheScreen(); }); + it('uses the main moneyline outcome when extended sports markets are present', () => { + const extendedMarket: PredictMarketType = { + ...mockMarket, + outcomes: [ + { + id: 'outcome-spread', + providerId: 'test-provider', + marketId: 'test-market-sport-1', + title: 'Spread', + description: 'Spread line', + image: '', + status: 'open', + sportsMarketType: 'spreads', + tokens: [ + { id: 'token-spread-home', title: 'Spain -1.5', price: 0.16 }, + { id: 'token-spread-away', title: 'England +1.5', price: 0.84 }, + ], + volume: 1000000, + groupItemTitle: 'Spread', + }, + { + ...mockMarket.outcomes[0], + sportsMarketType: 'moneyline', + }, + ], + }; + + const { getByText } = renderWithProvider( + , + { state: initialState }, + ); + + expect(getByText('SPA 60¢')).toBeOnTheScreen(); + expect(getByText('DRAW 15¢')).toBeOnTheScreen(); + expect(getByText('ENG 62¢')).toBeOnTheScreen(); + }); + it('renders compact carousel cards without scheduled score placeholders', () => { const { getByText, queryByText } = renderWithProvider( , diff --git a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx index 1b84f3bceec..f874510b670 100644 --- a/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx +++ b/app/components/UI/Predict/components/PredictMarketSportCard/PredictMarketSportCard.tsx @@ -22,7 +22,10 @@ import I18n from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import { getIntlDateTimeFormatter } from '../../../../../util/intl'; import { useTheme } from '../../../../../util/theme'; -import { isDrawCapableLeague } from '../../constants/sports'; +import { + getPrimaryMoneylineOutcomes, + isDrawCapableLeague, +} from '../../constants/sports'; import { PredictEventValues } from '../../constants/eventNames'; import { getLeagueConfig } from '../../constants/sportLeagueConfigs'; import { usePredictActionGuard } from '../../hooks/usePredictActionGuard'; @@ -117,9 +120,10 @@ const buildButtonItems = ( game: PredictMarketGame, showDraw: boolean, ): SportOutcomeButtonItem[] => { + const moneylineOutcomes = getPrimaryMoneylineOutcomes(market.outcomes); const sortedDrawOutcomes = - showDraw && market.outcomes.length >= 3 - ? [...market.outcomes].sort( + showDraw && moneylineOutcomes.length >= 3 + ? [...moneylineOutcomes].sort( (a, b) => (a.groupItemThreshold ?? 0) - (b.groupItemThreshold ?? 0), ) : null; @@ -165,7 +169,7 @@ const buildButtonItems = ( ]); } - const outcome = market.outcomes[0]; + const outcome = moneylineOutcomes[0]; if (!outcome) return []; const homeToken = diff --git a/app/components/UI/Predict/constants/sports.test.ts b/app/components/UI/Predict/constants/sports.test.ts index 8bdca5498e7..1bb6218a1bc 100644 --- a/app/components/UI/Predict/constants/sports.test.ts +++ b/app/components/UI/Predict/constants/sports.test.ts @@ -1,8 +1,13 @@ -import { MONEYLINE_MARKET_TYPES, isMoneylineLikeMarketType } from './sports'; +import { + MONEYLINE_MARKET_TYPES, + filterSupportedLeagues, + getPrimaryMoneylineOutcomes, + isMoneylineLikeMarketType, +} from './sports'; describe('MONEYLINE_MARKET_TYPES', () => { - it('contains exactly 3 entries', () => { - expect(MONEYLINE_MARKET_TYPES.size).toBe(3); + it('contains exactly 4 entries', () => { + expect(MONEYLINE_MARKET_TYPES.size).toBe(4); }); it('contains moneyline', () => { @@ -16,6 +21,10 @@ describe('MONEYLINE_MARKET_TYPES', () => { it('contains soccer_halftime_result', () => { expect(MONEYLINE_MARKET_TYPES.has('soccer_halftime_result')).toBe(true); }); + + it('contains tennis_first_set_winner', () => { + expect(MONEYLINE_MARKET_TYPES.has('tennis_first_set_winner')).toBe(true); + }); }); describe('isMoneylineLikeMarketType', () => { @@ -37,10 +46,17 @@ describe('isMoneylineLikeMarketType', () => { expect(result).toBe(true); }); + it('returns true for tennis_first_set_winner', () => { + const result = isMoneylineLikeMarketType('tennis_first_set_winner'); + + expect(result).toBe(true); + }); + it('returns true for mixed-case moneyline values', () => { expect(isMoneylineLikeMarketType('Moneyline')).toBe(true); expect(isMoneylineLikeMarketType('FIRST_HALF_MONEYLINE')).toBe(true); expect(isMoneylineLikeMarketType('Soccer_Halftime_Result')).toBe(true); + expect(isMoneylineLikeMarketType('Tennis_First_Set_Winner')).toBe(true); }); it('returns false for spreads', () => { @@ -55,3 +71,73 @@ describe('isMoneylineLikeMarketType', () => { expect(result).toBe(false); }); }); + +describe('filterSupportedLeagues', () => { + it('keeps the extended sports leagues supported by Predict', () => { + const result = filterSupportedLeagues([ + 'nba', + 'wnba', + 'mlb', + 'nhl', + 'fifwc', + 'ucl', + 'epl', + 'lal', + 'sea', + 'bun', + 'mls', + 'fif', + 'atp', + 'wta', + 'itf', + 'fake_league', + ]); + + expect(result).toEqual([ + 'nba', + 'wnba', + 'mlb', + 'nhl', + 'fifwc', + 'ucl', + 'epl', + 'lal', + 'sea', + 'bun', + 'mls', + 'fif', + 'atp', + 'wta', + 'itf', + ]); + }); +}); + +describe('getPrimaryMoneylineOutcomes', () => { + it('keeps only main moneyline outcomes when extended sports markets are present', () => { + const moneylineOutcome = { id: 'moneyline', sportsMarketType: 'moneyline' }; + const spreadOutcome = { id: 'spread', sportsMarketType: 'spreads' }; + const halftimeOutcome = { + id: 'halftime', + sportsMarketType: 'soccer_halftime_result', + }; + + const result = getPrimaryMoneylineOutcomes([ + spreadOutcome, + moneylineOutcome, + halftimeOutcome, + ]); + + expect(result).toEqual([moneylineOutcome]); + }); + + it('falls back to all outcomes when no main moneyline type is present', () => { + const outcomes = [ + { id: 'legacy-away', sportsMarketType: undefined }, + { id: 'legacy-draw', sportsMarketType: undefined }, + { id: 'legacy-home', sportsMarketType: undefined }, + ]; + + expect(getPrimaryMoneylineOutcomes(outcomes)).toBe(outcomes); + }); +}); diff --git a/app/components/UI/Predict/constants/sports.ts b/app/components/UI/Predict/constants/sports.ts index 3317116821f..24bc0ada920 100644 --- a/app/components/UI/Predict/constants/sports.ts +++ b/app/components/UI/Predict/constants/sports.ts @@ -12,6 +12,9 @@ import { PredictSportsLeague } from '../types'; export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = [ 'nfl', 'nba', + 'wnba', + 'mlb', + 'nhl', 'ucl', 'fif', 'lal', @@ -51,6 +54,9 @@ export const SUPPORTED_SPORTS_LEAGUES: PredictSportsLeague[] = [ 'dfb', 'cde', 'fifwc', + 'atp', + 'wta', + 'itf', ]; export const filterSupportedLeagues = ( @@ -109,7 +115,20 @@ export const MONEYLINE_MARKET_TYPES: ReadonlySet = new Set([ 'moneyline', 'first_half_moneyline', 'soccer_halftime_result', + 'tennis_first_set_winner', ]); export const isMoneylineLikeMarketType = (type?: string): boolean => type !== undefined && MONEYLINE_MARKET_TYPES.has(type.toLowerCase()); + +export const getPrimaryMoneylineOutcomes = < + T extends { sportsMarketType?: string }, +>( + outcomes: T[], +): T[] => { + const moneylineOutcomes = outcomes.filter( + (outcome) => outcome.sportsMarketType?.toLowerCase() === 'moneyline', + ); + + return moneylineOutcomes.length > 0 ? moneylineOutcomes : outcomes; +}; diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index 56d34f5e0f6..5d0114c3d7a 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -113,11 +113,14 @@ export const SPORTS_MARKET_TYPE_TO_GROUP: Record = { soccer_exact_score: 'exact_score', soccer_halftime_result: 'halftime', total_corners: 'corners', + tennis_first_set_winner: 'first_set', + tennis_first_set_totals: 'first_set', }; export const GROUP_ORDER: string[] = [ 'game_lines', 'first_half', + 'first_set', 'team_totals', 'touchdowns', 'rushing', @@ -135,6 +138,11 @@ export const DEFAULT_GROUP_KEY = 'game_lines'; export const SPORTS_MARKET_TYPE_PRIORITIES: Record = { moneyline: 0, + tennis_first_set_winner: 0, spreads: 1, totals: 2, + tennis_set_totals: 2, + tennis_first_set_totals: 2, + tennis_match_totals: 3, + tennis_completed_match: 4, }; diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 394698f6981..57ed5f68594 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -3,14 +3,16 @@ import EthQuery from '@metamask/eth-query'; import { SignTypedDataVersion } from '@metamask/keyring-controller'; import Engine from '../../../../../core/Engine'; import Logger from '../../../../../util/Logger'; -import { Side, type OrderPreview } from '../../types'; +import { Side, type OrderPreview, type PredictOutcome } from '../../types'; import { PREDICT_ERROR_CODES } from '../../constants/errors'; import { DEFAULT_CLOB_BASE_URL, MATIC_CONTRACTS_V2, POLYGON_MAINNET_CHAIN_ID, + POLYMARKET_PROVIDER_ID, } from './constants'; import { + buildOutcomeGroups, calculateConservativeBuyMarketFee, clearClobMarketInfoCache, clearClobMarketInfoSessionState, @@ -119,6 +121,49 @@ describe('polymarket utils', () => { >); }); + it('groups tennis first set markets separately from game lines', () => { + const createOutcome = ( + id: string, + sportsMarketType: string, + ): PredictOutcome => ({ + id, + providerId: POLYMARKET_PROVIDER_ID, + marketId: 'market-1', + title: id, + description: id, + image: 'icon.png', + status: 'open', + tokens: [{ id: `${id}-token`, title: 'Yes', price: 0.5 }], + volume: 100, + groupItemTitle: id, + sportsMarketType, + }); + + const groups = buildOutcomeGroups([ + createOutcome('moneyline', 'moneyline'), + createOutcome('set-total', 'tennis_set_totals'), + createOutcome('match-total', 'tennis_match_totals'), + createOutcome('completed', 'tennis_completed_match'), + createOutcome('first-set-winner', 'tennis_first_set_winner'), + createOutcome('first-set-total', 'tennis_first_set_totals'), + ]); + + expect(groups.map((group) => group.key)).toEqual([ + 'game_lines', + 'first_set', + ]); + expect(groups[0].subgroups?.map((group) => group.key)).toEqual([ + 'moneyline', + 'tennis_set_totals', + 'tennis_match_totals', + 'tennis_completed_match', + ]); + expect(groups[1].subgroups?.map((group) => group.key)).toEqual([ + 'tennis_first_set_winner', + 'tennis_first_set_totals', + ]); + }); + it('parses World Cup game events with game metadata when team data is available', () => { const teamsByAbbreviation: Record = { usa: { @@ -216,6 +261,287 @@ describe('polymarket utils', () => { ); }); + it('parses ATP game events from provider metadata when league tag is missing', () => { + const teamsByAbbreviation: Record = { + ivashka: { + id: 'team-ivashka', + name: 'Ilya Ivashka', + logo: 'ivashka.png', + abbreviation: 'ivashka', + color: 'red', + alias: 'I. Ivashka', + league: 'atp', + }, + stewart: { + id: 'team-stewart', + name: 'Hamish Stewart', + logo: 'stewart.png', + abbreviation: 'stewart', + color: 'orange', + alias: 'H. Stewart', + league: 'atp', + }, + }; + const event: PolymarketApiEvent = { + id: '509179', + slug: 'atp-ivashka-stewart-2026-05-22', + title: 'Bengaluru 3: Ilya Ivashka vs Hamish Stewart', + description: 'ATP match', + icon: 'icon.png', + closed: false, + active: true, + series: [ + { + id: '10365', + slug: 'atp', + title: 'ATP', + recurrence: 'daily', + }, + ], + markets: [ + { + conditionId: 'condition-1', + question: 'Bengaluru 3: Ilya Ivashka vs Hamish Stewart', + description: 'Market description', + icon: 'icon.png', + image: 'image.png', + groupItemTitle: '', + groupItemThreshold: 0, + sportsMarketType: 'moneyline', + status: 'open', + volumeNum: 100, + liquidity: 100, + negRisk: false, + clobTokenIds: '["token-ivashka","token-stewart"]', + outcomes: '["Ilya Ivashka","Hamish Stewart"]', + outcomePrices: '["0.625","0.375"]', + closed: false, + active: true, + acceptingOrders: true, + resolvedBy: '', + orderPriceMinTickSize: 0.01, + umaResolutionStatus: '', + }, + ], + tags: [ + { id: 'tennis', label: 'Tennis', slug: 'tennis' }, + { id: 'games', label: 'Games', slug: 'games' }, + ], + teams: [teamsByAbbreviation.ivashka, teamsByAbbreviation.stewart], + liquidity: 100, + volume: 100, + gameId: '5658375', + startTime: '2026-05-22T07:30:00Z', + live: false, + ended: false, + }; + + const [market] = parsePolymarketEvents([event], { + category: 'hot', + teamLookup: (_league, abbreviation) => teamsByAbbreviation[abbreviation], + extendedSportsMarketsLeagues: ['atp'], + }); + + expect(market.game).toEqual( + expect.objectContaining({ + id: '5658375', + league: 'atp', + startTime: '2026-05-22T07:30:00Z', + status: 'scheduled', + homeTeam: expect.objectContaining({ abbreviation: 'ivashka' }), + awayTeam: expect.objectContaining({ abbreviation: 'stewart' }), + }), + ); + }); + + it('parses WTA game events from provider metadata when league tag is missing', () => { + const teamsByAbbreviation: Record = { + sasnovi: { + id: 'team-sasnovi', + name: 'Aliaksandra Sasnovich', + logo: 'sasnovi.png', + abbreviation: 'sasnovi', + color: 'red', + alias: 'A. Sasnovich', + league: 'wta', + }, + ribera: { + id: 'team-ribera', + name: 'Marina Bassols Ribera', + logo: 'ribera.png', + abbreviation: 'ribera', + color: 'orange', + alias: 'M. Ribera', + league: 'wta', + }, + }; + const event: PolymarketApiEvent = { + id: '506439', + slug: 'wta-sasnovi-ribera-2026-05-22', + title: + 'Roland Garros, Qualification WTA: Aliaksandra Sasnovich vs Marina Bassols Ribera', + description: 'WTA match', + icon: 'icon.png', + closed: false, + active: true, + series: [ + { + id: '10366', + slug: 'wta', + title: 'WTA', + recurrence: 'daily', + }, + ], + markets: [ + { + conditionId: 'condition-1', + question: + 'Roland Garros, Qualification WTA: Aliaksandra Sasnovich vs Marina Bassols Ribera', + description: 'Market description', + icon: 'icon.png', + image: 'image.png', + groupItemTitle: '', + groupItemThreshold: 0, + sportsMarketType: 'moneyline', + status: 'open', + volumeNum: 100, + liquidity: 100, + negRisk: false, + clobTokenIds: '["token-sasnovi","token-ribera"]', + outcomes: '["Aliaksandra Sasnovich","Marina Bassols Ribera"]', + outcomePrices: '["0.735","0.265"]', + closed: false, + active: true, + acceptingOrders: true, + resolvedBy: '', + orderPriceMinTickSize: 0.01, + umaResolutionStatus: '', + }, + ], + tags: [ + { id: 'tennis', label: 'Tennis', slug: 'tennis' }, + { id: 'games', label: 'Games', slug: 'games' }, + ], + teams: [teamsByAbbreviation.sasnovi, teamsByAbbreviation.ribera], + liquidity: 100, + volume: 100, + gameId: '5655456', + startTime: '2026-05-22T09:00:00Z', + live: false, + ended: false, + }; + + const [market] = parsePolymarketEvents([event], { + category: 'hot', + teamLookup: (_league, abbreviation) => teamsByAbbreviation[abbreviation], + extendedSportsMarketsLeagues: ['wta'], + }); + + expect(market.game).toEqual( + expect.objectContaining({ + id: '5655456', + league: 'wta', + startTime: '2026-05-22T09:00:00Z', + status: 'scheduled', + homeTeam: expect.objectContaining({ abbreviation: 'sasnovi' }), + awayTeam: expect.objectContaining({ abbreviation: 'ribera' }), + }), + ); + }); + + it('parses ITF game events from provider metadata when league tag is missing', () => { + const teamsByAbbreviation: Record = { + back: { + id: 'team-back', + name: 'Dayeon Back', + logo: 'back.png', + abbreviation: 'back', + color: 'red', + alias: 'D. Back', + league: 'itf', + }, + eunjile: { + id: 'team-eunjile', + name: 'Eun Ji Lee', + logo: 'eunjile.png', + abbreviation: 'eunjile', + color: 'orange', + alias: 'E. Lee', + league: 'itf', + }, + }; + const event: PolymarketApiEvent = { + id: '506396', + slug: 'itf-back-eunjile-2026-05-21', + title: 'ITF Changwon: Dayeon Back vs Eun Ji Lee', + description: 'ITF match', + icon: 'icon.png', + closed: false, + active: true, + series: [ + { + id: '11634', + slug: 'itf', + title: 'ITF', + recurrence: 'daily', + }, + ], + markets: [ + { + conditionId: 'condition-1', + question: 'ITF Changwon: Dayeon Back vs Eun Ji Lee', + description: 'Market description', + icon: 'icon.png', + image: 'image.png', + groupItemTitle: '', + groupItemThreshold: 0, + sportsMarketType: 'moneyline', + status: 'open', + volumeNum: 100, + liquidity: 100, + negRisk: false, + clobTokenIds: '["token-back","token-eunjile"]', + outcomes: '["Dayeon Back","Eun Ji Lee"]', + outcomePrices: '["0.86","0.14"]', + closed: false, + active: true, + acceptingOrders: true, + resolvedBy: '', + orderPriceMinTickSize: 0.01, + umaResolutionStatus: '', + }, + ], + tags: [ + { id: 'tennis', label: 'Tennis', slug: 'tennis' }, + { id: 'games', label: 'Games', slug: 'games' }, + ], + teams: [teamsByAbbreviation.back, teamsByAbbreviation.eunjile], + liquidity: 100, + volume: 100, + gameId: '1631097223', + startTime: '2026-05-21T01:00:00Z', + live: false, + ended: false, + }; + + const [market] = parsePolymarketEvents([event], { + category: 'hot', + teamLookup: (_league, abbreviation) => teamsByAbbreviation[abbreviation], + extendedSportsMarketsLeagues: ['itf'], + }); + + expect(market.game).toEqual( + expect.objectContaining({ + id: '1631097223', + league: 'itf', + startTime: '2026-05-21T01:00:00Z', + status: 'scheduled', + homeTeam: expect.objectContaining({ abbreviation: 'back' }), + awayTeam: expect.objectContaining({ abbreviation: 'eunjile' }), + }), + ); + }); + describe('fetchEventsFromPolymarketApi', () => { beforeEach(() => { mockFetch.mockResolvedValue({ diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 54c74be3dcc..8e7e023f0a5 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -152,6 +152,9 @@ export type PredictCategory = export type PredictSportsLeague = | 'nfl' | 'nba' + | 'wnba' + | 'mlb' + | 'nhl' | 'ucl' | 'fif' | 'lal' @@ -190,7 +193,10 @@ export type PredictSportsLeague = | 'itc' | 'dfb' | 'cde' - | 'fifwc'; + | 'fifwc' + | 'atp' + | 'wta' + | 'itf'; // Game status export type PredictGameStatus = 'scheduled' | 'ongoing' | 'ended'; diff --git a/app/components/UI/Predict/utils/gameParser.test.ts b/app/components/UI/Predict/utils/gameParser.test.ts index 96aa7ea4ac9..0f1cf2c514d 100644 --- a/app/components/UI/Predict/utils/gameParser.test.ts +++ b/app/components/UI/Predict/utils/gameParser.test.ts @@ -58,6 +58,64 @@ describe('gameParser', () => { expect(result).toBe('nfl'); }); + it.each([ + ['wnba', 'wnba-tor-min-2026-05-21'], + ['mlb', 'mlb-cle-det-2026-05-21'], + ['nhl', 'nhl-mon-car-2026-05-21'], + ['atp', 'atp-darderi-minaur-2026-05-21'], + ['wta', 'wta-tan-fruhvir-2026-05-22'], + ['itf', 'itf-par-saigo-2026-05-21'], + ] as const)( + 'returns "%s" for supported league slug and tag', + (league, slug) => { + const event = createMockEvent({ + slug, + tags: [ + { id: '1', label: league.toUpperCase(), slug: league }, + { id: '2', label: 'Games', slug: 'games' }, + ], + }); + + const result = getEventLeague(event); + + expect(result).toBe(league); + }, + ); + + it('returns tennis league from provider metadata when league tag is missing', () => { + const event = createMockEvent({ + slug: 'wta-sasnovi-ribera-2026-05-22', + tags: [ + { id: '1', label: 'Tennis', slug: 'tennis' }, + { id: '2', label: 'Games', slug: 'games' }, + ], + series: [ + { + id: 'series-1', + slug: 'wta', + title: 'WTA', + recurrence: 'daily', + }, + ], + teams: [ + createMockApiTeam({ + id: 'team-1', + abbreviation: 'sasnovi', + league: 'wta', + }), + createMockApiTeam({ + id: 'team-2', + abbreviation: 'ribera', + league: 'wta', + }), + ], + }); + + const result = getEventLeague(event); + + expect(result).toBe('wta'); + }); + it('returns null when missing nfl tag', () => { const event = createMockEvent({ tags: [{ id: '2', label: 'Games', slug: 'games' }], @@ -208,6 +266,26 @@ describe('gameParser', () => { }); }); + it.each([ + ['wnba', 'wnba-tor-min-2026-05-21', 'tor', 'min'], + ['mlb', 'mlb-cle-det-2026-05-21', 'cle', 'det'], + ['nhl', 'nhl-mon-car-2026-05-21', 'mon', 'car'], + ['atp', 'atp-darderi-minaur-2026-05-21', 'minaur', 'darderi'], + ['wta', 'wta-tan-fruhvir-2026-05-22', 'fruhvir', 'tan'], + ['itf', 'itf-par-saigo-2026-05-21', 'saigo', 'par'], + ] as const)( + 'extracts participants from valid %s slug', + (league, slug, awayAbbreviation, homeAbbreviation) => { + const result = parseGameSlugTeams(slug, league); + + expect(result).toEqual({ + awayAbbreviation, + homeAbbreviation, + dateString: slug.slice(-10), + }); + }, + ); + it('returns null for non-NFL slug', () => { const result = parseGameSlugTeams('some-other-event', 'nfl'); @@ -656,6 +734,68 @@ describe('gameParser', () => { expect(result.get('nba')).toEqual(['lal', 'bos']); }); + it('extracts teams from newly supported league events', () => { + const events = [ + ['wnba', 'wnba-tor-min-2026-05-21'], + ['mlb', 'mlb-cle-det-2026-05-21'], + ['nhl', 'nhl-mon-car-2026-05-21'], + ['atp', 'atp-darderi-minaur-2026-05-21'], + ['wta', 'wta-tan-fruhvir-2026-05-22'], + ['itf', 'itf-par-saigo-2026-05-21'], + ].map(([league, slug], index) => + createMockEvent({ + id: `event-${index}`, + slug, + tags: [ + { + id: `${index}-league`, + label: league.toUpperCase(), + slug: league, + }, + { id: `${index}-games`, label: 'Games', slug: 'games' }, + ], + }), + ); + + const result = extractNeededTeamsFromEvents(events, [ + 'wnba', + 'mlb', + 'nhl', + 'atp', + 'wta', + 'itf', + ]); + + expect(result.get('wnba')).toEqual(['tor', 'min']); + expect(result.get('mlb')).toEqual(['cle', 'det']); + expect(result.get('nhl')).toEqual(['mon', 'car']); + expect(result.get('atp')).toEqual(['minaur', 'darderi']); + expect(result.get('wta')).toEqual(['fruhvir', 'tan']); + expect(result.get('itf')).toEqual(['saigo', 'par']); + }); + + it('extracts tennis teams when provider metadata supplies the league without a league tag', () => { + const event = createMockEvent({ + slug: 'wta-sasnovi-ribera-2026-05-22', + tags: [ + { id: '1', label: 'Tennis', slug: 'tennis' }, + { id: '2', label: 'Games', slug: 'games' }, + ], + series: [ + { + id: 'series-1', + slug: 'wta', + title: 'WTA', + recurrence: 'daily', + }, + ], + }); + + const result = extractNeededTeamsFromEvents([event], ['wta']); + + expect(result.get('wta')).toEqual(['ribera', 'sasnovi']); + }); + it('deduplicates team abbreviations across events', () => { const event1 = createMockEvent({ id: 'event-1', diff --git a/app/components/UI/Predict/utils/gameParser.ts b/app/components/UI/Predict/utils/gameParser.ts index 9a3c50d53a2..2e928e0ba9e 100644 --- a/app/components/UI/Predict/utils/gameParser.ts +++ b/app/components/UI/Predict/utils/gameParser.ts @@ -27,6 +27,18 @@ const LEAGUE_SLUG_CONFIGS: Record = { pattern: /^nba-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/, teamOrder: 'away-home', }, + wnba: { + pattern: /^wnba-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'away-home', + }, + mlb: { + pattern: /^mlb-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'away-home', + }, + nhl: { + pattern: /^nhl-([a-z]+)-([a-z]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'away-home', + }, ucl: { pattern: /^ucl-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, teamOrder: 'home-away', @@ -212,6 +224,18 @@ const LEAGUE_SLUG_CONFIGS: Record = { teamOrder: 'home-away', tagSlug: 'fifa-world-cup', }, + atp: { + pattern: /^atp-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + wta: { + pattern: /^wta-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, + itf: { + pattern: /^itf-([a-z0-9]+)-([a-z0-9]+)-(\d{4}-\d{2}-\d{2})$/, + teamOrder: 'home-away', + }, }; export type TeamLookup = ( @@ -233,6 +257,13 @@ const hasTeamsMatchingLeague = ( return teams.length > 0 && teams.every((team) => team.league === league); }; +const hasSeriesMatchingLeague = ( + event: PolymarketApiEvent, + league: PredictSportsLeague, +): boolean => + Array.isArray(event.series) && + event.series.some((series) => series.slug === league); + export function getEventLeague( event: PolymarketApiEvent, extendedSportsMarketsLeagues: string[] = [], @@ -248,13 +279,17 @@ export function getEventLeague( const { pattern, tagSlug } = LEAGUE_SLUG_CONFIGS[league]; const leagueTagSlug = tagSlug ?? league; const hasLeagueTag = tags.some((tag) => tag.slug === leagueTagSlug); + const hasProviderLeagueMetadata = + hasLeagueTag || + hasSeriesMatchingLeague(event, league) || + hasTeamsMatchingLeague(event, league); const hasValidSlug = pattern.test(event.slug); - if (hasLeagueTag && hasValidSlug) { + if (hasProviderLeagueMetadata && hasValidSlug) { return league; } const canInferFromTeams = - hasLeagueTag && + event.slug.startsWith(`${league}-`) && extendedSportsMarketsLeagues.includes(league) && hasTeamsMatchingLeague(event, league); diff --git a/app/components/Views/AccountStatus/index.test.tsx b/app/components/Views/AccountStatus/index.test.tsx index 2c70cb9cb51..7b90015f8a3 100644 --- a/app/components/Views/AccountStatus/index.test.tsx +++ b/app/components/Views/AccountStatus/index.test.tsx @@ -11,6 +11,7 @@ import renderWithProvider from '../../../util/test/renderWithProvider'; import Routes from '../../../constants/navigation/Routes'; import { PREVIOUS_SCREEN } from '../../../constants/navigation'; import { AccountStatusSelectorIDs } from './AccountStatus.testIds'; +import { AccountType } from '../../../constants/onboarding'; // Mock navigation const mockNavigate = jest.fn(); @@ -60,11 +61,25 @@ jest.mock('../../../util/metrics/TrackOnboarding/trackOnboarding', () => jest.mock('../../../core/Analytics/MetricsEventBuilder', () => ({ MetricsEventBuilder: { createEventBuilder: jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), build: jest.fn(), })), }, })); +const getMockEventBuilder = () => { + const mockBuild = jest.fn(); + const mockAddProperties = jest.fn().mockReturnThis(); + const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: mockAddProperties, + build: mockBuild, + })); + (MetricsEventBuilder.createEventBuilder as jest.Mock).mockImplementation( + mockCreateEventBuilder, + ); + return { mockBuild, mockAddProperties, mockCreateEventBuilder }; +}; + describe('AccountStatus', () => { beforeEach(() => { jest.clearAllMocks(); @@ -154,14 +169,41 @@ describe('AccountStatus', () => { }); describe('Analytics tracking', () => { - it('tracks WALLET_IMPORT_STARTED event when type="found"', () => { - const mockBuild = jest.fn(); - const mockCreateEventBuilder = jest.fn(() => ({ build: mockBuild })); - ( - MetricsEventBuilder.createEventBuilder as jest.Mock - ).mockImplementation(mockCreateEventBuilder); + it('tracks ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED with account_type on mount when type="found"', () => { + const { mockAddProperties, mockCreateEventBuilder } = + getMockEventBuilder(); - mockRouteParams = { type: 'found' }; + mockRouteParams = { type: 'found', provider: 'google' }; + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + account_type: AccountType.ImportedGoogle, + }); + }); + + it('tracks ACCOUNT_NOT_FOUND_PAGE_VIEWED with account_type on mount when type="not_exist"', () => { + const { mockAddProperties, mockCreateEventBuilder } = + getMockEventBuilder(); + + mockRouteParams = { type: 'not_exist', provider: 'google' }; + renderWithProvider(); + + expect(mockCreateEventBuilder).toHaveBeenCalledWith( + MetaMetricsEvents.ACCOUNT_NOT_FOUND_PAGE_VIEWED, + ); + expect(mockAddProperties).toHaveBeenCalledWith({ + account_type: AccountType.MetamaskGoogle, + }); + }); + + it('tracks WALLET_IMPORT_STARTED event with account_type when type="found"', () => { + const { mockBuild, mockAddProperties, mockCreateEventBuilder } = + getMockEventBuilder(); + + mockRouteParams = { type: 'found', provider: 'google' }; const { getByTestId } = renderWithProvider(); const primaryButton = getByTestId( AccountStatusSelectorIDs.ACCOUNT_FOUND_LOGIN_BUTTON, @@ -172,18 +214,18 @@ describe('AccountStatus', () => { expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.WALLET_IMPORT_STARTED, ); + expect(mockAddProperties).toHaveBeenCalledWith({ + account_type: AccountType.ImportedGoogle, + }); expect(mockBuild).toHaveBeenCalled(); expect(trackOnboarding).toHaveBeenCalled(); }); - it('tracks WALLET_SETUP_STARTED event when type="not_exist"', () => { - const mockBuild = jest.fn(); - const mockCreateEventBuilder = jest.fn(() => ({ build: mockBuild })); - ( - MetricsEventBuilder.createEventBuilder as jest.Mock - ).mockImplementation(mockCreateEventBuilder); + it('tracks WALLET_SETUP_STARTED event with account_type when type="not_exist"', () => { + const { mockBuild, mockAddProperties, mockCreateEventBuilder } = + getMockEventBuilder(); - mockRouteParams = { type: 'not_exist' }; + mockRouteParams = { type: 'not_exist', provider: 'google' }; const { getByText } = renderWithProvider(); const primaryButton = getByText('Create a new wallet'); @@ -192,6 +234,9 @@ describe('AccountStatus', () => { expect(mockCreateEventBuilder).toHaveBeenCalledWith( MetaMetricsEvents.WALLET_SETUP_STARTED, ); + expect(mockAddProperties).toHaveBeenCalledWith({ + account_type: AccountType.MetamaskGoogle, + }); expect(mockBuild).toHaveBeenCalled(); expect(trackOnboarding).toHaveBeenCalled(); }); diff --git a/app/components/Views/AccountStatus/index.tsx b/app/components/Views/AccountStatus/index.tsx index 90effc3623f..ba8bd5c1036 100644 --- a/app/components/Views/AccountStatus/index.tsx +++ b/app/components/Views/AccountStatus/index.tsx @@ -27,7 +27,9 @@ import { store } from '../../../store'; import { IMetaMetricsEvent, ITrackingEvent, + JsonMap, } from '../../../core/Analytics/MetaMetrics.types'; +import { getSocialAccountType } from '../../../constants/onboarding'; import { OnboardingActionTypes, saveOnboardingEvent as saveEvent, @@ -102,10 +104,18 @@ const AccountStatus = ({ saveOnboardingEvent }: AccountStatusProps) => { }; }, [windowWidth]); + const accountType = useMemo( + () => + provider ? getSocialAccountType(provider, type === 'found') : undefined, + [provider, type], + ); + const track = useCallback( - (event: IMetaMetricsEvent) => { + (event: IMetaMetricsEvent, properties: JsonMap = {}) => { trackOnboarding( - MetricsEventBuilder.createEventBuilder(event).build(), + MetricsEventBuilder.createEventBuilder(event) + .addProperties(properties) + .build(), saveOnboardingEvent, ); }, @@ -129,12 +139,13 @@ const AccountStatus = ({ saveOnboardingEvent }: AccountStatusProps) => { type === 'found' ? MetaMetricsEvents.ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED : MetaMetricsEvents.ACCOUNT_NOT_FOUND_PAGE_VIEWED, + accountType ? { account_type: accountType } : {}, ); return () => { endTrace({ name: traceName }); }; - }, [onboardingTraceCtx, type, track]); + }, [accountType, onboardingTraceCtx, type, track]); const navigateNextScreen = ( targetRoute: string, @@ -167,6 +178,7 @@ const AccountStatus = ({ saveOnboardingEvent }: AccountStatusProps) => { metricFlow === ACCOUNT_STATUS_PRIMARY_FLOW.EXISTING_ACCOUNT_IMPORT ? MetaMetricsEvents.WALLET_IMPORT_STARTED : MetaMetricsEvents.WALLET_SETUP_STARTED, + accountType ? { account_type: accountType } : {}, ); }; diff --git a/app/components/Views/ChoosePassword/index.test.tsx b/app/components/Views/ChoosePassword/index.test.tsx index 930042210c3..af2fc849e76 100644 --- a/app/components/Views/ChoosePassword/index.test.tsx +++ b/app/components/Views/ChoosePassword/index.test.tsx @@ -54,6 +54,7 @@ jest.mock('@metamask/key-tree', () => ({ import ChoosePassword from './index.tsx'; import trackOnboarding from '../../../util/metrics/TrackOnboarding/trackOnboarding'; +import { AccountType } from '../../../constants/onboarding'; import { TraceName, TraceOperation, @@ -1122,6 +1123,7 @@ describe('ChoosePassword', () => { ...mockRoute.params, [PREVIOUS_SCREEN]: ONBOARDING, oauthLoginSuccess: true, + provider: 'google', }; const component = renderWithProviders(); @@ -1146,6 +1148,7 @@ describe('ChoosePassword', () => { params: expect.objectContaining({ metricsEnabled: true, error: walletError, + accountType: AccountType.MetamaskGoogle, }), }, ], diff --git a/app/components/Views/ChoosePassword/index.tsx b/app/components/Views/ChoosePassword/index.tsx index bb94764eca8..dfb7b7616b6 100644 --- a/app/components/Views/ChoosePassword/index.tsx +++ b/app/components/Views/ChoosePassword/index.tsx @@ -248,6 +248,10 @@ const ChoosePassword = () => { const canSubmit = getOauth2LoginSuccess() ? passwordsMatch : passwordsMatch && isSelected; + const oauthProvider = route.params?.provider; + const socialAccountType = oauthProvider + ? getSocialAccountType(oauthProvider, false) + : undefined; if (loading) return { valid: false, shouldTrack: false }; @@ -261,6 +265,7 @@ const ChoosePassword = () => { track(MetaMetricsEvents.WALLET_SETUP_FAILURE, { wallet_setup_type: 'import', error_type: strings('choose_password.password_dont_match'), + ...(socialAccountType && { account_type: socialAccountType }), }); } return { valid: false, shouldTrack: false }; @@ -270,6 +275,7 @@ const ChoosePassword = () => { track(MetaMetricsEvents.WALLET_SETUP_FAILURE, { wallet_setup_type: 'import', error_type: strings('choose_password.password_length_error'), + ...(socialAccountType && { account_type: socialAccountType }), }); return { valid: false, shouldTrack: false }; } @@ -281,6 +287,7 @@ const ChoosePassword = () => { loading, isSelected, getOauth2LoginSuccess, + route.params?.provider, track, ]); @@ -397,9 +404,15 @@ const ChoosePassword = () => { dispatch(setLockTimeAction(-1)); setLoading(false); + const oauthProvider = route.params?.provider; + const socialAccountType = oauthProvider + ? getSocialAccountType(oauthProvider, false) + : undefined; + track(MetaMetricsEvents.WALLET_SETUP_FAILURE, { wallet_setup_type: 'new', error_type: caughtError.toString(), + ...(socialAccountType && { account_type: socialAccountType }), }); const onboardingTraceCtx = route.params?.onboardingTraceCtx; @@ -430,6 +443,7 @@ const ChoosePassword = () => { params: { metricsEnabled, error: caughtError, + ...(socialAccountType && { accountType: socialAccountType }), }, }, ], diff --git a/app/components/Views/Onboarding/index.test.tsx b/app/components/Views/Onboarding/index.test.tsx index c3486517cfd..6a757cff1c6 100644 --- a/app/components/Views/Onboarding/index.test.tsx +++ b/app/components/Views/Onboarding/index.test.tsx @@ -1829,6 +1829,70 @@ describe('Onboarding', () => { }), }), ); + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.SOCIAL_LOGIN_FAILED.category, + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + is_rehydration: 'false', + failure_type: 'error', + error_category: 'provider_login', + }), + }), + ); + }); + + it('tracks Social Login Failed when createLoginHandler rejects an invalid provider before OAuthService', async () => { + Platform.OS = 'ios'; + (Device.isIos as jest.Mock).mockReturnValue(true); + (mockAnalytics.isEnabled as jest.Mock).mockReturnValue(true); + mockCreateLoginHandler.mockImplementation(() => { + throw new OAuthError( + 'Invalid provider', + OAuthErrorType.InvalidProvider, + ); + }); + mockAnalytics.trackEvent.mockClear(); + + const { getByTestId } = renderScreen( + Onboarding, + { name: 'Onboarding' }, + { + state: mockInitialState, + }, + ); + + const createWalletButton = getByTestId( + OnboardingSelectorIDs.NEW_WALLET_BUTTON, + ); + await act(async () => { + fireEvent.press(createWalletButton); + }); + + const navCall = mockNavigate.mock.calls.find( + (call) => + call[0] === Routes.MODAL.ROOT_MODAL_FLOW && + call[1]?.screen === Routes.SHEET.ONBOARDING_SHEET, + ); + + await act(async () => { + await navCall[1].params.onPressContinueWithGoogle(true); + await flushPromises(); + await flushPromises(); + }); + + expect(mockOAuthService.handleOAuthLogin).not.toHaveBeenCalled(); + expect(mockAnalytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.SOCIAL_LOGIN_FAILED.category, + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + is_rehydration: 'false', + failure_type: 'error', + error_category: 'provider_login', + }), + }), + ); }); it('blocks Google login on iOS < 17.4 import flow with rehydration sheet when googleLoginIosUnsupportedBlockingEnabled is true', async () => { diff --git a/app/components/Views/Onboarding/index.tsx b/app/components/Views/Onboarding/index.tsx index 5816a186435..adcc97aa10b 100644 --- a/app/components/Views/Onboarding/index.tsx +++ b/app/components/Views/Onboarding/index.tsx @@ -87,6 +87,10 @@ import { SEEDLESS_ONBOARDING_ENABLED } from '../../../core/OAuthService/OAuthLog import OAuthLoginService from '../../../core/OAuthService/OAuthService'; import { OAuthError, OAuthErrorType } from '../../../core/OAuthService/error'; import { createLoginHandler } from '../../../core/OAuthService/OAuthLoginHandlers'; +import { + isPreOAuthSocialLoginFailure, + trackSocialLoginFailed, +} from '../../../core/OAuthService/socialLoginAnalytics'; import { AuthConnection } from '../../../core/OAuthService/OAuthInterface'; import { selectWalletSetupCompletedAttributionAnalyticsProps } from '../../../selectors/attribution'; import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; @@ -209,6 +213,16 @@ const Onboarding = () => { const onboardingTraceCtx = useRef(undefined); const socialLoginTraceCtx = useRef(undefined); + const endSocialLoginAttemptTrace = useCallback((success: boolean) => { + if (socialLoginTraceCtx.current) { + endTrace({ + name: TraceName.OnboardingSocialLoginAttempt, + data: { success }, + }); + socialLoginTraceCtx.current = undefined; + } + }, []); + const mounted = useRef(false); const hasCheckedVaultBackup = useRef(false); const warningCallback = useRef<() => boolean>(() => true); @@ -448,10 +462,7 @@ const Onboarding = () => { provider: string, ): void => { const isIOS = Platform.OS === 'ios'; - if (socialLoginTraceCtx.current) { - endTrace({ name: TraceName.OnboardingSocialLoginAttempt }); - socialLoginTraceCtx.current = undefined; - } + endSocialLoginAttemptTrace(true); // Error case (result.type !== 'success') is not handled here because // OAuthService.handleOAuthLogin() throws on failure, and the error is @@ -542,6 +553,7 @@ const Onboarding = () => { dispatch, onboardingVersion, walletSetupAttributionAnalyticsProps, + endSocialLoginAttemptTrace, ], ); @@ -579,6 +591,17 @@ const Onboarding = () => { socialConnectionType: string, createWallet: boolean, ): Promise => { + const isRehydration = !createWallet; + + const trackPreOAuthSocialLoginFailure = (failureError: unknown) => { + trackSocialLoginFailed({ + authConnection: socialConnectionType, + isRehydration, + errorCategory: 'provider_login', + error: failureError, + }); + }; + if (error instanceof OAuthError) { // For OAuth API failures (excluding user cancellation/dismissal), handle based on analytics consent if ( @@ -589,6 +612,7 @@ const Onboarding = () => { error.code === OAuthErrorType.TelegramLoginError ) { // QA: do not show error sheet if user cancelled + endSocialLoginAttemptTrace(false); return; } else if ( error.code === OAuthErrorType.GoogleLoginNoCredential || @@ -642,6 +666,7 @@ const Onboarding = () => { (fallbackError.code === OAuthErrorType.UserCancelled || fallbackError.code === OAuthErrorType.UserDismissed) ) { + endSocialLoginAttemptTrace(false); return; } // Handle both OAuthError and unexpected errors from browser fallback @@ -661,14 +686,15 @@ const Onboarding = () => { ); handleOAuthLoginError(wrappedError, socialConnectionType, true); } + endSocialLoginAttemptTrace(false); return; } } + endSocialLoginAttemptTrace(false); return; } // Show error sheet for auth server or seedless controller errors if ( - error.code === OAuthErrorType.InvalidProvider || error.code === OAuthErrorType.AuthServerError || error.code === OAuthErrorType.LoginError ) { @@ -683,10 +709,28 @@ const Onboarding = () => { type: 'error', }, }); + endSocialLoginAttemptTrace(false); + return; + } + if (isPreOAuthSocialLoginFailure(error)) { + trackPreOAuthSocialLoginFailure(error); + handleOAuthLoginError(error, socialConnectionType, false); + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SUCCESS_ERROR_SHEET, + params: { + title: strings('error_sheet.oauth_error_title'), + description: strings('error_sheet.oauth_error_description'), + descriptionAlign: 'center', + buttonLabel: strings('error_sheet.oauth_error_button'), + type: 'error', + }, + }); + endSocialLoginAttemptTrace(false); return; } // unexpected oauth login error handleOAuthLoginError(error, socialConnectionType, false); + endSocialLoginAttemptTrace(false); return; } @@ -700,13 +744,7 @@ const Onboarding = () => { }); endTrace({ name: TraceName.OnboardingSocialLoginError }); - if (socialLoginTraceCtx.current) { - endTrace({ - name: TraceName.OnboardingSocialLoginAttempt, - data: { success: false }, - }); - socialLoginTraceCtx.current = undefined; - } + endSocialLoginAttemptTrace(false); navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.SHEET.SUCCESS_ERROR_SHEET, @@ -725,6 +763,7 @@ const Onboarding = () => { setLoading, unsetLoading, handlePostSocialLogin, + endSocialLoginAttemptTrace, ], ); @@ -815,6 +854,15 @@ const Onboarding = () => { track(MetaMetricsEvents.WALLET_GOOGLE_IOS_ERROR_VIEWED, { account_type: accountType, }); + trackSocialLoginFailed({ + authConnection: provider, + isRehydration: !createWallet, + errorCategory: 'provider_login', + error: new OAuthError( + 'Google login not supported on this iOS version', + OAuthErrorType.UnsupportedPlatform, + ), + }); return; } @@ -839,43 +887,57 @@ const Onboarding = () => { return; } - socialLoginTraceCtx.current = trace({ - name: TraceName.OnboardingSocialLoginAttempt, - op: TraceOperation.OnboardingUserJourney, - tags: { ...getTraceTags(store.getState()), provider }, - parentContext: onboardingTraceCtx.current, - }); - setLoading(); - const loginHandler = createLoginHandler( - Platform.OS, - provider, - false, - provider === AuthConnection.Telegram - ? { telegramLoginEnabled: true } - : undefined, - ); try { - const result = await OAuthLoginService.handleOAuthLogin( - loginHandler, - !createWallet, - ); - handlePostSocialLogin( - result as OAuthLoginResult, - createWallet, + const loginHandler = createLoginHandler( + Platform.OS, provider, + false, + provider === AuthConnection.Telegram + ? { telegramLoginEnabled: true } + : undefined, ); - // Mark metrics opt-in UI as seen since OAuth users auto-consent to metrics. - // Set AFTER OAuth succeeds to avoid marking as seen if the flow fails. - await markMetricsOptInUISeen(); + socialLoginTraceCtx.current = trace({ + name: TraceName.OnboardingSocialLoginAttempt, + op: TraceOperation.OnboardingUserJourney, + tags: { ...getTraceTags(store.getState()), provider }, + parentContext: onboardingTraceCtx.current, + }); + + try { + const result = await OAuthLoginService.handleOAuthLogin( + loginHandler, + !createWallet, + ); + handlePostSocialLogin( + result as OAuthLoginResult, + createWallet, + provider, + ); - // delay unset loading to avoid flash of loading state - setTimeout(() => { + // Mark metrics opt-in UI as seen since OAuth users auto-consent to metrics. + // Set AFTER OAuth succeeds to avoid marking as seen if the flow fails. + await markMetricsOptInUISeen(); + + // delay unset loading to avoid flash of loading state + setTimeout(() => { + unsetLoading(); + }, 1000); + } catch (error) { unsetLoading(); - }, 1000); + await handleLoginError(error as Error, provider, createWallet); + } } catch (error) { unsetLoading(); + if (!(error instanceof OAuthError)) { + trackSocialLoginFailed({ + authConnection: provider, + isRehydration: !createWallet, + errorCategory: 'provider_login', + error, + }); + } await handleLoginError(error as Error, provider, createWallet); } }; diff --git a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx index 8d9b361b851..f3aab38fe77 100644 --- a/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx +++ b/app/components/Views/Settings/NotificationsSettings/NotificationsSettings.view.test.tsx @@ -108,7 +108,7 @@ describeForPlatforms('Notifications settings (toggles + visibility)', () => { it('renders notification sections when notifications are enabled', async () => { const { getByTestId, getByText, findAllByText, findByText } = - renderSettings(); + renderSettings({ socialLeaderboardEnabled: true }); expect( getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), @@ -122,6 +122,17 @@ describeForPlatforms('Notifications settings (toggles + visibility)', () => { expect(getByText('Off')).toBeOnTheScreen(); }); + it('hides social AI section when social leaderboard feature flag is disabled', async () => { + const { getByText, queryByText, findAllByText, findByText } = + renderSettings({ socialLeaderboardEnabled: false }); + + expect(await findByText(SECTION_TITLES.walletActivity)).toBeOnTheScreen(); + expect(getByText(SECTION_TITLES.perps)).toBeOnTheScreen(); + expect(queryByText(SECTION_TITLES.socialAI)).toBeNull(); + expect(getByText(SECTION_TITLES.marketing)).toBeOnTheScreen(); + expect(await findAllByText('Push, In app')).toHaveLength(2); + }); + it('hides notification sections when main toggle is off', async () => { const { getByTestId, queryByText } = renderSettings({ notificationsEnabled: false, diff --git a/app/components/Views/Settings/NotificationsSettings/index.test.tsx b/app/components/Views/Settings/NotificationsSettings/index.test.tsx index b22c37426c6..d618002ff2a 100644 --- a/app/components/Views/Settings/NotificationsSettings/index.test.tsx +++ b/app/components/Views/Settings/NotificationsSettings/index.test.tsx @@ -6,12 +6,20 @@ import { Props } from './NotificationsSettings.types'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils'; import { AvatarAccountType } from '../../../../component-library/components/Avatars/Avatar'; import { NotificationSettingsViewSelectorsIDs } from './NotificationSettingsView.testIds'; +import { strings } from '../../../../../locales/i18n'; + +jest.mock('react-native-device-info', () => ({ + getVersion: jest.fn().mockReturnValue('7.72.0'), +})); jest.mock('../../../UI/Perps/selectors/featureFlags', () => ({ selectPerpsEnabledFlag: jest.fn().mockReturnValue(true), })); -const mockInitialState = { +const createMockState = ({ + notificationsEnabled = false, + socialLeaderboardEnabled = false, +} = {}) => ({ settings: { avatarAccountType: AvatarAccountType.Maskicon, }, @@ -19,9 +27,43 @@ const mockInitialState = { backgroundState: { ...backgroundState, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, + NotificationServicesController: { + ...backgroundState.NotificationServicesController, + isNotificationServicesEnabled: notificationsEnabled, + }, + RemoteFeatureFlagController: { + ...backgroundState.RemoteFeatureFlagController, + remoteFeatureFlags: { + ...backgroundState.RemoteFeatureFlagController.remoteFeatureFlags, + aiSocialLeaderboardEnabled: { + enabled: socialLeaderboardEnabled, + minimumVersion: '0.0.1', + }, + }, + }, }, }, -}; +}); + +const setOptions = jest.fn(); + +const renderNotificationsSettings = ( + state = createMockState(), + navigation = { + setOptions, + goBack: jest.fn(), + navigate: jest.fn(), + } as unknown as Props['navigation'], +) => + renderWithProvider( + , + { + state, + }, + ); jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); @@ -76,25 +118,42 @@ jest.mock('./hooks/useNotificationStoragePreferences', () => ({ }), })); -const setOptions = jest.fn(); +const socialAISectionTitle = strings( + 'app_settings.notifications_opts.social_ai_title', +); describe('NotificationsSettings', () => { - it('renders correctly', () => { - const { getByTestId } = renderWithProvider( - , - { - state: mockInitialState, - }, - ); + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders main notifications toggle', () => { + const { getByTestId } = renderNotificationsSettings(); + expect( getByTestId(NotificationSettingsViewSelectorsIDs.NOTIFICATIONS_TOGGLE), ).toBeOnTheScreen(); }); + + it('renders social AI section when social leaderboard feature flag is enabled', () => { + const state = createMockState({ + notificationsEnabled: true, + socialLeaderboardEnabled: true, + }); + + const { getByText } = renderNotificationsSettings(state); + + expect(getByText(socialAISectionTitle)).toBeOnTheScreen(); + }); + + it('hides social AI section when social leaderboard feature flag is disabled', () => { + const state = createMockState({ + notificationsEnabled: true, + socialLeaderboardEnabled: false, + }); + + const { queryByText } = renderNotificationsSettings(state); + + expect(queryByText(socialAISectionTitle)).toBeNull(); + }); }); diff --git a/app/components/Views/Settings/NotificationsSettings/index.tsx b/app/components/Views/Settings/NotificationsSettings/index.tsx index 75979fe67e9..6777b2c5cac 100644 --- a/app/components/Views/Settings/NotificationsSettings/index.tsx +++ b/app/components/Views/Settings/NotificationsSettings/index.tsx @@ -12,6 +12,7 @@ import SwitchLoadingModal from '../../../UI/Notification/SwitchLoadingModal'; import { Props } from './NotificationsSettings.types'; import { selectIsMetamaskNotificationsEnabled } from '../../../../selectors/notifications'; +import { selectSocialLeaderboardEnabled } from '../../../../selectors/featureFlagController/socialLeaderboard'; import Routes from '../../../../constants/navigation/Routes'; @@ -96,6 +97,9 @@ const NotificationsSettings = ({ navigation }: Props) => { const isMetamaskNotificationsEnabled = useSelector( selectIsMetamaskNotificationsEnabled, ); + const isSocialLeaderboardEnabled = useSelector( + selectSocialLeaderboardEnabled, + ); const loadingText = useSwitchNotificationLoadingText(); const { preferences } = useNotificationStoragePreferences(); @@ -155,18 +159,22 @@ const NotificationsSettings = ({ navigation }: Props) => { } /> - - navigateToSection( - 'socialAI', - strings('app_settings.notifications_opts.social_ai_title'), - strings('app_settings.notifications_opts.social_ai_desc'), - ) - } - /> + {isSocialLeaderboardEnabled && ( + + navigateToSection( + 'socialAI', + strings('app_settings.notifications_opts.social_ai_title'), + strings('app_settings.notifications_opts.social_ai_desc'), + ) + } + /> + )} name as unknown as ComponentType; @@ -70,6 +72,18 @@ describe('SocialLoginErrorSheet', () => { }, }; + const renderSheet = ( + props: { error?: Error; accountType?: AccountType } = {}, + state = initialState, + ) => + renderWithProvider( + , + { state }, + ); + beforeEach(() => { jest.clearAllMocks(); mockAddProperties.mockReturnThis(); @@ -86,33 +100,30 @@ describe('SocialLoginErrorSheet', () => { describe('analytics', () => { it('tracks screen viewed event on mount', () => { - renderWithProvider(, { - state: initialState, - }); + renderSheet(); expect(mockCreateEventBuilder).toHaveBeenCalled(); expect(mockTrackEvent).toHaveBeenCalled(); }); - it('tracks screen viewed event with account_type from getSocialAccountType when OAuth provider is unknown', () => { - renderWithProvider(, { - state: initialState, - }); + it('tracks screen viewed event with explicit account_type prop', () => { + renderSheet({ accountType: AccountType.MetamaskGoogle }); expect(mockAddProperties).toHaveBeenCalledWith({ - account_type: AccountType.Metamask, + account_type: AccountType.MetamaskGoogle, error_type: 'Error', error_message: 'Test social login error', }); }); - it('tracks screen viewed event with metamask_google when Google OAuth is in seedless state', () => { - renderWithProvider(, { - state: stateWithGoogleOAuth, - }); + it('uses explicit account_type instead of seedless auth connection state', () => { + renderSheet( + { accountType: AccountType.MetamaskApple }, + stateWithGoogleOAuth, + ); expect(mockAddProperties).toHaveBeenCalledWith({ - account_type: AccountType.MetamaskGoogle, + account_type: AccountType.MetamaskApple, error_type: 'Error', error_message: 'Test social login error', }); @@ -121,10 +132,7 @@ describe('SocialLoginErrorSheet', () => { it('tracks retry clicked event when Try again is pressed', async () => { (Authentication.deleteWallet as jest.Mock).mockResolvedValue(undefined); - const { getByText } = renderWithProvider( - , - { state: initialState }, - ); + const { getByText } = renderSheet(); mockCreateEventBuilder.mockClear(); mockAddProperties.mockClear(); @@ -138,17 +146,14 @@ describe('SocialLoginErrorSheet', () => { ); expect(mockAddProperties).toHaveBeenCalledWith({ cta_type: WalletCreationErrorCtaType.Retry, - account_type: AccountType.Metamask, + account_type: AccountType.MetamaskGoogle, }); expect(mockTrackEvent).toHaveBeenCalled(); }); }); it('tracks support clicked event when MetaMask Support is pressed', () => { - const { getByText } = renderWithProvider( - , - { state: initialState }, - ); + const { getByText } = renderSheet(); mockCreateEventBuilder.mockClear(); mockAddProperties.mockClear(); @@ -161,41 +166,33 @@ describe('SocialLoginErrorSheet', () => { ); expect(mockAddProperties).toHaveBeenCalledWith({ cta_type: WalletCreationErrorCtaType.ContactSupport, - account_type: AccountType.Metamask, + account_type: AccountType.MetamaskGoogle, }); expect(mockTrackEvent).toHaveBeenCalled(); }); }); it('renders error title', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); + const { getByText } = renderSheet({}, initialState); expect(getByText('Something went wrong')).toBeOnTheScreen(); }); it('renders try again button', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); + const { getByText } = renderSheet({}, initialState); expect(getByText('Try again')).toBeOnTheScreen(); }); it('renders MetaMask Support link', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); + const { getByText } = renderSheet({}, initialState); expect(getByText('MetaMask Support')).toBeOnTheScreen(); }); it('deletes wallet and resets navigation when try again is pressed', async () => { (Authentication.deleteWallet as jest.Mock).mockResolvedValue(undefined); - const { getByText } = renderWithProvider(, { - state: initialState, - }); + const { getByText } = renderSheet({}, initialState); const tryAgainButton = getByText('Try again'); fireEvent.press(tryAgainButton); @@ -211,9 +208,7 @@ describe('SocialLoginErrorSheet', () => { }); it('opens support URL when MetaMask Support is pressed', () => { - const { getByText } = renderWithProvider(, { - state: initialState, - }); + const { getByText } = renderSheet({}, initialState); const supportLink = getByText('MetaMask Support'); fireEvent.press(supportLink); @@ -224,24 +219,14 @@ describe('SocialLoginErrorSheet', () => { }); it('renders fox logo image', () => { - const { UNSAFE_getAllByType } = renderWithProvider( - , - { - state: initialState, - }, - ); + const { UNSAFE_getAllByType } = renderSheet({}, initialState); const images = UNSAFE_getAllByType(Image); expect(images.length).toBeGreaterThan(0); }); it('renders danger icon', () => { - const { UNSAFE_getAllByType } = renderWithProvider( - , - { - state: initialState, - }, - ); + const { UNSAFE_getAllByType } = renderSheet({}, initialState); const icons = UNSAFE_getAllByType(asComponentType('SvgMock')); expect(icons.length).toBeGreaterThan(0); diff --git a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx index efc116f0cad..782e06d52b2 100644 --- a/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx +++ b/app/components/Views/WalletCreationError/SocialLoginErrorSheet.tsx @@ -1,8 +1,7 @@ -import React, { useCallback, useEffect, useMemo } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { Image, Linking } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation } from '@react-navigation/native'; -import { useSelector } from 'react-redux'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { @@ -22,10 +21,9 @@ import { import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../core/Analytics'; import { - getSocialAccountType, + AccountType, WalletCreationErrorCtaType, } from '../../../constants/onboarding'; -import { selectSeedlessOnboardingAuthConnection } from '../../../selectors/seedlessOnboardingController'; import { strings } from '../../../../locales/i18n'; import Routes from '../../../constants/navigation/Routes'; @@ -37,17 +35,16 @@ const FOX_LOGO = require('../../../images/branding/fox.png'); interface SocialLoginErrorSheetProps { error?: Error; + accountType: AccountType; } -const SocialLoginErrorSheet = ({ error }: SocialLoginErrorSheetProps) => { +const SocialLoginErrorSheet = ({ + error, + accountType, +}: SocialLoginErrorSheetProps) => { const navigation = useNavigation(); const tw = useTailwind(); const { trackEvent, createEventBuilder } = useAnalytics(); - const oauthProvider = useSelector(selectSeedlessOnboardingAuthConnection); - const accountType = useMemo( - () => getSocialAccountType(oauthProvider ?? '', false), - [oauthProvider], - ); useEffect(() => { trackEvent( diff --git a/app/components/Views/WalletCreationError/index.tsx b/app/components/Views/WalletCreationError/index.tsx index 214a62a9a23..336e32d86fe 100644 --- a/app/components/Views/WalletCreationError/index.tsx +++ b/app/components/Views/WalletCreationError/index.tsx @@ -3,24 +3,36 @@ import { useRoute, RouteProp } from '@react-navigation/native'; import SocialLoginErrorSheet from './SocialLoginErrorSheet'; import SRPErrorScreen from './SRPErrorScreen'; +import { AccountType } from '../../../constants/onboarding'; interface WalletCreationErrorParams { metricsEnabled: boolean; error: Error; + accountType?: AccountType; } const WalletCreationError = () => { const route = useRoute>(); - const { metricsEnabled, error } = route.params || {}; + const { metricsEnabled, error, accountType } = route.params || {}; // Render different UI based on metrics consent status if (metricsEnabled) { - return ; + return ( + + ); } - return ; + return ( + + ); }; export default WalletCreationError; diff --git a/app/core/Analytics/MetaMetrics.events.ts b/app/core/Analytics/MetaMetrics.events.ts index 299083e5ca6..1246b431d7a 100644 --- a/app/core/Analytics/MetaMetrics.events.ts +++ b/app/core/Analytics/MetaMetrics.events.ts @@ -183,6 +183,7 @@ enum EVENT_NAME { WALLET_SETUP_COMPLETED = 'Wallet Setup Completed', SOCIAL_LOGIN_COMPLETED = 'Social Login Completed', SOCIAL_LOGIN_FAILED = 'Social Login Failed', + SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED = 'Social Login Auth Browser Dismissed', SOCIAL_LOGIN_IOS_SUCCESS_VIEWED = 'Social Login iOS Success Viewed', SOCIAL_LOGIN_IOS_SUCCESS_CTA_CLICKED = 'Social Login iOS Success CTA Clicked', ACCOUNT_ALREADY_EXISTS_PAGE_VIEWED = 'Account Already Exists Page Viewed', @@ -995,6 +996,9 @@ const events = { WALLET_SETUP_COMPLETED: generateOpt(EVENT_NAME.WALLET_SETUP_COMPLETED), SOCIAL_LOGIN_COMPLETED: generateOpt(EVENT_NAME.SOCIAL_LOGIN_COMPLETED), SOCIAL_LOGIN_FAILED: generateOpt(EVENT_NAME.SOCIAL_LOGIN_FAILED), + SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED: generateOpt( + EVENT_NAME.SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED, + ), SOCIAL_LOGIN_IOS_SUCCESS_VIEWED: generateOpt( EVENT_NAME.SOCIAL_LOGIN_IOS_SUCCESS_VIEWED, ), diff --git a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts index 8d0b6c71aac..ba352699f4a 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.test.ts @@ -48,6 +48,58 @@ describe('AndroidGoogleLoginHandler', () => { expect(mockSignInWithGoogle).toHaveBeenCalledTimes(1); }); + it('treats American spelling "canceled" as UserCancelled', async () => { + mockSignInWithGoogle.mockRejectedValue( + new Error('One Tap was canceled by the user'), + ); + + await expect(handler.login()).rejects.toMatchObject({ + code: OAuthErrorType.UserCancelled, + }); + }); + + it('treats One Tap failure with cancel wording as UserCancelled', async () => { + mockSignInWithGoogle.mockRejectedValue( + new Error( + 'During begin signin, failure response from one tap: canceled', + ), + ); + + await expect(handler.login()).rejects.toMatchObject({ + code: OAuthErrorType.UserCancelled, + }); + }); + + it('throws GoogleLoginOneTapFailure for One Tap failure without cancel wording', async () => { + mockSignInWithGoogle.mockRejectedValue( + new Error('During begin signin, failure response from one tap'), + ); + + await expect(handler.login()).rejects.toMatchObject({ + code: OAuthErrorType.GoogleLoginOneTapFailure, + }); + }); + + it('throws GoogleLoginNoMatchingCredential when One Tap failure includes matching credential', async () => { + mockSignInWithGoogle.mockRejectedValue( + new Error( + 'During begin signin, failure response from one tap. 16: [28433] Cannot find matching credential error', + ), + ); + + await expect(handler.login()).rejects.toMatchObject({ + code: OAuthErrorType.GoogleLoginNoMatchingCredential, + }); + }); + + it('treats resolved cancel result as UserCancelled', async () => { + mockSignInWithGoogle.mockResolvedValue({ type: 'cancel' }); + + await expect(handler.login()).rejects.toMatchObject({ + code: OAuthErrorType.UserCancelled, + }); + }); + it('throws GoogleLoginUserDisabledOneTapFeature when user disabled One Tap', async () => { mockSignInWithGoogle.mockRejectedValue( new Error('user disabled the feature'), diff --git a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts index 2f26f4f6437..23240bda4d0 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/androidHandlers/google.ts @@ -6,7 +6,11 @@ import { } from '../../OAuthInterface'; import { signInWithGoogle } from '@metamask/react-native-acm'; import { BaseHandlerOptions, BaseLoginHandler } from '../baseHandler'; -import { OAuthErrorType, OAuthError } from '../../error'; +import { + OAuthError, + OAuthErrorType, + isOAuthUserCancellationMessage, +} from '../../error'; import Logger from '../../../../util/Logger'; /** @@ -18,18 +22,17 @@ import Logger from '../../../../util/Logger'; * matches both ONE_TAP_FAILURE and NO_MATCHING_CREDENTIAL. * * Current priority order (more specific patterns first): - * 1. CANCEL / NO_CREDENTIAL - user explicitly cancelled or dismissed the dialog + * 1. USER_CANCEL / NO_CREDENTIAL - user explicitly cancelled or dismissed the dialog; isOAuthUserCancellationMessage runs before regex branches, including One Tap cancel text * 2. USER_DISABLED_FEATURE - user disabled One Tap * 3. NO_MATCHING_CREDENTIAL - account exists but doesn't match (contains "matching credential") * 4. ONE_TAP_FAILURE - generic One Tap failure (catch-all for other One Tap issues) * 5. NO_PROVIDER_DEPENDENCIES - credential provider not available (e.g., missing Google Play Services) */ const ACM_ERRORS_REGEX = { - CANCEL: /user\s+cancel|cancelled|16:\s*\[.*\]\s*cancel/i, NO_CREDENTIAL: /no credential/i, - NO_MATCHING_CREDENTIAL: /matching credential/i, USER_DISABLED_FEATURE: /user disabled the feature/i, ONE_TAP_FAILURE: /failure response from one tap/i, + NO_MATCHING_CREDENTIAL: /matching credential/i, NO_PROVIDER_DEPENDENCIES: /no provider dependencies|provider.{0,20}not available|provider.{0,20}configuration/i, }; @@ -87,6 +90,17 @@ export class AndroidGoogleLoginHandler extends BaseLoginHandler { }; } + if ( + result?.type === 'cancel' || + result?.type === 'cancelled' || + result?.type === 'dismiss' + ) { + throw new OAuthError( + 'handleGoogleLogin: User cancelled the login process', + OAuthErrorType.UserCancelled, + ); + } + throw new OAuthError( 'handleGoogleLogin: Unknown error', OAuthErrorType.UnknownError, @@ -97,7 +111,7 @@ export class AndroidGoogleLoginHandler extends BaseLoginHandler { throw error; } else if (error instanceof Error) { if ( - ACM_ERRORS_REGEX.CANCEL.test(error.message) || + isOAuthUserCancellationMessage(error.message) || ACM_ERRORS_REGEX.NO_CREDENTIAL.test(error.message) ) { throw new OAuthError( diff --git a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts index 81b732585da..7e7df7d2a31 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/iosHandlers/apple.ts @@ -9,7 +9,11 @@ import { AppleAuthenticationScope, } from 'expo-apple-authentication'; import { BaseHandlerOptions, BaseLoginHandler } from '../baseHandler'; -import { OAuthErrorType, OAuthError } from '../../error'; +import { + OAuthErrorType, + OAuthError, + isOAuthUserCancellationMessage, +} from '../../error'; import Logger from '../../../../util/Logger'; /** @@ -77,8 +81,10 @@ export class IosAppleLoginHandler extends BaseLoginHandler { if (error instanceof OAuthError) { throw error; } else if (error instanceof Error) { + const errorWithCode = error as Error & { code?: string }; if ( - error.message.includes('The user canceled the authorization attempt') + errorWithCode.code === 'ERR_REQUEST_CANCELED' || + isOAuthUserCancellationMessage(error.message) ) { throw new OAuthError( 'handleIosAppleLogin: User canceled the authorization attempt', diff --git a/app/core/OAuthService/OAuthService.test.ts b/app/core/OAuthService/OAuthService.test.ts index 0a615933cec..a50ec56442f 100644 --- a/app/core/OAuthService/OAuthService.test.ts +++ b/app/core/OAuthService/OAuthService.test.ts @@ -434,6 +434,119 @@ describe('OAuth login service', () => { ); }); + it('tracks SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED when provider login is dismissed', async () => { + const loginHandler = mockCreateLoginHandler(); + mockLoginHandlerResponse.mockImplementation(() => { + throw new OAuthError('Login dismissed', OAuthErrorType.UserDismissed); + }); + + await expect( + OAuthLoginService.handleOAuthLogin(loginHandler, false), + ).rejects.toMatchObject({ code: OAuthErrorType.UserDismissed }); + + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Auth Browser Dismissed', + properties: expect.objectContaining({ + auth_connection: AuthConnection.Google, + account_type: AccountType.MetamaskGoogle, + surface: 'onboarding', + elapsed_ms: expect.any(Number), + }), + }), + ); + expect(analytics.trackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Failed', + }), + ); + }); + + it('tracks SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED for native provider cancel', async () => { + const loginHandler = mockCreateLoginHandler(); + mockLoginHandlerResponse.mockImplementation(() => { + throw new OAuthError('Login cancelled', OAuthErrorType.UserCancelled); + }); + + await expect( + OAuthLoginService.handleOAuthLogin(loginHandler, false), + ).rejects.toMatchObject({ code: OAuthErrorType.UserCancelled }); + + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Auth Browser Dismissed', + properties: expect.objectContaining({ + auth_connection: AuthConnection.Google, + account_type: AccountType.MetamaskGoogle, + surface: 'onboarding', + elapsed_ms: expect.any(Number), + }), + }), + ); + expect(analytics.trackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Failed', + }), + ); + }); + + it('tracks rehydration surface on SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED when rehydrating', async () => { + const loginHandler = mockCreateLoginHandler(); + mockLoginHandlerResponse.mockImplementation(() => { + throw new OAuthError('Login cancelled', OAuthErrorType.UserCancelled); + }); + + await expect( + OAuthLoginService.handleOAuthLogin(loginHandler, true), + ).rejects.toMatchObject({ code: OAuthErrorType.UserCancelled }); + + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Auth Browser Dismissed', + properties: expect.objectContaining({ + auth_connection: AuthConnection.Google, + account_type: AccountType.ImportedGoogle, + surface: 'rehydration', + elapsed_ms: expect.any(Number), + }), + }), + ); + expect(analytics.trackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Failed', + }), + ); + }); + + it('tracks SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED for provider cancel mapped to AppleLoginError', async () => { + const loginHandler = mockCreateLoginHandler(); + mockLoginHandlerResponse.mockImplementation(() => { + throw new OAuthError( + 'Apple login error - The user canceled the authorization attempt', + OAuthErrorType.AppleLoginError, + ); + }); + + await expect( + OAuthLoginService.handleOAuthLogin(loginHandler, false), + ).rejects.toMatchObject({ code: OAuthErrorType.AppleLoginError }); + + expect(analytics.trackEvent).toHaveBeenCalledTimes(1); + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Auth Browser Dismissed', + }), + ); + expect(analytics.trackEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Social Login Failed', + }), + ); + }); + // use for loop to test undefine and null cases for (const value of [undefined, null]) { it(`throws error when login handler returns ${value}`, async () => { diff --git a/app/core/OAuthService/OAuthService.ts b/app/core/OAuthService/OAuthService.ts index ace460a7f17..cc4fea5abf3 100644 --- a/app/core/OAuthService/OAuthService.ts +++ b/app/core/OAuthService/OAuthService.ts @@ -25,7 +25,11 @@ import { GoogleWebGID, } from './OAuthLoginHandlers/constants'; import { QAMockOAuthService } from './QAMockOAuthService'; -import { OAuthError, OAuthErrorType } from './error'; +import { + OAuthError, + OAuthErrorType, + isSocialLoginAuthSessionDismissed, +} from './error'; import { BaseLoginHandler } from './OAuthLoginHandlers/baseHandler'; import { Platform } from 'react-native'; import { signOut as acmSignOut } from '@metamask/react-native-acm'; @@ -36,6 +40,7 @@ import { import { analytics } from '../../util/analytics/analytics'; import { AnalyticsEventBuilder } from '../../util/analytics/AnalyticsEventBuilder'; import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events'; +import { trackSocialLoginFailed } from './socialLoginAnalytics'; import ReduxService from '../redux'; import { setSeedlessOnboarding } from '../../actions/onboarding'; import Device from '../../util/device'; @@ -287,8 +292,9 @@ export class OAuthService { name: TraceName.OnboardingOAuthBYOAServerGetAuthTokensError, }); - this.#trackSocialLoginFailure({ + trackSocialLoginFailed({ authConnection, + isRehydration: this.localState.userClickedRehydration, errorCategory: 'get_auth_tokens', error, }); @@ -333,8 +339,9 @@ export class OAuthService { name: TraceName.OnboardingOAuthSeedlessAuthenticateError, }); - this.#trackSocialLoginFailure({ + trackSocialLoginFailed({ authConnection, + isRehydration: this.localState.userClickedRehydration, errorCategory: 'seedless_auth', error, }); @@ -378,46 +385,26 @@ export class OAuthService { } }; - #trackSocialLoginFailure = ({ + #trackSocialLoginAuthBrowserDismissed = ({ authConnection, - errorCategory, - error, + elapsedMs, }: { authConnection: AuthConnection; - errorCategory: 'provider_login' | 'get_auth_tokens' | 'seedless_auth'; - error: unknown; + elapsedMs: number; }) => { - const isUserCancelled = - error instanceof OAuthError && - (error.code === OAuthErrorType.UserCancelled || - error.code === OAuthErrorType.UserDismissed); - - let userClickedRehydration: 'true' | 'false' | 'unknown' = 'unknown'; - if (this.localState.userClickedRehydration !== undefined) { - userClickedRehydration = this.localState.userClickedRehydration - ? 'true' - : 'false'; - } - - const oauthErrorCode = - error instanceof OAuthError ? String(error.code) : undefined; + const isRehydration = this.localState.userClickedRehydration === true; + const properties = { + auth_connection: authConnection, + account_type: getSocialAccountType(authConnection, isRehydration), + surface: isRehydration ? 'rehydration' : 'onboarding', + elapsed_ms: elapsedMs, + }; analytics.trackEvent( AnalyticsEventBuilder.createEventBuilder( - MetaMetricsEvents.SOCIAL_LOGIN_FAILED, + MetaMetricsEvents.SOCIAL_LOGIN_AUTH_BROWSER_DISMISSED, ) - .addProperties({ - account_type: getSocialAccountType( - authConnection, - this.localState.userClickedRehydration === true, - ), - is_rehydration: userClickedRehydration, - failure_type: isUserCancelled ? 'user_cancelled' : 'error', - error_category: errorCategory, - ...(oauthErrorCode !== undefined && { - oauth_error_code: oauthErrorCode, - }), - }) + .addProperties(properties) .build(), ); }; @@ -426,6 +413,7 @@ export class OAuthService { loginHandler: BaseLoginHandler, ): Promise => { let providerLoginSuccess = false; + const providerLoginStartedAt = Date.now(); try { trace({ name: TraceName.OnboardingOAuthProviderLogin, @@ -459,11 +447,19 @@ export class OAuthService { endTrace({ name: TraceName.OnboardingOAuthProviderLoginError }); } - this.#trackSocialLoginFailure({ - authConnection: loginHandler.authConnection, - errorCategory: 'provider_login', - error, - }); + if (isSocialLoginAuthSessionDismissed(error)) { + this.#trackSocialLoginAuthBrowserDismissed({ + authConnection: loginHandler.authConnection, + elapsedMs: Date.now() - providerLoginStartedAt, + }); + } else { + trackSocialLoginFailed({ + authConnection: loginHandler.authConnection, + isRehydration: this.localState.userClickedRehydration, + errorCategory: 'provider_login', + error, + }); + } throw error; } finally { diff --git a/app/core/OAuthService/error.test.ts b/app/core/OAuthService/error.test.ts new file mode 100644 index 00000000000..c827ab336b6 --- /dev/null +++ b/app/core/OAuthService/error.test.ts @@ -0,0 +1,66 @@ +import { + OAuthError, + OAuthErrorType, + isOAuthUserCancellationMessage, + isSocialLoginAuthSessionDismissed, +} from './error'; + +describe('OAuth error helpers', () => { + describe('isOAuthUserCancellationMessage', () => { + it('detects Apple authorization cancel messages', () => { + expect( + isOAuthUserCancellationMessage( + 'The user canceled the authorization attempt', + ), + ).toBe(true); + }); + + it('detects American spelling canceled', () => { + expect(isOAuthUserCancellationMessage('One Tap was canceled')).toBe(true); + }); + + it('detects dismiss wording', () => { + expect( + isOAuthUserCancellationMessage('User dismissed the login process'), + ).toBe(true); + }); + + it('returns false for unrelated errors', () => { + expect(isOAuthUserCancellationMessage('Network request failed')).toBe( + false, + ); + }); + }); + + describe('isSocialLoginAuthSessionDismissed', () => { + it('returns true for UserCancelled and UserDismissed', () => { + expect( + isSocialLoginAuthSessionDismissed( + new OAuthError('cancel', OAuthErrorType.UserCancelled), + ), + ).toBe(true); + expect( + isSocialLoginAuthSessionDismissed( + new OAuthError('dismiss', OAuthErrorType.UserDismissed), + ), + ).toBe(true); + }); + + it('returns true for provider errors with cancel wording', () => { + expect( + isSocialLoginAuthSessionDismissed( + new OAuthError( + 'Apple login error - The user canceled the authorization attempt', + OAuthErrorType.AppleLoginError, + ), + ), + ).toBe(true); + }); + + it('returns false for non-OAuth errors', () => { + expect(isSocialLoginAuthSessionDismissed(new Error('cancel'))).toBe( + false, + ); + }); + }); +}); diff --git a/app/core/OAuthService/error.ts b/app/core/OAuthService/error.ts index 597f0bac32f..836686104a9 100644 --- a/app/core/OAuthService/error.ts +++ b/app/core/OAuthService/error.ts @@ -43,6 +43,24 @@ export const OAuthErrorMessages: Record = { [OAuthErrorType.TelegramLoginError]: 'Telegram login error', } as const; +/** + * Returns true when an OAuth provider error message indicates the user aborted + * sign-in (cancel, dismiss, close) rather than a server or configuration failure. + */ +export function isOAuthUserCancellationMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes('the user canceled the authorization attempt') || + normalized.includes('authorization error error 1001') || + normalized.includes('err_request_canceled') || + /user\s+cancel/.test(normalized) || + /\bcanceled\b/.test(normalized) || + /\bcancelled\b/.test(normalized) || + /16:\s*\[.*\]\s*cancel/.test(normalized) || + /\bdismiss/.test(normalized) + ); +} + export class OAuthError extends Error { public readonly code: OAuthErrorType; public readonly data: Record; @@ -64,3 +82,31 @@ export class OAuthError extends Error { this.data = data || {}; } } + +/** + * Returns true when a social login attempt ended because the user closed or + * cancelled the provider UI before completing authentication. + */ +export function isSocialLoginAuthSessionDismissed(error: unknown): boolean { + if (!(error instanceof OAuthError)) { + return false; + } + + if ( + error.code === OAuthErrorType.UserCancelled || + error.code === OAuthErrorType.UserDismissed + ) { + return true; + } + + if ( + error.code === OAuthErrorType.GoogleLoginError || + error.code === OAuthErrorType.AppleLoginError || + error.code === OAuthErrorType.UnknownError || + error.code === OAuthErrorType.GoogleLoginOneTapFailure + ) { + return isOAuthUserCancellationMessage(error.message); + } + + return false; +} diff --git a/app/core/OAuthService/socialLoginAnalytics.test.ts b/app/core/OAuthService/socialLoginAnalytics.test.ts new file mode 100644 index 00000000000..c3ee0349154 --- /dev/null +++ b/app/core/OAuthService/socialLoginAnalytics.test.ts @@ -0,0 +1,113 @@ +import { AccountType } from '../../constants/onboarding'; +import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events'; +import { AuthConnection } from './OAuthInterface'; +import { OAuthError, OAuthErrorType } from './error'; +import { + isPreOAuthSocialLoginFailure, + trackSocialLoginFailed, +} from './socialLoginAnalytics'; + +const mockTrackEvent = jest.fn(); + +jest.mock('../../util/analytics/analytics', () => ({ + analytics: { + trackEvent: (...args: unknown[]) => mockTrackEvent(...args), + }, +})); + +describe('socialLoginAnalytics', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('isPreOAuthSocialLoginFailure', () => { + it('returns true for invalid provider errors', () => { + expect( + isPreOAuthSocialLoginFailure( + new OAuthError('Invalid provider', OAuthErrorType.InvalidProvider), + ), + ).toBe(true); + }); + + it('returns true for unsupported platform errors', () => { + expect( + isPreOAuthSocialLoginFailure( + new OAuthError( + 'Unsupported platform', + OAuthErrorType.UnsupportedPlatform, + ), + ), + ).toBe(true); + }); + + it('returns false for provider login errors handled inside OAuthService', () => { + expect( + isPreOAuthSocialLoginFailure( + new OAuthError('Login error', OAuthErrorType.LoginError), + ), + ).toBe(false); + }); + }); + + describe('trackSocialLoginFailed', () => { + it('tracks Social Login Failed for onboarding create-wallet flow', () => { + trackSocialLoginFailed({ + authConnection: AuthConnection.Google, + isRehydration: false, + errorCategory: 'provider_login', + error: new OAuthError( + 'Invalid provider', + OAuthErrorType.InvalidProvider, + ), + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: MetaMetricsEvents.SOCIAL_LOGIN_FAILED.category, + properties: expect.objectContaining({ + account_type: AccountType.MetamaskGoogle, + is_rehydration: 'false', + failure_type: 'error', + error_category: 'provider_login', + }), + }), + ); + }); + + it('tracks user_cancelled failure_type for dismiss errors', () => { + trackSocialLoginFailed({ + authConnection: AuthConnection.Apple, + isRehydration: true, + errorCategory: 'provider_login', + error: new OAuthError('User dismissed', OAuthErrorType.UserDismissed), + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + account_type: AccountType.ImportedApple, + is_rehydration: 'true', + failure_type: 'user_cancelled', + }), + }), + ); + }); + + it('includes oauth_error_code when error is an OAuthError', () => { + trackSocialLoginFailed({ + authConnection: AuthConnection.Google, + isRehydration: false, + errorCategory: 'provider_login', + error: new OAuthError('Login error', OAuthErrorType.LoginError), + }); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + oauth_error_code: String(OAuthErrorType.LoginError), + }), + }), + ); + }); + }); +}); diff --git a/app/core/OAuthService/socialLoginAnalytics.ts b/app/core/OAuthService/socialLoginAnalytics.ts new file mode 100644 index 00000000000..c213497b77c --- /dev/null +++ b/app/core/OAuthService/socialLoginAnalytics.ts @@ -0,0 +1,68 @@ +import { getSocialAccountType } from '../../constants/onboarding'; +import { analytics } from '../../util/analytics/analytics'; +import { AnalyticsEventBuilder } from '../../util/analytics/AnalyticsEventBuilder'; +import { MetaMetricsEvents } from '../Analytics/MetaMetrics.events'; +import { OAuthError, OAuthErrorType } from './error'; + +export type SocialLoginFailureErrorCategory = + | 'provider_login' + | 'get_auth_tokens' + | 'seedless_auth'; + +/** + * OAuth failures that occur in Onboarding before {@link OAuthService.handleOAuthLogin} + * is invoked (e.g. invalid/disabled provider from {@link createLoginHandler}). + */ +export function isPreOAuthSocialLoginFailure(error: OAuthError): boolean { + return ( + error.code === OAuthErrorType.InvalidProvider || + error.code === OAuthErrorType.UnsupportedPlatform + ); +} + +/** + * Tracks the Social Login Failed analytics event. + */ +export function trackSocialLoginFailed({ + authConnection, + isRehydration, + errorCategory, + error, +}: { + authConnection: string; + isRehydration?: boolean; + errorCategory: SocialLoginFailureErrorCategory; + error: unknown; +}): void { + const isUserCancelled = + error instanceof OAuthError && + (error.code === OAuthErrorType.UserCancelled || + error.code === OAuthErrorType.UserDismissed); + + let isRehydrationValue: 'true' | 'false' | 'unknown' = 'unknown'; + if (isRehydration !== undefined) { + isRehydrationValue = isRehydration ? 'true' : 'false'; + } + + const oauthErrorCode = + error instanceof OAuthError ? String(error.code) : undefined; + + analytics.trackEvent( + AnalyticsEventBuilder.createEventBuilder( + MetaMetricsEvents.SOCIAL_LOGIN_FAILED, + ) + .addProperties({ + account_type: getSocialAccountType( + authConnection, + isRehydration === true, + ), + is_rehydration: isRehydrationValue, + failure_type: isUserCancelled ? 'user_cancelled' : 'error', + error_category: errorCategory, + ...(oauthErrorCode !== undefined && { + oauth_error_code: oauthErrorCode, + }), + }) + .build(), + ); +} diff --git a/locales/languages/en.json b/locales/languages/en.json index e707e2f48a1..f7b22a73522 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -2366,6 +2366,7 @@ "outcome_groups": { "game_lines": "Game Lines", "first_half": "1st Half", + "first_set": "1st Set", "team_totals": "Team Totals", "touchdowns": "Touchdowns", "rushing": "Rushing", @@ -2387,6 +2388,7 @@ "moneyline": "Moneyline", "spreads": "Spreads", "totals": "Totals", + "nrfi": "Will there be a run in the first inning?", "both_teams_to_score": "Both Teams to Score", "first_half_moneyline": "1H Moneyline", "first_half_spreads": "1H Spreads", @@ -2406,7 +2408,12 @@ "soccer_anytime_goalscorer": "Goalscorers", "soccer_exact_score": "Exact Score", "soccer_halftime_result": "Halftime Result", - "total_corners": "Corners" + "total_corners": "Corners", + "tennis_set_totals": "Total Sets", + "tennis_match_totals": "Total Games", + "tennis_first_set_totals": "1st Set Total Games", + "tennis_first_set_winner": "1st Set Winner", + "tennis_completed_match": "Completed Match" }, "sell_position": "Sell position", "odds": "Odds", diff --git a/package.json b/package.json index 5edc676e9b2..1e6ff08329e 100644 --- a/package.json +++ b/package.json @@ -310,7 +310,7 @@ "@metamask/native-utils": "^0.8.0", "@metamask/network-controller": "^31.0.0", "@metamask/network-enablement-controller": "^5.1.0", - "@metamask/notification-services-controller": "^24.0.0", + "@metamask/notification-services-controller": "24.1.1", "@metamask/permission-controller": "^13.1.1", "@metamask/phishing-controller": "^17.1.1", "@metamask/post-message-stream": "^10.0.0", diff --git a/scripts/perps/agentic/README.md b/scripts/perps/agentic/README.md index 6713b0054b7..494ecbb4742 100644 --- a/scripts/perps/agentic/README.md +++ b/scripts/perps/agentic/README.md @@ -213,6 +213,29 @@ Used in `assert` blocks on steps and pre-conditions: Compound: `{ all: [...] }`, `{ any: [...] }`, `{ none: [...] }`. +## Preflight modes + +`preflight.sh` accepts `--mode ` to control how much of the native setup runs. Cuts cold-rebuild waste when the native dep graph hasn't changed. + +| Mode | yarn setup | pod install | xcodebuild | Reads shared cache | +|---|---|---|---|---| +| `auto` (recommended) | no | only on native rebuild, no `--repo-update` (one-shot `--repo-update` retry on failure) | only on fingerprint miss | yes | +| `fast` | no | no | no — fail loud if missing | yes | +| `rebuild-native` | no | yes (no `--repo-update`) | yes | no | +| `clean` (legacy `--clean`) | yes | yes with `--repo-update` | yes | no (writes only) | + +Cache lives in `$MM_BUILD_CACHE_DIR` (default `~/Library/Caches/mm-mobile-builds` on macOS, `~/.cache/mm-mobile-builds` on Linux), keyed by `@expo/fingerprint` hash. Parallel worktrees at the same fingerprint share one artifact through a per-fingerprint mutex: Linux uses `flock(1)` (auto-released by the kernel on process death); macOS, where `flock` is not in base, uses an atomic `mkdir .lock.d` fallback that is released by the script's `EXIT` trap. If a script is killed with `kill -9` between `mkdir` and the trap, the mutex dir can be left behind — delete it manually under `$MM_BUILD_CACHE_DIR//`. Override retention with `BUILD_CACHE_RETAIN=N` (default 5 per platform). + +Invoke directly: + +```bash +bash scripts/perps/agentic/preflight.sh --platform ios --mode auto --wallet-setup # fingerprint-gated reuse, build only on miss +bash scripts/perps/agentic/preflight.sh --platform ios --mode fast --wallet-setup # fail loud if no cached/installed build +bash scripts/perps/agentic/preflight.sh --platform ios --clean --wallet-setup # legacy clean rebuild (unchanged) +``` + +Farmslot dispatch: once this branch lands on `main`, switch `projects/metamask-mobile-farm/project.json` `preflight` hook from `--clean` to `--mode auto`. Keep `--mode clean` as the explicit burn-it-down escape. + ## CLI ```bash diff --git a/scripts/perps/agentic/lib/build-cache.sh b/scripts/perps/agentic/lib/build-cache.sh new file mode 100644 index 00000000000..839288390b5 --- /dev/null +++ b/scripts/perps/agentic/lib/build-cache.sh @@ -0,0 +1,290 @@ +#!/bin/bash +# build-cache.sh — shared helpers for fingerprint-gated native build reuse. +# +# Two-tier cache: +# Tier 1 (shared, one per host): $MM_BUILD_CACHE_DIR (default ~/Library/Caches/mm-mobile-builds) +# Tier 2 (per-worktree sidecar): .agent/build-cache//installed.json +# +# All functions are pure shell so preflight.sh can source this file directly. +# Callers must `set -euo pipefail` themselves; this file does not. + +# Source-time sanitization: drop any inherited claim on the private memo +# directory. Bash imports exported env vars as shell vars on startup, so a +# parent process running this lib could otherwise convince us we own a +# caller-supplied BC_MEMO_DIR and recurse rm -rf into it from cleanup. +# Only ownership set by bc_memo_init running in this shell, AFTER the unset +# below, is ever trusted. +unset BC_MEMO_DIR_OWNED BC_MEMO_DIR + +# Resolve shared cache root. Honors override env, defaults per-OS. +bc_root() { + if [ -n "${MM_BUILD_CACHE_DIR:-}" ]; then + printf '%s\n' "$MM_BUILD_CACHE_DIR" + return + fi + if [ "$(uname)" = "Darwin" ]; then + printf '%s\n' "$HOME/Library/Caches/mm-mobile-builds" + else + printf '%s\n' "${XDG_CACHE_HOME:-$HOME/.cache}/mm-mobile-builds" + fi +} + +bc_plat_dir() { + local plat="$1" + printf '%s/%s\n' "$(bc_root)" "$plat" +} + +bc_artifact_path() { + local plat="$1" fp="$2" + local ext + [ "$plat" = "ios" ] && ext="app" || ext="apk" + printf '%s/%s.%s\n' "$(bc_plat_dir "$plat")" "$fp" "$ext" +} + +bc_meta_path() { + local plat="$1" fp="$2" + printf '%s/%s.meta.json\n' "$(bc_plat_dir "$plat")" "$fp" +} + +bc_lock_path() { + local plat="$1" fp="$2" + printf '%s/%s.lock\n' "$(bc_plat_dir "$plat")" "$fp" +} + +bc_installed_json() { + local plat="$1" + printf '.agent/build-cache/%s/installed.json\n' "$plat" +} + +# Ensure shared + per-worktree dirs exist. +bc_init_dirs() { + local plat="$1" + mkdir -p "$(bc_plat_dir "$plat")" + mkdir -p ".agent/build-cache/$plat" +} + +# Compute the current native fingerprint. Memoized in $BC_MEMO_DIR/fp so +# command-substitution callers (`FP=$(bc_fingerprint)`) survive subshell exit. +# Falls back to per-call compute if BC_MEMO_DIR isn't initialized. +bc_fingerprint() { + local memo="" + if [ -n "${BC_MEMO_DIR:-}" ] && [ -d "$BC_MEMO_DIR" ] && [ -w "$BC_MEMO_DIR" ]; then + memo="$BC_MEMO_DIR/fp" + # Trust the file only if it is a regular file (not a symlink/dir) inside + # our private dir; mktemp -d guarantees 0700 + exclusive ownership. + if [ -f "$memo" ] && [ ! -L "$memo" ] && [ -s "$memo" ]; then + cat "$memo" + return 0 + fi + fi + local fp + fp=$(node scripts/generate-fingerprint.js 2>/dev/null || true) + if [ -z "$fp" ]; then + return 1 + fi + if [ -n "$memo" ]; then + printf '%s' "$fp" > "$memo" + fi + printf '%s\n' "$fp" +} + +# Create the private memo dir (0700, mktemp -d) and record ownership in a +# NON-EXPORTED shell variable that child processes cannot inherit. +# A forgeable on-disk sentinel would not be enough — anyone with write access +# to a victim dir could pre-create the marker and trick us into rm -rf'ing +# it. Storing ownership in this shell only means an attacker who controls +# BC_MEMO_DIR via env cannot also make us think we own the dir. +bc_memo_init() { + if [ "${BC_MEMO_DIR_OWNED:-}" = "1" ] && [ -n "${BC_MEMO_DIR:-}" ] && [ -d "$BC_MEMO_DIR" ]; then + return 0 # already created by us in this shell + fi + local dir + dir=$(mktemp -d 2>/dev/null) || return 1 + chmod 700 "$dir" 2>/dev/null || true + export BC_MEMO_DIR="$dir" + # Deliberately not exported — child processes that inherit BC_MEMO_DIR + # from us will not also inherit the ownership flag. + BC_MEMO_DIR_OWNED=1 +} + +# Tear down the private memo dir — only if we own it in this shell. +# Never deletes an inherited / caller-supplied path. +bc_memo_cleanup() { + if [ "${BC_MEMO_DIR_OWNED:-}" = "1" ] \ + && [ -n "${BC_MEMO_DIR:-}" ] \ + && [ -d "$BC_MEMO_DIR" ]; then + rm -rf "$BC_MEMO_DIR" + fi + unset BC_MEMO_DIR + unset BC_MEMO_DIR_OWNED +} + +# Drop any inherited memo claim (bash imports env vars on startup, so a +# parent could otherwise convince us we own BC_MEMO_DIR) and create a fresh +# private dir. Called once at preflight startup. +bc_fingerprint_reset_memo() { + unset BC_MEMO_DIR_OWNED BC_MEMO_DIR + bc_memo_init +} + +# True if shared artifact for (plat, fp) exists AND is non-trivially populated. +# Rejects empty .app dirs (no Info.plist) and zero-byte .apk files to avoid +# treating a half-written or aborted store as a cache hit. +bc_has_artifact() { + local plat="$1" fp="$2" + local p + p=$(bc_artifact_path "$plat" "$fp") + if [ "$plat" = "ios" ]; then + [ -d "$p" ] && [ -f "$p/Info.plist" ] + else + [ -f "$p" ] && [ -s "$p" ] + fi +} + +# Read the per-worktree installed fingerprint (empty if unset). +bc_installed_fp() { + local plat="$1" + local f + f=$(bc_installed_json "$plat") + [ -f "$f" ] || { printf ''; return; } + jq -r '.fingerprint // empty' "$f" 2>/dev/null || true +} + +# Read the per-worktree installed target (sim UDID / adb serial). Empty if unset. +bc_installed_target() { + local plat="$1" + local f + f=$(bc_installed_json "$plat") + [ -f "$f" ] || { printf ''; return; } + jq -r '.target // empty' "$f" 2>/dev/null || true +} + +# Write per-worktree installed.json after a successful install. +# Uses jq for JSON escaping so unusual paths/targets don't produce invalid JSON. +bc_record_install() { + local plat="$1" fp="$2" target="$3" + local f + f=$(bc_installed_json "$plat") + mkdir -p "$(dirname "$f")" + jq -n --arg fp "$fp" --arg target "$target" --arg installedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{fingerprint:$fp, target:$target, installedAt:$installedAt}' > "$f" +} + +# Store a freshly-built artifact into the shared cache (atomic mv). +# JSON meta is written via jq to escape arbitrary paths. +bc_store_artifact() { + local plat="$1" fp="$2" src="$3" + local dst tmp meta + dst=$(bc_artifact_path "$plat" "$fp") + tmp="${dst}.tmp.$$" + meta=$(bc_meta_path "$plat" "$fp") + bc_init_dirs "$plat" + rm -rf "$tmp" "$dst" + cp -R "$src" "$tmp" + mv "$tmp" "$dst" + jq -n --arg fp "$fp" --arg builtAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" --arg builderWorktree "$(pwd)" \ + '{fingerprint:$fp, builtAt:$builtAt, builderWorktree:$builderWorktree}' > "$meta" +} + +# Portable mtime extraction. macOS BSD stat uses -f; GNU stat uses -c. +# Echoes " " per line. Silent on stat errors. +bc__stat_mtime() { + local path="$1" + if stat -f '%m %N' "$path" 2>/dev/null; then + return 0 + fi + stat -c '%Y %n' "$path" 2>/dev/null || true +} + +# LRU prune of shared cache. Keeps newest N per platform (default 5). +bc_prune() { + local plat="$1" keep="${2:-5}" + local d + d=$(bc_plat_dir "$plat") + [ -d "$d" ] || return 0 + local ext + [ "$plat" = "ios" ] && ext="app" || ext="apk" + local entries + entries=$( + find "$d" -maxdepth 1 -name "*.$ext" 2>/dev/null \ + | while IFS= read -r p; do bc__stat_mtime "$p"; done \ + | sort -rn \ + | awk '{ $1=""; sub(/^ /,""); print }' + ) + local i=0 + while IFS= read -r path; do + [ -z "$path" ] && continue + i=$((i + 1)) + [ "$i" -le "$keep" ] && continue + local base="${path%.*}" + rm -rf "$path" "${base}.meta.json" + done <<< "$entries" +} + +# Persistent-fd lock helpers. Use these when the locked region is too large to +# wrap in a single function call. Acquire returns 0 on success, 1 on timeout. +# Release tears down whichever lock mechanism was used. +# +# Usage: +# bc_lock_acquire ios "$FP" || fail "build-cache: lock timeout" +# trap 'bc_lock_release' EXIT +# ... locked section ... +# bc_lock_release +# trap - EXIT +bc_lock_acquire() { + local plat="$1" fp="$2" + local lock + lock=$(bc_lock_path "$plat" "$fp") + bc_init_dirs "$plat" + local timeout="${BUILD_CACHE_LOCK_TIMEOUT:-1800}" + + if command -v flock >/dev/null 2>&1; then + exec 9>"$lock" + if ! flock -w "$timeout" 9; then + exec 9>&- + echo "build-cache: timed out waiting for $lock" >&2 + return 1 + fi + BUILD_CACHE_LOCK_KIND="flock" + BUILD_CACHE_LOCK_PATH="$lock" + return 0 + fi + + # macOS fallback: mkdir-based mutex with poll. + local lockdir="${lock}.d" + local waited=0 + while ! mkdir "$lockdir" 2>/dev/null; do + if [ "$waited" -ge "$timeout" ]; then + echo "build-cache: timed out waiting for $lockdir" >&2 + return 1 + fi + sleep 1 + waited=$((waited + 1)) + done + BUILD_CACHE_LOCK_KIND="mkdir" + BUILD_CACHE_LOCK_PATH="$lockdir" + return 0 +} + +bc_lock_release() { + case "${BUILD_CACHE_LOCK_KIND:-}" in + flock) + exec 9>&- 2>/dev/null || true + ;; + mkdir) + [ -n "${BUILD_CACHE_LOCK_PATH:-}" ] && rmdir "$BUILD_CACHE_LOCK_PATH" 2>/dev/null || true + ;; + esac + unset BUILD_CACHE_LOCK_KIND BUILD_CACHE_LOCK_PATH +} + +# Function-scoped lock wrapper (kept for callers that have a tight body). +# For the larger preflight build region, prefer bc_lock_acquire / bc_lock_release. +bc_with_lock() { + local plat="$1" fp="$2"; shift 2 + bc_lock_acquire "$plat" "$fp" || return 1 + local rc=0 + "$@" || rc=$? + bc_lock_release + return $rc +} diff --git a/scripts/perps/agentic/lib/test-build-cache.sh b/scripts/perps/agentic/lib/test-build-cache.sh new file mode 100644 index 00000000000..338c31f8080 --- /dev/null +++ b/scripts/perps/agentic/lib/test-build-cache.sh @@ -0,0 +1,244 @@ +#!/bin/bash +# Smoke test for scripts/perps/agentic/lib/build-cache.sh + preflight --mode plumbing. +# Idempotent: uses a throwaway shared-cache dir and restores any pre-existing +# .agent/build-cache after running. Safe to invoke repeatedly. +# +# Usage: +# bash scripts/perps/agentic/lib/test-build-cache.sh +set -euo pipefail + +# Run from repo root regardless of caller cwd. +REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +cd "$REPO_ROOT" + +# Use a throwaway shared cache so we never touch the user's real ~/.../mm-mobile-builds. +export MM_BUILD_CACHE_DIR="/tmp/mm-bc-test-$$" +rm -rf "$MM_BUILD_CACHE_DIR" + +# Stash any real sidecar so the test can scribble on .agent/build-cache. +SIDE_BACKUP="" +if [ -d .agent/build-cache ]; then + SIDE_BACKUP="/tmp/mm-bc-sidecar-backup-$$" + mv .agent/build-cache "$SIDE_BACKUP" +fi +cleanup() { + rm -rf "$MM_BUILD_CACHE_DIR" .agent/build-cache 2>/dev/null || true + # Delegate memo-dir cleanup to the lib helper: it refuses to delete an + # inherited / unowned BC_MEMO_DIR. Plain `rm -rf "$BC_MEMO_DIR"` would + # nuke a caller-supplied path on early test failure. + if type bc_memo_cleanup >/dev/null 2>&1; then + bc_memo_cleanup + fi + if [ -n "$SIDE_BACKUP" ] && [ -d "$SIDE_BACKUP" ]; then + mv "$SIDE_BACKUP" .agent/build-cache + fi +} +trap cleanup EXIT + +# shellcheck disable=SC1091 +. scripts/perps/agentic/lib/build-cache.sh + +FAILED=0 +pass() { printf " \033[32mPASS\033[0m %s\n" "$1"; } +fail() { printf " \033[31mFAIL\033[0m %s\n" "$1"; FAILED=1; } +hdr() { printf "\n\033[1m== %s ==\033[0m\n" "$1"; } + +# Portable bounded capture: runs the command and captures combined stdout+stderr, +# killing it if it exceeds $1 seconds. Avoids the GNU `timeout` binary which is +# not in base macOS. Echoes whatever the command produced before the watchdog +# fired. +_capture_for() { + local secs="$1"; shift + local out_file + out_file=$(mktemp -t mm-bc-capture) + "$@" >"$out_file" 2>&1 & + local pid=$! + ( sleep "$secs" && kill "$pid" 2>/dev/null ) & + local watcher=$! + wait "$pid" 2>/dev/null + kill "$watcher" 2>/dev/null + wait "$watcher" 2>/dev/null + cat "$out_file" + rm -f "$out_file" +} + +# ─── 1. Path helpers ──────────────────────────────────────────────── +hdr "path helpers" +[ "$(bc_root)" = "$MM_BUILD_CACHE_DIR" ] && pass "bc_root respects MM_BUILD_CACHE_DIR" || fail "bc_root: $(bc_root)" +[ "$(bc_plat_dir ios)" = "$MM_BUILD_CACHE_DIR/ios" ] && pass "bc_plat_dir ios" || fail "bc_plat_dir ios" +[ "$(bc_artifact_path ios abc123)" = "$MM_BUILD_CACHE_DIR/ios/abc123.app" ] && pass "bc_artifact_path ios → .app" || fail "ios artifact path" +[ "$(bc_artifact_path android abc123)" = "$MM_BUILD_CACHE_DIR/android/abc123.apk" ] && pass "bc_artifact_path android → .apk" || fail "android artifact path" + +# ─── 2. Init dirs idempotent ──────────────────────────────────────── +hdr "bc_init_dirs" +bc_init_dirs ios +bc_init_dirs ios # second call must not error +[ -d "$MM_BUILD_CACHE_DIR/ios" ] && pass "shared dir created" || fail "shared dir missing" +[ -d ".agent/build-cache/ios" ] && pass "sidecar dir created" || fail "sidecar dir missing" + +# ─── 3. Fingerprint ───────────────────────────────────────────────── +hdr "bc_fingerprint" +unset BUILD_CACHE_FP +FP1=$(bc_fingerprint) +FP2=$(bc_fingerprint) # should hit memoized value +if [ -n "$FP1" ] && [ "${#FP1}" -gt 20 ] && [ "$FP1" = "$FP2" ]; then + pass "fingerprint stable: ${FP1:0:16}..." +else + fail "fingerprint unstable or empty: [$FP1] vs [$FP2]" +fi + +# ─── 4. Store + lookup round-trip ─────────────────────────────────── +hdr "bc_store_artifact + bc_has_artifact" +TEST_FP="testfp1234567890" +SRC="/tmp/mm-bc-fake-app-$$" +rm -rf "$SRC" +mkdir -p "$SRC" +# bc_has_artifact validity check requires Info.plist at the .app root. +echo "" > "$SRC/Info.plist" +bc_store_artifact ios "$TEST_FP" "$SRC" +[ -e "$(bc_artifact_path ios "$TEST_FP")" ] && pass "artifact stored at expected path" || fail "artifact not at expected path" +bc_has_artifact ios "$TEST_FP" && pass "bc_has_artifact returns true on hit" || fail "bc_has_artifact missed" +bc_has_artifact ios "nonexistent_fp" && fail "bc_has_artifact wrongly hits" || pass "bc_has_artifact returns false on miss" +[ -e "$(bc_meta_path ios "$TEST_FP")" ] && pass "meta.json written" || fail "meta.json missing" + +# ─── 5. installed.json round-trip ─────────────────────────────────── +hdr "installed.json" +bc_record_install ios "$TEST_FP" "Simulator-XYZ" +[ "$(bc_installed_fp ios)" = "$TEST_FP" ] && pass "bc_installed_fp returns recorded fp" || fail "bc_installed_fp mismatch: $(bc_installed_fp ios)" + +# ─── 6. Re-store overwrites atomically ────────────────────────────── +hdr "atomic overwrite" +echo "v2" > "$SRC/Info.plist" +bc_store_artifact ios "$TEST_FP" "$SRC" +GOT=$(cat "$(bc_artifact_path ios "$TEST_FP")/Info.plist") +echo "$GOT" | grep -q "v2" && pass "re-store overwrites contents" || fail "re-store did not overwrite: got '$GOT'" + +# ─── 7. Lock — serialized within one shell ────────────────────────── +hdr "bc_with_lock (sequential)" +LOG="/tmp/mm-bc-lock-log-$$" +: > "$LOG" +bc_with_lock ios "lockfp1" sh -c "echo A >> $LOG; sleep 0.2; echo B >> $LOG" +bc_with_lock ios "lockfp1" sh -c "echo C >> $LOG" +[ "$(tr -d '[:space:]' < "$LOG")" = "ABC" ] && pass "sequential lock acquire/release" || fail "sequential lock order: $(cat "$LOG")" +rm -f "$LOG" + +# ─── 8. Lock — concurrent (one waits for the other) ───────────────── +hdr "bc_with_lock (concurrent)" +LOG="/tmp/mm-bc-lock-conc-log-$$" +: > "$LOG" +BUILD_CACHE_LOCK_TIMEOUT=10 bc_with_lock ios "lockfp2" sh -c "echo start1 >> $LOG; sleep 1; echo end1 >> $LOG" & +PID1=$! +sleep 0.1 +BUILD_CACHE_LOCK_TIMEOUT=10 bc_with_lock ios "lockfp2" sh -c "echo start2 >> $LOG; echo end2 >> $LOG" & +PID2=$! +wait $PID1 $PID2 +LINES=$(tr '\n' ' ' < "$LOG") +case "$LINES" in + "start1 end1 start2 end2 ") pass "concurrent: second waited for first" ;; + *) fail "concurrent lock ordering wrong: $LINES" ;; +esac +rm -f "$LOG" + +# ─── 9. Prune keeps N newest ──────────────────────────────────────── +hdr "bc_prune" +# Clear prior artifacts so only prune-fp-* exist in the cache. +rm -rf "$MM_BUILD_CACHE_DIR/ios"/*.app "$MM_BUILD_CACHE_DIR/ios"/*.meta.json 2>/dev/null || true +for i in 1 2 3 4 5 6 7; do + FP="prune-fp-$i" + D="$(bc_artifact_path ios "$FP")" + mkdir -p "$D" + echo "x" > "$D/marker" + printf '{}' > "$(bc_meta_path ios "$FP")" + # YYYYMMDDhhmm — use distinct days so mtimes are unambiguously ordered. + touch -t "2024010${i}1200" "$D" "$(bc_meta_path ios "$FP")" 2>/dev/null || true +done +bc_prune ios 3 +REMAINING=$(find "$MM_BUILD_CACHE_DIR/ios" -maxdepth 1 -name "prune-fp-*.app" | wc -l | tr -d ' ') +if [ "$REMAINING" = "3" ]; then + pass "bc_prune keeps exactly 3 (got $REMAINING)" +else + fail "bc_prune kept $REMAINING (expected 3)" +fi +for keep in 5 6 7; do + [ -d "$MM_BUILD_CACHE_DIR/ios/prune-fp-${keep}.app" ] && pass "kept newest: prune-fp-${keep}" || fail "newest dropped: prune-fp-${keep}" +done + +# ─── 10. Preflight --mode plumbing ────────────────────────────────── +hdr "preflight --mode arg parsing" +out=$(bash scripts/perps/agentic/preflight.sh --mode invalid --check-only 2>&1 || true) +echo "$out" | grep -q "unknown --mode 'invalid'" && pass "unknown --mode rejected" || fail "unknown mode not rejected: $out" + +out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode fast --check-only 2>&1 | head -20 || true) +echo "$out" | grep -qE "Mode:.*fast.*no build" && pass "fast mode header rendered" || fail "fast mode header missing" + +out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode auto --check-only 2>&1 | head -20 || true) +echo "$out" | grep -qE "Mode:.*auto.*fingerprint-gated" && pass "auto mode header rendered" || fail "auto mode header missing" + +out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode rebuild-native --check-only 2>&1 | head -20 || true) +echo "$out" | grep -qE "Mode:.*rebuild-native" && pass "rebuild-native mode header rendered" || fail "rebuild-native mode header missing" + +out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --mode clean --check-only 2>&1 | head -20 || true) +echo "$out" | grep -qE "Mode:.*clean.*yarn setup" && pass "clean mode header rendered" || fail "clean mode header missing" + +# Legacy --clean still maps to clean mode (back-compat) +out=$(_capture_for 10 bash scripts/perps/agentic/preflight.sh --clean --check-only 2>&1 | head -20 || true) +echo "$out" | grep -qE "Mode:.*clean.*yarn setup" && pass "legacy --clean still maps to clean" || fail "legacy --clean broken" + +# ─── 11. Memo cleanup refuses inherited / unowned BC_MEMO_DIR ────── +# Across R6/R7/R8/R9 codex flagged five attack shapes against the memo +# directory cleanup. Each scenario sets up a "victim" dir, hands its path +# to a child shell via env, runs a code path that previously deleted the +# dir, and asserts the dir + its contents survive. +hdr "memo cleanup refuses inherited / unowned BC_MEMO_DIR" +_memo_attack() { + local label="$1" extra_env="$2" sentinel="$3" body="$4" + local victim + victim=$(mktemp -d) + echo keep > "$victim/please-keep-me" + [ "$sentinel" = "yes" ] && : > "$victim/.bc_memo_owner" + env BC_MEMO_DIR="$victim" $extra_env bash -c ". scripts/perps/agentic/lib/build-cache.sh; $body" >/dev/null 2>&1 || true + if [ -d "$victim" ] && [ -f "$victim/please-keep-me" ]; then + pass "$label" + else + fail "$label — victim dir was deleted" + fi + rm -rf "$victim" +} +# Five attack shapes — must all preserve the victim: +_memo_attack "R6: plain inherited dir + reset_memo" "" no "bc_fingerprint_reset_memo" +_memo_attack "R7: forged on-disk sentinel + reset_memo" "" yes "bc_fingerprint_reset_memo" +_memo_attack "R8: forged env BC_MEMO_DIR_OWNED=1" "BC_MEMO_DIR_OWNED=1" no "bc_fingerprint_reset_memo" +_memo_attack "R9A: direct bc_memo_init + bc_memo_cleanup" "BC_MEMO_DIR_OWNED=1" no "bc_memo_init; bc_memo_cleanup" +_memo_attack "R9B: EXIT cleanup on inherited memo" "" no "cleanup(){ bc_memo_cleanup; }; trap cleanup EXIT; false" + +# ─── 12. Fast-mode strictness when fingerprint cannot be computed ─── +# Codex R2 B3: --mode fast must hard-fail if the fingerprint command can't +# run, instead of silently falling through to the legacy build path. +hdr "preflight --mode fast / fingerprint failure" +FP_SCRIPT="scripts/generate-fingerprint.js" +FP_BACKUP="${FP_SCRIPT}.test-bak-$$" +mv "$FP_SCRIPT" "$FP_BACKUP" +restore_fp() { [ -f "$FP_BACKUP" ] && mv "$FP_BACKUP" "$FP_SCRIPT" 2>/dev/null || true; } +# Augment trap so we restore even on test failure. +trap ' + rm -rf "$MM_BUILD_CACHE_DIR" .agent/build-cache 2>/dev/null || true + if [ -n "$SIDE_BACKUP" ] && [ -d "$SIDE_BACKUP" ]; then + mv "$SIDE_BACKUP" .agent/build-cache + fi + restore_fp +' EXIT + +out=$(_capture_for 20 bash scripts/perps/agentic/preflight.sh --mode fast --platform ios --no-launch 2>&1 || true) +restore_fp +echo "$out" | grep -q "Mode 'fast': could not compute fingerprint" \ + && pass "--mode fast fails loud when fingerprint cannot be computed" \ + || fail "--mode fast did not fail loud on fingerprint failure: $(echo "$out" | tail -5)" + +echo "" +if [ "$FAILED" -eq 0 ]; then + printf "\033[1;32m=== ALL TESTS PASSED ===\033[0m\n" + exit 0 +else + printf "\033[1;31m=== TESTS FAILED ===\033[0m\n" + exit 1 +fi diff --git a/scripts/perps/agentic/lib/test-preflight-cache-e2e.sh b/scripts/perps/agentic/lib/test-preflight-cache-e2e.sh new file mode 100644 index 00000000000..2c981b23a98 --- /dev/null +++ b/scripts/perps/agentic/lib/test-preflight-cache-e2e.sh @@ -0,0 +1,125 @@ +#!/bin/bash +# Read-only e2e for preflight --mode auto cache-hit Path 1. +# +# Plants a synthetic installed.json claiming the current fingerprint is +# already installed on the booted simulator, then runs preflight and +# verifies it logs "Cache: installed app matches fingerprint" and skips +# the native build branch. Does NOT uninstall, modify, or rebuild anything +# on the sim — purely tests that the decision branch fires. +# +# Idempotent: stashes/restores any pre-existing .agent/build-cache. +# Requires: a booted iOS simulator with MetaMask already installed. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)" +cd "$REPO_ROOT" + +# Find a booted simulator that already has MetaMask installed. Iterate all +# booted sims so the test works regardless of which one .js.env points at. +BOOTED_UDID="" +BOOTED_NAME="" +while IFS= read -r line; do + udid=$(echo "$line" | awk -F'[()]' '{print $2}') + name=$(echo "$line" | sed -E 's/^[[:space:]]*//; s/ \(.*//') + if [ -n "$udid" ] && xcrun simctl listapps "$udid" 2>/dev/null | grep -q "io.metamask.MetaMask"; then + BOOTED_UDID="$udid" + BOOTED_NAME="$name" + break + fi +done < <(xcrun simctl list devices 2>/dev/null | grep "Booted") + +if [ -z "$BOOTED_UDID" ]; then + echo "SKIP: no booted iOS simulator with MetaMask installed — run 'yarn a:setup:ios' first" >&2 + exit 0 +fi +echo "Booted sim: $BOOTED_NAME ($BOOTED_UDID) — MetaMask present" + +# Override .js.env sim selection so preflight inspects the right sim. +export IOS_SIMULATOR="$BOOTED_NAME" +export SIM_UDID="$BOOTED_UDID" + +export MM_BUILD_CACHE_DIR="/tmp/mm-bc-e2e-$$" +rm -rf "$MM_BUILD_CACHE_DIR" + +SIDE_BACKUP="" +if [ -d .agent/build-cache ]; then + SIDE_BACKUP="/tmp/mm-bc-e2e-sidecar-$$" + mv .agent/build-cache "$SIDE_BACKUP" +fi +PIDFILE_BACKUP="" +if [ -f .agent/metro.pid ]; then + PIDFILE_BACKUP="/tmp/mm-bc-e2e-metropid-$$" + cp .agent/metro.pid "$PIDFILE_BACKUP" +fi +cleanup() { + rm -rf "$MM_BUILD_CACHE_DIR" 2>/dev/null || true + rm -rf .agent/build-cache 2>/dev/null || true + [ -n "$SIDE_BACKUP" ] && [ -d "$SIDE_BACKUP" ] && mv "$SIDE_BACKUP" .agent/build-cache + [ -n "$PIDFILE_BACKUP" ] && [ -f "$PIDFILE_BACKUP" ] && mv "$PIDFILE_BACKUP" .agent/metro.pid +} +trap cleanup EXIT + +# shellcheck disable=SC1091 +. scripts/perps/agentic/lib/build-cache.sh + +FP=$(bc_fingerprint) +echo "Current fingerprint: ${FP:0:16}..." + +FAILED=0 +pass() { printf " \033[32mPASS\033[0m %s\n" "$1"; } +fail() { printf " \033[31mFAIL\033[0m %s\n" "$1"; FAILED=1; } + +printf "\n\033[1m== Path 1: installed.json matches current fingerprint ==\033[0m\n" +mkdir -p .agent/build-cache/ios +bc_record_install ios "$FP" "$BOOTED_UDID" + +LOG="/tmp/mm-bc-e2e-log-$$" +set +e +# Run preflight in the background; watchdog below kills it after 45s OR as +# soon as the cache-decision marker appears. Avoids the GNU `timeout` binary +# which is not in base macOS. +bash scripts/perps/agentic/preflight.sh --mode auto --platform ios --no-launch > "$LOG" 2>&1 & +PID=$! +( sleep 45 && kill "$PID" 2>/dev/null ) & +WATCHDOG=$! +for _ in $(seq 1 45); do + if grep -q "Cache: installed app matches fingerprint" "$LOG" 2>/dev/null; then break; fi + if ! kill -0 "$PID" 2>/dev/null; then break; fi + sleep 1 +done +kill "$PID" 2>/dev/null || true +wait "$PID" 2>/dev/null || true +kill "$WATCHDOG" 2>/dev/null || true +wait "$WATCHDOG" 2>/dev/null || true +set -e + +if grep -q "Cache: installed app matches fingerprint ${FP:0:12}" "$LOG"; then + pass "preflight recognized installed-app fp match" +else + fail "expected 'Cache: installed app matches fingerprint ${FP:0:12}' in log:" + tail -50 "$LOG" | sed 's/^/ /' +fi + +if grep -qE "Running pod install|expo run:ios|Building \+ installing app" "$LOG"; then + fail "preflight unexpectedly entered the build branch" +else + pass "build branch was skipped (no pod/xcodebuild)" +fi + +# Verify app is still present (we didn't break the sim). +POST_COUNT=$(xcrun simctl listapps "$BOOTED_UDID" 2>/dev/null | grep -c "io.metamask.MetaMask" || true) +if [ "$POST_COUNT" -gt 0 ]; then + pass "MetaMask still installed on sim post-test (no destructive ops)" +else + fail "MetaMask vanished from sim — test should have been read-only" +fi +rm -f "$LOG" + +echo "" +if [ "$FAILED" -eq 0 ]; then + printf "\033[1;32m=== E2E PATH 1 TEST PASSED ===\033[0m\n" + exit 0 +else + printf "\033[1;31m=== E2E TEST FAILED ===\033[0m\n" + exit 1 +fi diff --git a/scripts/perps/agentic/preflight.sh b/scripts/perps/agentic/preflight.sh index 26cc5ecb390..c688afe9bd4 100755 --- a/scripts/perps/agentic/preflight.sh +++ b/scripts/perps/agentic/preflight.sh @@ -77,10 +77,13 @@ DO_WALLET_SETUP=false WALLET_FIXTURE="${WALLET_FIXTURE:-.agent/wallet-fixture.json}" WALLET_PW="${MM_WALLET_PASSWORD:-}" FORCE_PLATFORM="" +MODE="" # auto | fast | rebuild-native | clean — resolved below +MODE_EXPLICIT=false while [[ $# -gt 0 ]]; do case "$1" in --platform) FORCE_PLATFORM="$2"; shift 2 ;; + --mode) MODE="$2"; MODE_EXPLICIT=true; shift 2 ;; --rebuild) DO_REBUILD=true; shift ;; --clean) DO_CLEAN=true; DO_REBUILD=true; shift ;; --no-launch) DO_LAUNCH=false; shift ;; @@ -92,6 +95,52 @@ while [[ $# -gt 0 ]]; do esac done +# ── Resolve --mode → existing flag state ───────────────────────────── +# If --mode not given, fall back to legacy flag mapping for back-compat. +if ! $MODE_EXPLICIT; then + if $DO_CLEAN; then + MODE="clean" + elif $DO_REBUILD; then + MODE="rebuild-native" + else + MODE="default" # legacy: skip build if app installed, otherwise build + fi +fi +case "$MODE" in + auto) + DO_CLEAN=false; DO_REBUILD=false + ;; + fast) + DO_CLEAN=false; DO_REBUILD=false + ;; + rebuild-native) + DO_CLEAN=false; DO_REBUILD=true + ;; + clean) + DO_CLEAN=true; DO_REBUILD=true + ;; + default) + : # keep parsed flag state + ;; + *) + echo "ERROR: unknown --mode '$MODE' (expected: auto|fast|rebuild-native|clean)" >&2 + exit 2 + ;; +esac + +# Source the build-cache helpers (no-op if file missing — fall back to legacy). +BUILD_CACHE_LIB="$(dirname "$0")/lib/build-cache.sh" +if [ -f "$BUILD_CACHE_LIB" ]; then + # shellcheck disable=SC1090 + . "$BUILD_CACHE_LIB" + BUILD_CACHE_ENABLED=true + # Allocate private mktemp memo dir, exported so $(bc_fingerprint) subshells + # inherit it. No EXIT trap here — lock helpers need EXIT; dir is OS-reaped. + bc_fingerprint_reset_memo +else + BUILD_CACHE_ENABLED=false +fi + # ── Platform detection ───────────────────────────────────────────── detect_platform() { if [ -n "$FORCE_PLATFORM" ]; then echo "$FORCE_PLATFORM"; return; fi @@ -296,15 +345,15 @@ step() { echo "" echo -e "${BOLD}=== MetaMask Mobile Preflight ===${NC}" echo -e " Port: $PORT | Platform: $PLAT" -if $DO_CLEAN; then - echo -e " Mode: ${YELLOW}clean${NC} (yarn setup → build → Metro → CDP → wallet)" -elif $DO_REBUILD; then - echo -e " Mode: ${YELLOW}rebuild${NC} (build → Metro → CDP)" -elif $CHECK_ONLY; then - echo -e " Mode: check-only" -else - echo -e " Mode: default (Metro → CDP)" -fi +case "$MODE" in + auto) echo -e " Mode: ${BLUE}auto${NC} (fingerprint-gated reuse, build only if needed)" ;; + fast) echo -e " Mode: ${BLUE}fast${NC} (no build — fail loud if app missing)" ;; + rebuild-native) echo -e " Mode: ${YELLOW}rebuild-native${NC} (skip yarn setup, force native rebuild)" ;; + clean) echo -e " Mode: ${YELLOW}clean${NC} (yarn setup → pod --repo-update → build)" ;; + default) $CHECK_ONLY \ + && echo -e " Mode: check-only" \ + || echo -e " Mode: default (fingerprint-gated reuse; falls back to native build on cache miss, no fail-loud)" ;; +esac # ── Zombie sweep (silent when clean) ───────────────────────────────── # Detect and clean up orphaned expo/metro processes from previous crashed runs. @@ -352,6 +401,11 @@ sweep_port "$PORT" "worktree Metro" [ "$PORT" != "8081" ] && sweep_port 8081 "expo default" # ── Step: yarn setup (clean only) ──────────────────────────────────── +# --check-only is read-only by contract; refuse a destructive yarn setup +# combo loudly instead of running it briefly and then early-exiting. +if $DO_CLEAN && $CHECK_ONLY; then + fail "--check-only conflicts with --clean / --mode clean (would mutate node_modules + build artifacts)" +fi if $DO_CLEAN; then if [ "$PLAT" = "ios" ]; then step "Installing dependencies" "rm ios/build → yarn setup (install deps + patches + pods)" @@ -410,6 +464,76 @@ if [ "$PLAT" = "ios" ]; then # ── Step: App build / install ──────────────────────────────────── step "Checking app" "Looking for $BUNDLE_ID on simulator" APP_INSTALLED=$(xcrun simctl listapps "$SIM_TARGET" 2>/dev/null | grep -c "$BUNDLE_ID" || true) + BC_LOCK_HELD=false # set to true once we own the per-fingerprint build lock + + # Cache validation runs in every mode except `clean` / `rebuild-native`, + # which intentionally bypass the cache. This is a deliberate behaviour + # change vs origin/main: default mode now opts into fingerprint-gated reuse. + if $BUILD_CACHE_ENABLED && [ "$MODE" != "clean" ] && [ "$MODE" != "rebuild-native" ]; then + FP=$(bc_fingerprint 2>/dev/null || true) + if [ -n "$FP" ]; then + INSTALLED_FP=$(bc_installed_fp ios) + INSTALLED_TGT=$(bc_installed_target ios) + if [ "$APP_INSTALLED" -gt 0 ] \ + && [ "$INSTALLED_FP" = "$FP" ] \ + && [ "$INSTALLED_TGT" = "$SIM_TARGET" ] \ + && ! $DO_REBUILD; then + ok "Cache: installed app matches fingerprint ${FP:0:12} on $SIM_TARGET — no native action needed" + CHECK_ONLY_FP_VERIFIED=true + CHECK_ONLY_FP_VALUE="$FP" + else + if bc_lock_acquire ios "$FP"; then + BC_LOCK_HELD=true + trap 'bc_lock_release' EXIT + if bc_has_artifact ios "$FP"; then + if $CHECK_ONLY; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fail "App not at fingerprint ${FP:0:12} on $SIM_TARGET — cache hit available, but --check-only forbids install" + fi + echo -e " ${GREEN}Cache hit:${NC} fp=${FP:0:12} — installing from shared cache" + IOS_ARTIFACT=$(bc_artifact_path ios "$FP") + # `simctl install` overwrites the .app bundle in place; it keeps + # the existing container data (wallet/app state), so no preemptive + # uninstall is needed on the happy path. If install fails we + # explicitly reset APP_INSTALLED to force the build branch. + if xcrun simctl install "$SIM_TARGET" "$IOS_ARTIFACT"; then + bc_record_install ios "$FP" "$SIM_TARGET" + APP_INSTALLED=1 + ok "Installed from cache: $IOS_ARTIFACT" + else + APP_INSTALLED=0 + if [ "$MODE" = "fast" ]; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fail "Mode 'fast': cached artifact install failed for fp ${FP:0:12}" + fi + warn "Cache install failed — falling through to native build" + fi + elif [ "$MODE" = "fast" ]; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fail "Mode 'fast' but no cached build for fp ${FP:0:12} and app not installed at this fingerprint on $SIM_TARGET" + else + # Cache miss in auto/default mode. Whatever is installed (if anything) + # is at the wrong fingerprint; force the build gate to fire so we + # produce + install a fresh artifact instead of running a stale app. + APP_INSTALLED=0 + fi + # Lock stays held through native build; post-build store releases it. + else + if [ "$MODE" = "fast" ]; then + fail "Mode 'fast': could not acquire build-cache lock for fp ${FP:0:12}" + fi + warn "Could not acquire build-cache lock for fp ${FP:0:12} — proceeding without lock" + APP_INSTALLED=0 # unknown cache state — treat installed app as untrusted + fi + fi + else + if [ "$MODE" = "fast" ]; then + fail "Mode 'fast': could not compute fingerprint — cannot validate cache availability" + fi + warn "Could not compute fingerprint — falling back to legacy build path" + fi + fi + if [ "$APP_INSTALLED" -eq 0 ] || $DO_REBUILD; then $CHECK_ONLY && fail "App not installed (run with --rebuild)" echo "" @@ -417,13 +541,31 @@ if [ "$PLAT" = "ios" ]; then echo -e " ${DIM}expo run:ios --port \$PORT (bundler killed after build, start-metro.sh takes over)${NC}" echo "" + # Skip --repo-update unless --mode clean: it re-pulls every CocoaPods + # spec (~3-5 min) on every dispatch. Plain `pod install` is sufficient + # whenever Podfile.lock pods are already present in the local spec repo. + if $DO_CLEAN; then + POD_CMD="cd ios && bundle exec pod install --repo-update --ansi" + else + POD_CMD="cd ios && bundle exec pod install --ansi" + fi echo " Running pod install via bundler..." stage_log "$POD_INSTALL_LOG" - printf '$ (cd ios && bundle exec pod install --repo-update --ansi)\n' > "$POD_INSTALL_LOG" - if run_with_live_log "$POD_INSTALL_LOG" "cd ios && bundle exec pod install --repo-update --ansi"; then + printf '$ (%s)\n' "$POD_CMD" > "$POD_INSTALL_LOG" + if run_with_live_log "$POD_INSTALL_LOG" "$POD_CMD"; then ok "pod install complete" else - warn "pod install had issues — see $POD_INSTALL_LOG" + # On non-clean modes, the failure may be a missing spec → retry once with --repo-update. + if ! $DO_CLEAN; then + warn "pod install failed — retrying with --repo-update" + if run_with_live_log "$POD_INSTALL_LOG" "cd ios && bundle exec pod install --repo-update --ansi"; then + ok "pod install complete (after --repo-update retry)" + else + warn "pod install had issues — see $POD_INSTALL_LOG" + fi + else + warn "pod install had issues — see $POD_INSTALL_LOG" + fi fi # Must pass --port (never --no-bundler): @expo/cli rejects that combo @@ -558,7 +700,36 @@ if [ "$PLAT" = "ios" ]; then fail "simctl install succeeded but app not found" fi ok "App built and installed" + + # Publish to shared cache. If we hold the lock from the cache-decision + # phase, store + release directly; else (clean/rebuild-native) bc_with_lock. + if $BUILD_CACHE_ENABLED && [ -n "${APP_PATH:-}" ]; then + FP=$(bc_fingerprint 2>/dev/null || true) + if [ -n "$FP" ]; then + if $BC_LOCK_HELD; then + if bc_store_artifact ios "$FP" "$APP_PATH"; then + ok "Stored build in shared cache: fp=${FP:0:12}" + bc_record_install ios "$FP" "$SIM_TARGET" + bc_prune ios "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true + else + warn "Failed to store build in cache" + fi + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + else + if bc_with_lock ios "$FP" bc_store_artifact ios "$FP" "$APP_PATH"; then + ok "Stored build in shared cache: fp=${FP:0:12}" + bc_record_install ios "$FP" "$SIM_TARGET" + bc_prune ios "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true + else + warn "Could not store build in cache (lock timeout?)" + fi + fi + fi + fi else + if $BC_LOCK_HELD; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fi ok "App already installed" fi @@ -603,18 +774,86 @@ else ok "Device connected: $DEVICE_NAME" fi - # Set up adb reverse so device can reach Metro on host - $ADB_CMD reverse tcp:$PORT tcp:$PORT 2>/dev/null || warn "adb reverse failed — device may not reach Metro" - ok "adb reverse tcp:$PORT → host" + # Set up adb reverse so device can reach Metro on host. + # Skipped in --check-only to preserve the read-only contract. + if ! $CHECK_ONLY; then + $ADB_CMD reverse tcp:$PORT tcp:$PORT 2>/dev/null || warn "adb reverse failed — device may not reach Metro" + ok "adb reverse tcp:$PORT → host" + fi # ── Step: App build / install ──────────────────────────────────── step "Checking app" "Looking for $PACKAGE_ID on device" APP_INSTALLED=$($ADB_CMD shell pm list packages 2>/dev/null | grep -c "$PACKAGE_ID" || true) + BC_LOCK_HELD=false # see iOS block for semantics + + # ── Build-cache lookup (auto/fast/default modes only) ──────────── + if $BUILD_CACHE_ENABLED && [ "$MODE" != "clean" ] && [ "$MODE" != "rebuild-native" ]; then + FP=$(bc_fingerprint 2>/dev/null || true) + if [ -n "$FP" ]; then + INSTALLED_FP=$(bc_installed_fp android) + INSTALLED_TGT=$(bc_installed_target android) + ADB_DEVICE_ID="${ADB_TARGET:-default}" + if [ "$APP_INSTALLED" -gt 0 ] \ + && [ "$INSTALLED_FP" = "$FP" ] \ + && [ "$INSTALLED_TGT" = "$ADB_DEVICE_ID" ] \ + && ! $DO_REBUILD; then + ok "Cache: installed app matches fingerprint ${FP:0:12} on $ADB_DEVICE_ID — no native action needed" + CHECK_ONLY_FP_VERIFIED=true + CHECK_ONLY_FP_VALUE="$FP" + else + if bc_lock_acquire android "$FP"; then + BC_LOCK_HELD=true + trap 'bc_lock_release' EXIT + if bc_has_artifact android "$FP"; then + if $CHECK_ONLY; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fail "App not at fingerprint ${FP:0:12} on $ADB_DEVICE_ID — cache hit available, but --check-only forbids install" + fi + echo -e " ${GREEN}Cache hit:${NC} fp=${FP:0:12} — installing from shared cache" + ANDROID_ARTIFACT=$(bc_artifact_path android "$FP") + # `adb install -r` reinstalls keeping data; no preemptive uninstall. + if $ADB_CMD install -r "$ANDROID_ARTIFACT" 2>/dev/null; then + bc_record_install android "$FP" "$ADB_DEVICE_ID" + APP_INSTALLED=1 + ok "Installed from cache: $ANDROID_ARTIFACT" + else + APP_INSTALLED=0 + if [ "$MODE" = "fast" ]; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fail "Mode 'fast': cached artifact install failed for fp ${FP:0:12}" + fi + warn "Cache install failed — falling through to native build" + fi + elif [ "$MODE" = "fast" ]; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fail "Mode 'fast' but no cached build for fp ${FP:0:12} and app not installed at this fingerprint on $ADB_DEVICE_ID" + else + # Cache miss in auto/default mode. Stale app must not pass the build + # gate untouched; force a fresh build + install. + APP_INSTALLED=0 + fi + else + if [ "$MODE" = "fast" ]; then + fail "Mode 'fast': could not acquire build-cache lock for fp ${FP:0:12} — refusing to proceed without lock" + fi + warn "Could not acquire build-cache lock for fp ${FP:0:12} — proceeding without lock" + APP_INSTALLED=0 + fi + fi + else + if [ "$MODE" = "fast" ]; then + fail "Mode 'fast': could not compute fingerprint — cannot validate cache availability" + fi + warn "Could not compute fingerprint — falling back to legacy build path" + fi + fi + if [ "$APP_INSTALLED" -eq 0 ] || $DO_REBUILD; then $CHECK_ONLY && fail "App not installed (run with --rebuild)" - # Uninstall first for a clean slate (avoids stale data / vault) - if ($DO_CLEAN || $DO_WALLET_SETUP) && [ "$APP_INSTALLED" -gt 0 ]; then + # Uninstall for a clean slate. Re-query device since cache-miss zeroes + # APP_INSTALLED even when the app is still physically present. + if ($DO_CLEAN || $DO_WALLET_SETUP) && $ADB_CMD shell pm list packages 2>/dev/null | grep -q "$PACKAGE_ID"; then echo " Uninstalling previous app..." $ADB_CMD uninstall "$PACKAGE_ID" 2>/dev/null || true fi @@ -660,7 +899,37 @@ else fail "Build completed but app not found on device" fi ok "App built and installed" + + # Publish .apk to shared cache. If we still hold the per-fingerprint lock + # from the cache-decision phase, store directly; otherwise (clean/rebuild-native) + # acquire-and-release inline via bc_with_lock. + if $BUILD_CACHE_ENABLED && [ -n "${APK_PATH:-}" ]; then + FP=$(bc_fingerprint 2>/dev/null || true) + if [ -n "$FP" ]; then + if $BC_LOCK_HELD; then + if bc_store_artifact android "$FP" "$APK_PATH"; then + ok "Stored build in shared cache: fp=${FP:0:12}" + bc_record_install android "$FP" "${ADB_TARGET:-default}" + bc_prune android "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true + else + warn "Failed to store build in cache" + fi + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + else + if bc_with_lock android "$FP" bc_store_artifact android "$FP" "$APK_PATH"; then + ok "Stored build in shared cache: fp=${FP:0:12}" + bc_record_install android "$FP" "${ADB_TARGET:-default}" + bc_prune android "${BUILD_CACHE_RETAIN:-5}" 2>/dev/null || true + else + warn "Could not store build in cache (lock timeout?)" + fi + fi + fi + fi else + if $BC_LOCK_HELD; then + bc_lock_release; BC_LOCK_HELD=false; trap - EXIT + fi ok "App already installed" fi fi @@ -669,6 +938,20 @@ fi # ── Shared steps (both platforms) ──────────────────────────────────── # ══════════════════════════════════════════════════════════════════════ +# --check-only is read-only: probes above fail loud on mismatch; here we +# must not run Metro / CDP / wallet (all state-changing). +if $CHECK_ONLY; then + TOTAL_ELAPSED=$(elapsed_since $PREFLIGHT_START) + echo "" + echo -e "${GREEN}${BOLD}=== Preflight check-only passed ===${NC} ${DIM}(${TOTAL_ELAPSED}s)${NC}" + if ${CHECK_ONLY_FP_VERIFIED:-false}; then + echo -e " Platform ${DIM}$PLAT${NC} | App installed and verified at fingerprint ${DIM}${CHECK_ONLY_FP_VALUE:0:12}${NC}" + else + echo -e " Platform ${DIM}$PLAT${NC} | App installed (fingerprint not verified — cache disabled or fingerprint compute failed)" + fi + exit 0 +fi + # ── Step: Metro ───────────────────────────────────────────────────── step "Starting Metro" "Bundler on port $PORT → logs at $LOGFILE" stage_log "$LOGFILE" diff --git a/tests/api-mocking/mock-responses/defaults/user-storage.ts b/tests/api-mocking/mock-responses/defaults/user-storage.ts index e47247c2c0f..3544521f156 100644 --- a/tests/api-mocking/mock-responses/defaults/user-storage.ts +++ b/tests/api-mocking/mock-responses/defaults/user-storage.ts @@ -28,8 +28,9 @@ const notificationPreferences = { }, ], }, + // Feature announcements are controlled by marketing in-app preferences. marketing: { - inAppNotificationsEnabled: false, + inAppNotificationsEnabled: true, pushNotificationsEnabled: false, }, perps: { diff --git a/tests/component-view/presets/notifications.ts b/tests/component-view/presets/notifications.ts index a81662a4f32..687f6e37f7a 100644 --- a/tests/component-view/presets/notifications.ts +++ b/tests/component-view/presets/notifications.ts @@ -42,6 +42,7 @@ interface NotificationsPresetOptions { notificationsEnabled?: boolean; featureAnnouncementsEnabled?: boolean; pushEnabled?: boolean; + socialLeaderboardEnabled?: boolean; notifications?: typeof MOCK_NOTIFICATIONS; } @@ -62,6 +63,7 @@ export function buildNotificationsState( notificationsEnabled = true, featureAnnouncementsEnabled = true, pushEnabled = true, + socialLeaderboardEnabled = false, notifications = MOCK_NOTIFICATIONS, } = options; @@ -93,6 +95,10 @@ export function buildNotificationsState( RemoteFeatureFlagController: { remoteFeatureFlags: { assetsNotificationsEnabled: true, + aiSocialLeaderboardEnabled: { + enabled: socialLeaderboardEnabled, + minimumVersion: '0.0.1', + }, }, }, AccountsController: { diff --git a/yarn.lock b/yarn.lock index adf2d01fdb5..bb19755c0fe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9275,9 +9275,9 @@ __metadata: languageName: node linkType: hard -"@metamask/notification-services-controller@npm:^24.0.0": - version: 24.1.0 - resolution: "@metamask/notification-services-controller@npm:24.1.0" +"@metamask/notification-services-controller@npm:24.1.1": + version: 24.1.1 + resolution: "@metamask/notification-services-controller@npm:24.1.1" dependencies: "@contentful/rich-text-html-renderer": "npm:^16.5.2" "@metamask/authenticated-user-storage": "npm:^2.0.0" @@ -9293,7 +9293,7 @@ __metadata: loglevel: "npm:^1.8.1" semver: "npm:^7.6.3" uuid: "npm:^8.3.2" - checksum: 10/c8bcfcc7e9178eee8789ef4417636ac583e597639596c85eac82e5703c5d59fb8e4762e0d1fe71005320bedb11ea5893431ee540f0d268bcecf10bb226d7af33 + checksum: 10/8f5249dea67dc7168c9254fde1de81e24483f9b054ba1ac282c36849e2090515f0cff0ed7cd4beaf6aefed6382f966277e02d3259f69aea67dee1c84c2ec7400 languageName: node linkType: hard @@ -35288,7 +35288,7 @@ __metadata: "@metamask/native-utils": "npm:^0.8.0" "@metamask/network-controller": "npm:^31.0.0" "@metamask/network-enablement-controller": "npm:^5.1.0" - "@metamask/notification-services-controller": "npm:^24.0.0" + "@metamask/notification-services-controller": "npm:24.1.1" "@metamask/object-multiplex": "npm:^1.1.0" "@metamask/permission-controller": "npm:^13.1.1" "@metamask/phishing-controller": "npm:^17.1.1"