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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 181 additions & 1 deletion app/components/UI/Perps/utils/e2eBridgePerps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
import DevLogger from '../../../../core/SDKConnect/utils/DevLogger';
import { isE2E } from '../../../../util/test/utils';
import { Linking } from 'react-native';
import axios, { AxiosResponse } from 'axios';
import {
getCommandQueueServerPort,
getLocalHost,
} from '../../../../../e2e/framework/fixtures/FixtureUtils';

// Global bridge for E2E mock injection
export interface E2EBridgePerpsStreaming {
Expand All @@ -23,10 +28,18 @@ let hasRegisteredDeepLinkHandler = false;
// Track processed URLs to avoid duplicate handling when both initial URL and event fire
const processedDeepLinks = new Set<string>();

// E2E HTTP polling state
let hasStartedPolling = false;
let pollTimeout: ReturnType<typeof setTimeout> | null = null;
let consecutivePollFailures = 0;
let pollingDisabled = false;
const MAX_CONSECUTIVE_POLL_FAILURES = 2;

/**
* Register a lightweight deep link handler for E2E-only schema (e2e://perps/*)
* This avoids touching production deeplink parsing while enabling deterministic
* E2E commands like price push and forced liquidation.
* !! TODO: E2E perps deeplink handler can be later removed if HTTP polling is stable !!
*/
function registerE2EPerpsDeepLinkHandler(): void {
if (hasRegisteredDeepLinkHandler || !isE2E) {
Expand Down Expand Up @@ -121,6 +134,167 @@ function registerE2EPerpsDeepLinkHandler(): void {
}
}

/**
* E2E-only: Poll external command API to apply mock updates
* Avoids deep links; relies on tests posting commands to a standalone service
*
* @returns void
*/
function startE2EPerpsCommandPolling(): void {
if (!isE2E || hasStartedPolling) {
return;
}

hasStartedPolling = true;

const pollIntervalMs = Number(process.env.E2E_POLL_INTERVAL_MS || 2000);
const host = getLocalHost();
const port = getCommandQueueServerPort();
// Change isDebug while developing E2E for Perps to avoid emptying out the queue
const isDebug = false;
const baseUrl = isDebug
? `http://${host}:${port}/debug.json`
: `http://${host}:${port}/queue.json`;
const FETCH_TIMEOUT = 40000; // Timeout in milliseconds

function scheduleNext(delay: number): void {
if (!isE2E || pollingDisabled) return;
if (pollTimeout) clearTimeout(pollTimeout);
pollTimeout = setTimeout(pollOnce, delay);
}

async function pollOnce(): Promise<void> {
try {
// Lazy require to keep bridge tree-shakeable in prod and avoid ESM import in Jest
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
const mod = require('../../../../../e2e/controller-mocking/mock-responses/perps/perps-e2e-mocks');
const service = mod?.PerpsE2EMockService?.getInstance?.();
/* eslint-enable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */
if (!service) {
scheduleNext(pollIntervalMs);
return;
}

DevLogger.log('[E2E Perps Bridge - HTTP Polling] Poll URL', baseUrl);

const response = await new Promise<AxiosResponse>((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('Request timeout'));
}, FETCH_TIMEOUT);

axios
.get(baseUrl)
.then((res) => {
clearTimeout(timeoutId);
resolve(res);
})
.catch((error) => {
clearTimeout(timeoutId);
reject(error);
});
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Timeout Race Condition Causes Promise Rejection

The manual timeout in pollOnce has a race condition. The setTimeout callback isn't cleared when the axios request completes, which can lead to it calling reject() on an already-settled promise. This results in unhandled promise rejections.

Fix in Cursor Fix in Web


if ((response as AxiosResponse).status !== 200) {
DevLogger.log(
'[E2E Perps Bridge - HTTP Polling] Poll non-200',
(response as AxiosResponse).status,
);
consecutivePollFailures += 1;
if (consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
pollingDisabled = true;
DevLogger.log(
'[E2E Perps Bridge - HTTP Polling] Disabling polling due to repeated non-200 responses',
);
return;
}
scheduleNext(pollIntervalMs);
return;
}

const data = ((await response) as AxiosResponse).data?.queue as
| (
| {
type: 'push-price';
symbol: string;
price: string | number;
}
| {
type: 'force-liquidation';
symbol: string;
}
| {
type: 'mock-deposit';
amount: string;
}
)[]
| null
| undefined;

if (!Array.isArray(data) || data.length === 0) {
consecutivePollFailures = 0;
scheduleNext(pollIntervalMs);
return;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Polling Code Fails to Extract Command Queue

The polling code expects response.data to be an array of commands, but the CommandQueueServer returns { queue: [...] }. The code attempts to check Array.isArray(data) on line 222, but data is actually an object with a queue property containing the array. This will cause all commands to be skipped because the array check will always fail. The fix should either:

  1. Change line 203 to extract the queue: const data = ((await response) as AxiosResponse).data?.queue as ...
  2. Or change line 222 to: if (!Array.isArray(data?.queue) || data.queue.length === 0)

Fix in Cursor Fix in Web


consecutivePollFailures = 0;
DevLogger.log('[E2E Perps Bridge - HTTP Polling] Poll data', data);

for (const item of data) {
if (!item || typeof item !== 'object') continue;
if (item.type === 'push-price') {
const sym = (item as { symbol: string }).symbol;
const price = String((item as { price: string | number }).price);
try {
if (typeof service.mockPushPrice === 'function') {
service.mockPushPrice(sym, price);
}
} catch (e) {
// no-op
}
} else if (item.type === 'force-liquidation') {
const sym = (item as { symbol: string }).symbol;
try {
if (typeof service.mockForceLiquidation === 'function') {
service.mockForceLiquidation(sym);
}
} catch (e) {
// no-op
}
} else if (item.type === 'mock-deposit') {
const amount = (item as { amount: string }).amount;
try {
if (typeof service.mockDepositUSD === 'function') {
service.mockDepositUSD(amount);
}
} catch (e) {
// no-op
}
}

// no cursor handling
}

scheduleNext(0);
} catch (err) {
DevLogger.log('[E2E Perps Bridge - HTTP Polling] Poll error', err);
consecutivePollFailures += 1;
if (consecutivePollFailures >= MAX_CONSECUTIVE_POLL_FAILURES) {
pollingDisabled = true;
DevLogger.log(
'[E2E Perps Bridge - HTTP Polling] Disabling polling due to repeated errors',
);
return;
}
scheduleNext(pollIntervalMs);
}
}

DevLogger.log(
'[E2E Perps Bridge - HTTP Polling] Starting E2E perps HTTP polling',
);
scheduleNext(0);
}

/**
* Auto-configure E2E bridge when isE2E is true
*/
Expand Down Expand Up @@ -158,9 +332,15 @@ function autoConfigureE2EBridge(): void {
};

// Register E2E deep link handler for price/liq commands
// TODO: E2E perps deeplink handler can be later removed if HTTP polling is stable
registerE2EPerpsDeepLinkHandler();

DevLogger.log('E2E Bridge auto-configured successfully');
// Start E2E HTTP polling for commands (replaces deeplink handler)
startE2EPerpsCommandPolling();

DevLogger.log(
'[E2E Perps Bridge - HTTP Polling] E2E Bridge auto-configured successfully',
);
DevLogger.log('Mock state:', {
accountBalance: mockService.getMockAccountState().availableBalance,
positionsCount: mockService.getMockPositions().length,
Expand Down
18 changes: 17 additions & 1 deletion e2e/framework/fixtures/CommandQueueServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ const logger = createLogger({
name: 'CommandQueueServer',
});

interface CommandQueueItem {
/**
* The command queue item to add to the command queue server
*
* @param type - The type of command to add to the command queue
* @param args - The arguments to add to the command queue
*/
export interface CommandQueueItem {
type: CommandType;
args: Record<string, unknown>;
}
Expand Down Expand Up @@ -37,6 +43,12 @@ class CommandQueueServer {
queue: newQueue,
};
}

if (this._isDebugRequest(ctx)) {
ctx.body = {
queue: this._queue,
};
}
});
}

Expand Down Expand Up @@ -91,6 +103,10 @@ class CommandQueueServer {
private _isQueueRequest(ctx: Context) {
return ctx.method === 'GET' && ctx.path === '/queue.json';
}

private _isDebugRequest(ctx: Context) {
return ctx.method === 'GET' && ctx.path === '/debug.json';
}
}

export default CommandQueueServer;
69 changes: 68 additions & 1 deletion e2e/specs/perps/helpers/perps-modifiers.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import { openE2EUrl } from '../../../framework/DeepLink';
import { E2EDeeplinkSchemes } from '../../../framework/Constants';
import { createLogger } from '../../../framework/logger';
import CommandQueueServer, {
CommandQueueItem,
} from '../../../framework/fixtures/CommandQueueServer';
import { PerpsModifiersCommandTypes } from '../../../framework/types';

const logger = createLogger({
name: 'PerpsE2EModifiers',
});

class PerpsE2EModifiers {
static async updateMarketPrice(symbol: string, price: string): Promise<void> {
Expand All @@ -20,9 +29,67 @@ class PerpsE2EModifiers {

static async applyDepositUSD(amount: string): Promise<void> {
await openE2EUrl(
`${E2EDeeplinkSchemes.PERPS}mock-deposit?amount=${encodeURIComponent(amount)}`,
`${E2EDeeplinkSchemes.PERPS}mock-deposit?amount=${encodeURIComponent(
amount,
)}`,
);
}

/**
*
* @param commandQueueServer - The command queue server to add the command to
* @param symbol - The symbol to update the price for
* @param price - The price to update the symbol to
* @returns void
*/
static async updateMarketPriceServer(
commandQueueServer: CommandQueueServer,
symbol: string,
price: string,
): Promise<void> {
logger.debug('Updating market price for symbol', symbol, 'to price', price);
const command: CommandQueueItem = {
type: PerpsModifiersCommandTypes.pushPrice,
args: { symbol, price },
};
await commandQueueServer.addToQueue(command);
}

/**
*
* @param commandQueueServer - The command queue server to add the command to
* @param symbol - The symbol to trigger the liquidation for
* @returns void
*/
static async triggerLiquidationServer(
commandQueueServer: CommandQueueServer,
symbol: string,
): Promise<void> {
logger.debug('Triggering liquidation for symbol', symbol);
const command: CommandQueueItem = {
type: PerpsModifiersCommandTypes.forceLiquidation,
args: { symbol },
};
await commandQueueServer.addToQueue(command);
}

/**
*
* @param commandQueueServer - The command queue server to add the command to
* @param amount - The amount to apply the deposit for
* @returns void
*/
static async applyDepositUSDServer(
commandQueueServer: CommandQueueServer,
amount: string,
): Promise<void> {
logger.debug('Applying deposit USD for amount', amount);
const command: CommandQueueItem = {
type: PerpsModifiersCommandTypes.mockDeposit,
args: { amount },
};
await commandQueueServer.addToQueue(command);
}
}

export default PerpsE2EModifiers;
9 changes: 6 additions & 3 deletions e2e/specs/perps/perps-add-funds.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { withFixtures } from '../../framework/fixtures/FixtureHelper';
import FixtureBuilder from '../../framework/fixtures/FixtureBuilder';
import { LocalNodeType } from '../../framework/types';
import { LocalNodeType, TestSuiteParams } from '../../framework/types';
import { Hardfork } from '../../seeder/anvil-manager';
import { SmokeTrade } from '../../tags';
import { loginToApp } from '../../viewHelper';
Expand Down Expand Up @@ -61,7 +61,10 @@ describe(SmokeTrade('Perps - Add funds (has funds, not first time)'), () => {
},
],
},
async () => {
async ({ commandQueueServer }: TestSuiteParams) => {
if (!commandQueueServer) {
throw new Error('Command queue server not found');
}
await loginToApp();
await device.disableSynchronization();
await Assertions.expectElementToBeVisible(
Expand Down Expand Up @@ -106,7 +109,7 @@ describe(SmokeTrade('Perps - Add funds (has funds, not first time)'), () => {

await PerpsDepositProcessingView.expectProcessingVisible();
// Apply deposit mock and verify balance update
await PerpsE2EModifiers.applyDepositUSD('80');
await PerpsE2EModifiers.applyDepositUSDServer(commandQueueServer, '80');
logger.info('🔥 E2E Mock: Deposit applied');
await Utilities.executeWithRetry(
async () => {
Expand Down
Loading