From 73d1932f599dc33471e151488339d94c11c9f359 Mon Sep 17 00:00:00 2001 From: Jorge Galat Date: Mon, 25 Sep 2023 17:34:56 -0300 Subject: [PATCH] =?UTF-8?q?=E2=9C=85e2e:=20add=20leverage=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/Leverager/AssetInput/index.tsx | 1 - components/Leverager/AssetSelector/index.tsx | 20 +- components/Leverager/Modal/index.tsx | 2 + .../Leverager/MultiplierSlider/index.tsx | 21 +- components/Leverager/NetPosition/index.tsx | 8 +- components/Leverager/Operation/index.tsx | 11 +- components/Leverager/Summary/index.tsx | 2 + components/asset/AssetOption/index.tsx | 3 + .../strategies/StrategiesContent/index.tsx | 6 +- e2e/common/allowance.ts | 42 ++++ e2e/common/balance.ts | 4 +- e2e/components/leverage.ts | 189 ++++++++++++++++++ e2e/specs/6-rollover/usdc.spec.ts | 2 - e2e/specs/7-leverage/usdc.spec.ts | 167 ++++++++++++++++ e2e/utils/contracts.ts | 10 +- playwright.config.ts | 2 +- 16 files changed, 469 insertions(+), 21 deletions(-) create mode 100644 e2e/common/allowance.ts create mode 100644 e2e/components/leverage.ts create mode 100644 e2e/specs/7-leverage/usdc.spec.ts diff --git a/components/Leverager/AssetInput/index.tsx b/components/Leverager/AssetInput/index.tsx index 50e9ec326..ba2c78559 100644 --- a/components/Leverager/AssetInput/index.tsx +++ b/components/Leverager/AssetInput/index.tsx @@ -56,7 +56,6 @@ function AssetInput({ symbol }: Props) { fontWeight: 600, fontSize: 12, }} - data-testid="modal-on-max" disabled={!available || !parseFloat(available)} > Max diff --git a/components/Leverager/AssetSelector/index.tsx b/components/Leverager/AssetSelector/index.tsx index 3f874fb89..ebe61d13b 100644 --- a/components/Leverager/AssetSelector/index.tsx +++ b/components/Leverager/AssetSelector/index.tsx @@ -14,9 +14,17 @@ type AssetSelectorProps = { options: AssetSelectorOption[]; onChange: (newValue: string) => void; disabled?: boolean; + 'data-testid'?: string; }; -const AssetSelector: FC = ({ title, currentValue, options, onChange, disabled = false }) => { +const AssetSelector: FC = ({ + title, + currentValue, + options, + onChange, + 'data-testid': testId, + disabled = false, +}) => { return ( = ({ title, currentValue, options, o ) } renderOption={({ symbol, value }: AssetSelectorOption) => ( - + )} - data-testid="modal-asset-selector" + data-testid={testId} /> ); }; diff --git a/components/Leverager/Modal/index.tsx b/components/Leverager/Modal/index.tsx index 9e154521d..3953cf57b 100644 --- a/components/Leverager/Modal/index.tsx +++ b/components/Leverager/Modal/index.tsx @@ -54,6 +54,7 @@ function LeveragerModal({ isOpen, close }: Props) { return ( diff --git a/components/Leverager/MultiplierSlider/index.tsx b/components/Leverager/MultiplierSlider/index.tsx index 0496a10fb..55c7f2254 100644 --- a/components/Leverager/MultiplierSlider/index.tsx +++ b/components/Leverager/MultiplierSlider/index.tsx @@ -53,14 +53,22 @@ const MultiplierSlider = () => { onClick={onClick} sx={{ cursor: 'pointer' }} > - {`${t( + {`${t( 'Current', ).toUpperCase()}:${currentLeverageRatio.toFixed(2)}x`} - 1x + setLeverageRatio(minLeverageRatio)} + sx={{ cursor: 'pointer' }} + data-testid="leverage-slider-min" + > + 1x + setLeverageRatio(value as number)} marks={currentMark} @@ -104,7 +112,14 @@ const MultiplierSlider = () => { }, }} /> - {t('Max')} + setLeverageRatio(max)} + sx={{ cursor: 'pointer' }} + data-testid="leverage-slider-max" + > + {t('Max')} + ); diff --git a/components/Leverager/NetPosition/index.tsx b/components/Leverager/NetPosition/index.tsx index 11e38f3c2..92dd1a9a2 100644 --- a/components/Leverager/NetPosition/index.tsx +++ b/components/Leverager/NetPosition/index.tsx @@ -14,10 +14,14 @@ const NetPosition = () => { const [expanded, setExpanded] = useState(false); return ( - + {input.collateralSymbol && ( - setExpanded((_expanded) => !_expanded)}> + setExpanded((_expanded) => !_expanded)} + data-testid="leverage-more-options-expand" + > {expanded ? : } diff --git a/components/Leverager/Operation/index.tsx b/components/Leverager/Operation/index.tsx index 528be02e9..63cdd3c5d 100644 --- a/components/Leverager/Operation/index.tsx +++ b/components/Leverager/Operation/index.tsx @@ -52,6 +52,7 @@ const Operation = () => { currentValue={input.collateralSymbol} options={collateralOptions} onChange={setCollateralSymbol} + data-testid="leverage-select-from" /> @@ -64,6 +65,7 @@ const Operation = () => { options={borrowOptions} onChange={setBorrowSymbol} disabled={!input.collateralSymbol} + data-testid="leverage-select-to" /> @@ -88,11 +90,16 @@ const Operation = () => { )} {disabledSubmit ? ( - ) : ( - ), @@ -28,7 +28,7 @@ function StrategiesContent() { description: t('Reduce your risk by decreasing your investment exposure and borrowing less.'), tags: ['advanced'], children: ( - ), @@ -40,7 +40,7 @@ function StrategiesContent() { ), tags: ['basic'], children: ( - ), diff --git a/e2e/common/allowance.ts b/e2e/common/allowance.ts new file mode 100644 index 000000000..852d2d344 --- /dev/null +++ b/e2e/common/allowance.ts @@ -0,0 +1,42 @@ +import { expect } from '@playwright/test'; +import { parseUnits } from 'viem'; +import type { Address, PublicClient } from 'viem'; + +import { erc20, type ERC20TokenSymbol, erc20Market } from '../utils/contracts'; +import { CommonTest } from './types'; + +export default function ({ test, publicClient }: CommonTest & { publicClient: PublicClient }) { + type AllowanceParams = { + address: Address; + type: 'erc20' | 'market'; + symbol: ERC20TokenSymbol; + spender: Address; + less: string; + }; + + const check = async ({ address, type, symbol, spender, less: _less }: AllowanceParams) => { + await test.step(`checks ${spender} allowance to be less than ${_less}`, async () => { + let decimals = 18; + let allowance = 2n ** 256n - 1n; + + switch (type) { + case 'erc20': { + const contract = await erc20(symbol, { publicClient }); + allowance = await contract.read.allowance([address, spender]); + decimals = await contract.read.decimals(); + break; + } + case 'market': { + const market = await erc20Market(symbol, { publicClient }); + allowance = await market.read.allowance([address, spender]); + decimals = await market.read.decimals(); + } + } + + const less = parseUnits(_less, decimals); + expect(allowance).toBeLessThanOrEqual(less); + }); + }; + + return { check }; +} diff --git a/e2e/common/balance.ts b/e2e/common/balance.ts index 82237d387..e21db745e 100644 --- a/e2e/common/balance.ts +++ b/e2e/common/balance.ts @@ -25,8 +25,8 @@ export default function ({ test, publicClient }: CommonTest & { publicClient: Pu const lower = (expected * (wad - delta)) / wad; const upper = (expected * (wad + delta)) / wad; - expect(balance > lower).toBe(true); - expect(balance < upper).toBe(true); + expect(balance).toBeGreaterThan(lower); + expect(balance).toBeLessThan(upper); } else { expect(balance).toBe(expected); } diff --git a/e2e/components/leverage.ts b/e2e/components/leverage.ts new file mode 100644 index 000000000..1f853efcd --- /dev/null +++ b/e2e/components/leverage.ts @@ -0,0 +1,189 @@ +import { type Page, expect } from '@playwright/test'; + +import type { ERC20TokenSymbol } from '../utils/contracts'; + +export default function (page: Page) { + const waitForStepToContinue = async () => { + await page.waitForFunction( + () => { + const button = document.querySelector(`[data-testid="leverage-modal-continue"]`); + if (!button) return true; + const attr = button.getAttribute('disabled'); + return attr === null; + }, + null, + { + timeout: 30_000, + polling: 1_000, + }, + ); + }; + + const waitSummaryToBeReady = async () => { + await page.waitForFunction( + () => { + const button = document.querySelector(`[data-testid="leverage-submit"]`); + if (!button) return true; + const attr = button.getAttribute('disabled'); + return attr === null; + }, + null, + { + timeout: 30_000, + polling: 1_000, + }, + ); + }; + + const cta = () => { + return page.getByTestId('leverage'); + }; + + const open = async () => { + await cta().click(); + + const modal = page.getByTestId('leverage-modal'); + await expect(modal).toBeVisible(); + }; + + const close = async () => { + await page.getByTestId('leverage-modal-close').click(); + await expect(page.getByTestId('leverage-modal')).not.toBeVisible(); + }; + + const checkOption = async ( + option: 'from' | 'to', + stats: { type: 'empty' } | { type: 'selected'; symbol: ERC20TokenSymbol }, + ) => { + const button = page.getByTestId(`leverage-select-${option}`); + switch (stats.type) { + case 'empty': + await expect(button).toHaveText('Choose Asset'); + break; + case 'selected': + await expect(button).toHaveText(stats.symbol); + break; + } + }; + + const selectAsset = async (option: 'from' | 'to', symbol: ERC20TokenSymbol) => { + await page.getByTestId(`leverage-select-${option}`).click(); + + const row = page.getByTestId(`leverage-select-${option}-${symbol}`); + await expect(row).toBeVisible(); + await row.click(); + }; + + const selectMultiplier = async (option: { type: 'min' } | { type: 'max' } | { type: 'custom'; value: number }) => { + const slider = page.getByTestId('leverage-slider'); + await expect(slider).not.toBeDisabled(); + + switch (option.type) { + case 'min': { + await page.getByTestId('leverage-slider-min').click(); + break; + } + case 'max': { + await page.getByTestId('leverage-slider-max').click(); + break; + } + + case 'custom': { + throw new Error('not implemented'); + } + } + }; + + const checkCurrentMultiplier = async (value: string | RegExp) => { + await expect(page.getByTestId('leverage-slider-current')).toHaveText(value); + }; + + const openMoreOptions = async () => { + const more = page.getByTestId('leverage-more-options'); + await expect(more).toBeVisible(); + await expect(more).toHaveAttribute('aria-expanded', 'false'); + + await page.getByTestId('leverage-more-options-expand').click(); + + await expect(page.getByTestId('leverage-more-options')).toHaveAttribute('aria-expanded', 'true'); + }; + + const closeMoreOptions = async () => { + const more = page.getByTestId('leverage-more-options'); + await expect(more).toBeVisible(); + await expect(more).toHaveAttribute('aria-expanded', 'true'); + + await page.getByTestId('leverage-more-options-expand').click(); + + await expect(more).toHaveAttribute('aria-expanded', 'false'); + }; + + const input = async (value: string) => { + const inp = page.getByTestId('modal-input'); + await expect(inp).toBeVisible(); + await inp.fill(value); + }; + + const goToSummary = async () => { + await page.getByTestId('leverage-modal-continue').click(); + }; + + const acceptRisk = async () => { + const inp = page.getByTestId('leverage-accept-risk'); + await expect(inp).toBeVisible(); + + await inp.click(); + await expect(inp).toBeChecked(); + }; + + const submit = async () => { + const button = page.getByTestId('leverage-submit'); + await expect(button).toBeVisible(); + await expect(button).not.toBeDisabled(); + + await button.click(); + }; + + const waitForTransaction = async () => { + const status = page.getByTestId('transaction-status'); + + await expect(status).toBeVisible({ timeout: 30_000 }); + + await page.waitForFunction( + (message) => { + const text = document.querySelector('[data-testid="transaction-status"]'); + if (!text) return false; + return text.textContent !== message; + }, + 'Processing transaction...', + { timeout: 30_000, polling: 1_000 }, + ); + }; + + const checkTransactionStatus = async (target: 'success' | 'error', summary: string) => { + const status = page.getByTestId('transaction-status'); + + await expect(status).toHaveText(`Transaction ${target === 'success' ? 'completed' : target}`); + await expect(page.getByTestId('transaction-summary')).toHaveText(summary); + }; + + return { + waitForStepToContinue, + cta, + open, + close, + checkOption, + selectAsset, + selectMultiplier, + checkCurrentMultiplier, + openMoreOptions, + closeMoreOptions, + input, + goToSummary, + acceptRisk, + waitSummaryToBeReady, + submit, + waitForTransaction, + checkTransactionStatus, + }; +} diff --git a/e2e/specs/6-rollover/usdc.spec.ts b/e2e/specs/6-rollover/usdc.spec.ts index f105450e4..a0b9458d2 100644 --- a/e2e/specs/6-rollover/usdc.spec.ts +++ b/e2e/specs/6-rollover/usdc.spec.ts @@ -126,6 +126,4 @@ test('USDC rollover', async ({ page, web3, setup }) => { await dashboard.checkFloatingTableRow('borrow', 'USDC'); }); - - await page.waitForTimeout(600_000); }); diff --git a/e2e/specs/7-leverage/usdc.spec.ts b/e2e/specs/7-leverage/usdc.spec.ts new file mode 100644 index 000000000..7c3cf6afe --- /dev/null +++ b/e2e/specs/7-leverage/usdc.spec.ts @@ -0,0 +1,167 @@ +import base from '../../fixture/base'; +import _balance from '../../common/balance'; +import _allowance from '../../common/allowance'; +import _app from '../../common/app'; +import _leverage from '../../components/leverage'; + +import { debtManager } from '../../utils/contracts'; + +const test = base(); + +test.describe.configure({ mode: 'serial' }); + +// FIXME: Skipping until new contract is deployed +test.skip(); + +test('USDC leverage', async ({ page, web3, setup }) => { + await web3.fork.setBalance(web3.account.address, { + ETH: 1, + USDC: 50_000, + }); + + await page.goto('/dashboard'); + + const app = _app({ test, page }); + const balance = _balance({ test, page, publicClient: web3.publicClient }); + const allowance = _allowance({ test, page, publicClient: web3.publicClient }); + const leverage = _leverage(page); + + const spender = await debtManager(); + + await setup.enterMarket('USDC'); + await setup.deposit({ symbol: 'USDC', amount: '10000', receiver: web3.account.address }); + + await app.reload(); + + await page.goto('/strategies'); + + await test.step('Leverage USDC (no deposit)', async () => { + await leverage.open(); + + await leverage.checkOption('from', { type: 'empty' }); + await leverage.checkOption('to', { type: 'empty' }); + + await leverage.selectAsset('from', 'USDC'); + await leverage.checkOption('to', { type: 'selected', symbol: 'USDC' }); + + await leverage.checkCurrentMultiplier(/1\.00x$/); + + await leverage.selectMultiplier({ type: 'max' }); + + await leverage.waitForStepToContinue(); + await leverage.goToSummary(); + + await leverage.acceptRisk(); + await leverage.waitSummaryToBeReady(); + + await leverage.submit(); + + await leverage.waitForTransaction(); + await leverage.checkTransactionStatus('success', 'Your position has been leveraged'); + + await leverage.close(); + + await balance.check({ address: web3.account.address, symbol: 'USDC', amount: '40000' }); + + await allowance.check({ + address: web3.account.address, + type: 'erc20', + symbol: 'USDC', + less: '0', + spender: spender.address, + }); + + await allowance.check({ + address: web3.account.address, + type: 'market', + symbol: 'USDC', + less: '10', + spender: spender.address, + }); + }); + + await test.step('Deleverage USDC', async () => { + await leverage.open(); + + await leverage.checkOption('from', { type: 'empty' }); + await leverage.checkOption('to', { type: 'empty' }); + + await leverage.selectAsset('from', 'USDC'); + await leverage.checkOption('to', { type: 'selected', symbol: 'USDC' }); + + await leverage.checkCurrentMultiplier(/5\.32x$/); + + await leverage.selectMultiplier({ type: 'min' }); + + await leverage.waitForStepToContinue(); + await leverage.goToSummary(); + + await leverage.acceptRisk(); + await leverage.waitSummaryToBeReady(); + + await leverage.submit(); + + await leverage.waitForTransaction(); + await leverage.checkTransactionStatus('success', 'Your position has been deleveraged'); + + await leverage.close(); + + await balance.check({ address: web3.account.address, symbol: 'USDC', amount: '40000' }); + + await allowance.check({ + address: web3.account.address, + type: 'market', + symbol: 'USDC', + less: '10', + spender: spender.address, + }); + }); + + await test.step('Leverage USDC (with deposit)', async () => { + await leverage.open(); + + await leverage.checkOption('from', { type: 'empty' }); + await leverage.checkOption('to', { type: 'empty' }); + + await leverage.selectAsset('from', 'USDC'); + await leverage.checkOption('to', { type: 'selected', symbol: 'USDC' }); + + await leverage.checkCurrentMultiplier(/1\.00x$/); + + await leverage.selectMultiplier({ type: 'max' }); + + await leverage.openMoreOptions(); + await leverage.input('5000'); + + await leverage.waitForStepToContinue(); + await leverage.goToSummary(); + + await leverage.acceptRisk(); + await leverage.waitSummaryToBeReady(); + + await leverage.submit(); + + await leverage.waitForTransaction(); + await leverage.checkTransactionStatus('success', 'Your position has been leveraged'); + + await leverage.close(); + + await balance.check({ address: web3.account.address, symbol: 'USDC', amount: '35000' }); + + await allowance.check({ + address: web3.account.address, + type: 'erc20', + symbol: 'USDC', + less: '0', + spender: spender.address, + }); + + await allowance.check({ + address: web3.account.address, + type: 'market', + symbol: 'USDC', + less: '10', + spender: spender.address, + }); + }); +}); diff --git a/e2e/utils/contracts.ts b/e2e/utils/contracts.ts index 5ad38a1d0..87f8d7365 100644 --- a/e2e/utils/contracts.ts +++ b/e2e/utils/contracts.ts @@ -1,9 +1,10 @@ import { getContract, isAddress, type PublicClient, type WalletClient } from 'viem'; import auditorContract from '@exactly/protocol/deployments/optimism/Auditor.json' assert { type: 'json' }; import marketETHRouter from '@exactly/protocol/deployments/optimism/MarketETHRouter.json' assert { type: 'json' }; +import debtManagerContract from '@exactly/protocol/deployments/optimism/DebtManager.json' assert { type: 'json' }; -import { Auditor, Market, MarketETHRouter, ERC20 } from '../../types/contracts'; -import { auditorABI, marketABI, marketEthRouterABI, erc20ABI } from '../../types/abi'; +import { Auditor, Market, MarketETHRouter, ERC20, DebtManager } from '../../types/contracts'; +import { auditorABI, marketABI, marketEthRouterABI, erc20ABI, debtManagerABI } from '../../types/abi'; const ERC20TokenSymbols = ['WETH', 'USDC', 'OP'] as const; export type ERC20TokenSymbol = (typeof ERC20TokenSymbols)[number]; @@ -43,3 +44,8 @@ export const auditor = async (clients: Clients = {}): Promise => { if (!isAddress(auditorContract.address)) throw new Error('Invalid address'); return getContract({ address: auditorContract.address, abi: auditorABI, ...clients }); }; + +export const debtManager = async (clients: Clients = {}): Promise => { + if (!isAddress(debtManagerContract.address)) throw new Error('Invalid address'); + return getContract({ address: debtManagerContract.address, abi: debtManagerABI, ...clients }); +}; diff --git a/playwright.config.ts b/playwright.config.ts index 8764b8bab..937b5f7f7 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -20,7 +20,6 @@ const config: PlaywrightTestConfig = { 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/106.0.0.0 Safari/537.36', headless: true, actionTimeout: 0, - viewport: { width: 1920, height: 1080 }, baseURL: 'http://localhost:3000', trace: 'on-first-retry', }, @@ -29,6 +28,7 @@ const config: PlaywrightTestConfig = { name: 'chromium', use: { ...devices['Desktop Chrome'], + viewport: { width: 1920, height: 1080 }, launchOptions: { args: ['--disable-web-security'], },