Repository Public
1import { spawn, type ChildProcess } from 'node:child_process';2import { mkdirSync } from 'node:fs';3import { dirname, resolve } from 'node:path';4import { fileURLToPath } from 'node:url';5import { expect, test, type Page } from '@playwright/test';67// « Déposer » / « Retirer » of a venue account, in a browser, against the in-browser mock (`?mock=1`, development build only):8// no backend, no chain, no venue, no signer, a simulated passkey — apps/web/src/derivatives/mockCollateral.ts says what is real9// in it (pinned route, floor, fee, canonical withdrawal and its hash, withdraw3 action, passkey challenge) and what is not (time10// stands in for the chain and the venue). What this spec checks is the SCREEN: what it shows before the passkey, that it never11// announces a deposit before the venue credited it nor a withdrawal before it arrived, and what it says when it is late.12// npx playwright test -c tests/e2e/playwright.config.ts futures-collateral.spec.ts (FUTURES_SHOTS=1 writes docs/v4/screens/collateral-*.png)13const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');14const PORT = 5191;15const BASE = process.env.FUTURES_URL ?? `http://localhost:${PORT}`;16const SHOTS = resolve(ROOT, 'docs/v4/screens');17const BRIDGE = '0x2Df1c51E09aECF9cacB7bc98cB1742757f163dF7';18let vite: ChildProcess | null = null;1920test.beforeAll(async () => {21 if (process.env.FUTURES_URL) return;22 vite = spawn(resolve(ROOT, 'apps/web/node_modules/.bin/vite'), ['--port', String(PORT), '--strictPort'], { cwd: resolve(ROOT, 'apps/web'), stdio: 'ignore' });23 for (let i = 0; i < 80; i++) {24 try { if ((await fetch(`${BASE}/`)).ok) return; } catch { /* not up yet */ }25 await new Promise((r) => setTimeout(r, 250));26 }27 throw new Error('the Vite dev server did not start');28});29test.afterAll(() => { vite?.kill(); });3031const live = (page: Page, name: string, ...args: unknown[]) => page.evaluate(([n, a]) => (window as any).__spwMock.live[n as string](...(a as unknown[])), [name, args] as const);32const coll = (page: Page, name: string, ...args: unknown[]) => page.evaluate(([n, a]) => (window as any).__spwMock.live.collateral[n as string](...(a as unknown[])), [name, args] as const);33const errorsOf = (page: Page) => { const errors: string[] = []; page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) errors.push(m.text()); }); page.on('pageerror', (e) => errors.push(e.message)); return errors; };3435for (const size of [36 { name: 'phone', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } },37 { name: 'desktop', use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false } },38] as const) {39 test.describe(`futures collateral — ${size.name}`, () => {40 test.use({ ...size.use, locale: 'fr-CA' });41 const shot = async (page: Page, name: string) => { if (process.env.FUTURES_SHOTS !== '1') return; mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: `${SHOTS}/collateral-${size.name}-${name}.png`, fullPage: false }); };42 /** a real-money account that never deposited (the mock's server setting is on), with 40 USDC in the wallet on Arbitrum */43 const open = async (page: Page, network: 'mainnet' | 'testnet' = 'mainnet') => {44 await page.goto(`${BASE}/futures?v=accounts&mock=1&mainnet=1`);45 await expect(page.getByTestId('live-card')).toBeVisible();46 await live(page, 'fund', network, '0');47 await page.getByTestId(`live-create-${network}`).getByRole('button').click();48 const card = page.getByTestId(`live-account-${network}`);49 await expect(card.getByTestId('collateral-panel')).toBeVisible();50 return card;51 };5253 test('deposit: minimum refused, review says everything, waits for the venue, then credited; withdrawal: fee, own address, arrives', async ({ page }) => {54 test.setTimeout(120_000);55 const errors = errorsOf(page), card = await open(page), panel = card.getByTestId('collateral-panel');56 await expect(card.getByTestId('mode-band')).toHaveAttribute('data-tone', 'mainnet');57 await expect(panel.getByTestId('collateral-venue-balance')).toContainText(/0,00\s\$/);58 await expect(card.getByTestId('funding-box').getByRole('button')).toHaveCount(0);59 await panel.getByTestId('collateral-deposit').click();60 const sheet = page.getByTestId('deposit-sheet');61 await expect(sheet).toHaveAttribute('data-step', 'form');62 // the real-money band is in the sheet and cannot be dismissed63 await expect(sheet.getByTestId('mode-band')).toContainText(/Réel — argent réel/i);64 await expect(sheet.getByTestId('mode-band').getByRole('button')).toHaveCount(0);65 await expect(sheet.getByTestId('deposit-minimum')).toContainText('perdu pour toujours');66 // below the minimum: refused before anything is prepared67 await sheet.getByTestId('deposit-amount').fill('5');68 await expect(sheet.getByTestId('deposit-problem')).toContainText('Refusé : sous 6 USDC');69 await expect(sheet.getByTestId('deposit-review')).toBeDisabled();70 await sheet.getByTestId('deposit-amount').fill('60');71 await expect(sheet.getByTestId('deposit-problem')).toContainText('n’a que 40 USDC');72 await shot(page, '01-deposit-form');73 await sheet.getByTestId('deposit-amount').fill('10');74 await expect(sheet.getByTestId('deposit-problem')).toHaveCount(0);75 await sheet.getByTestId('deposit-review').click();76 await expect(sheet).toHaveAttribute('data-step', 'review');77 const facts = sheet.getByTestId('deposit-facts'), says = sheet.getByTestId('deposit-says');78 await expect(facts).toContainText('10 USDC');79 await expect(facts).toContainText('USDC natif sur Arbitrum One → pont de Hyperliquid → votre compte');80 await expect(facts).toContainText('moins de 60 s');81 await expect(facts).toContainText('6 USDC');82 await expect(says).toContainText('La plateforme crédite l’adresse qui envoie');83 await expect(says).toContainText(BRIDGE.slice(0, 8));84 await expect(says).toContainText('Sous 5 USDC, un dépôt n’est pas crédité et est perdu');85 await expect(says).toContainText('en attente du crédit par la plateforme');86 await expect(sheet.getByTestId('collateral-integrity')).toHaveCount(0);87 await expect(sheet.getByTestId('mode-band')).toBeVisible();88 await shot(page, '02-deposit-review');89 await sheet.getByTestId('deposit-approve').click();90 await expect(sheet).toHaveAttribute('data-step', 'progress', { timeout: 10_000 });91 const op = sheet.getByTestId('collateral-op');92 // sent, then confirmed on-chain: NOT done, the balance after is "not yet"93 await expect(op).toHaveAttribute('data-state', /^(sent|awaiting_credit)$/);94 await expect(op).toHaveAttribute('data-done', '0');95 await expect(op).toContainText('pas encore — en attente');96 await expect(op).not.toContainText('Crédité :');97 await shot(page, '03-deposit-waiting');98 // the venue credits: only now is it announced99 await expect(op).toHaveAttribute('data-state', 'credited', { timeout: 20_000 });100 await expect(op).toHaveAttribute('data-done', '1');101 await expect(op.getByTestId('collateral-op-words')).toContainText('Crédité : la plateforme a inscrit ce dépôt sur votre compte.');102 await expect(op).toContainText(/Solde sur la plateforme, après\s*10 USDC/);103 await shot(page, '04-deposit-credited');104 await sheet.getByRole('button', { name: 'Fermer' }).last().click();105 await expect(panel.getByTestId('collateral-venue-balance')).toContainText(/10,00\s\$/, { timeout: 15_000 });106 await expect(panel.getByTestId('collateral-ops').getByTestId('collateral-op').first()).toHaveAttribute('data-state', 'credited');107108 // withdrawal109 await panel.getByTestId('collateral-withdraw').click();110 const ws = page.getByTestId('withdraw-sheet');111 await expect(ws.getByTestId('mode-band')).toContainText(/Réel — argent réel/i);112 await ws.getByTestId('withdraw-amount').fill('1');113 await expect(ws.getByTestId('withdraw-problem')).toContainText('dépasser les frais de 1 USDC');114 await ws.getByTestId('withdraw-amount').fill('11');115 await expect(ws.getByTestId('withdraw-problem')).toContainText('10 USDC retirables');116 await ws.getByTestId('withdraw-amount').fill('4');117 await ws.getByTestId('withdraw-review').click();118 await expect(ws).toHaveAttribute('data-step', 'review');119 await expect(ws.getByTestId('withdraw-facts')).toContainText(/Frais de la plateforme\s*1 USDC/);120 await expect(ws.getByTestId('withdraw-facts')).toContainText(/Vous recevrez\s*3 USDC/);121 await expect(ws.getByTestId('withdraw-says')).toContainText('l’adresse de ce portefeuille, et aucune autre');122 await expect(ws.getByTestId('withdraw-says')).toContainText('withdraw3 (Mainnet): 4 USDC to 0x5ad0c0ffee');123 await expect(ws.getByTestId('collateral-integrity')).toHaveCount(0);124 await shot(page, '05-withdraw-review');125 await ws.getByTestId('withdraw-approve').click();126 await expect(ws).toHaveAttribute('data-step', 'progress', { timeout: 10_000 });127 const wop = ws.getByTestId('collateral-op');128 await expect(wop).toHaveAttribute('data-state', 'submitted');129 await expect(wop).toHaveAttribute('data-done', '0');130 await expect(wop.getByTestId('collateral-op-words')).toContainText('l’USDC n’est pas encore arrivé sur ce portefeuille');131 await expect(wop).toHaveAttribute('data-state', 'completed', { timeout: 20_000 });132 await expect(wop.getByTestId('collateral-op-words')).toContainText('Arrivé : ce portefeuille a reçu l’USDC du pont.');133 await expect(wop).toContainText(/Reçu sur le portefeuille\s*3 USDC/);134 await shot(page, '06-withdraw-arrived');135 await ws.getByRole('button', { name: 'Fermer' }).last().click();136 await expect(panel.getByTestId('collateral-venue-balance')).toContainText(/6,00\s\$/, { timeout: 15_000 });137 expect(errors).toEqual([]);138 });139140 test('a credit that does not come is never announced: after ten minutes the screen says what to check; a wrong passkey sends nothing', async ({ page }) => {141 test.setTimeout(90_000);142 const errors = errorsOf(page), card = await open(page), panel = card.getByTestId('collateral-panel');143 // wrong passkey first: the operation ends, nothing left the wallet144 await live(page, 'passkey', 'bad');145 await panel.getByTestId('collateral-deposit').click();146 let sheet = page.getByTestId('deposit-sheet');147 await sheet.getByTestId('deposit-amount').fill('10');148 await sheet.getByTestId('deposit-review').click();149 await sheet.getByTestId('deposit-approve').click();150 await expect(sheet).toHaveAttribute('data-step', 'progress', { timeout: 10_000 });151 await expect(sheet.getByTestId('collateral-op')).toHaveAttribute('data-state', 'failed');152 await expect(sheet.getByTestId('collateral-op')).toHaveAttribute('data-done', '0');153 await expect(sheet.getByTestId('collateral-op-words')).toContainText('Le dépôt n’a pas eu lieu. Rien n’a atteint la plateforme.');154 await sheet.getByRole('button', { name: 'Fermer' }).last().click();155 // now a deposit whose credit never comes, seen eleven minutes later156 await coll(page, 'creditAfter', null); await coll(page, 'age', 11 * 60_000);157 await panel.getByTestId('collateral-deposit').click();158 sheet = page.getByTestId('deposit-sheet');159 await sheet.getByTestId('deposit-amount').fill('12');160 await sheet.getByTestId('deposit-review').click();161 await sheet.getByTestId('deposit-approve').click();162 await expect(sheet).toHaveAttribute('data-step', 'progress', { timeout: 10_000 });163 const op = sheet.getByTestId('collateral-op');164 await expect(op).toHaveAttribute('data-state', 'awaiting_credit');165 await expect(op).toHaveAttribute('data-done', '0');166 await expect(op.getByTestId('collateral-op-words')).toContainText('Le dépôt n’est PAS annoncé comme arrivé');167 await expect(op.getByTestId('collateral-overdue')).toContainText('À vérifier, dans l’ordre');168 await expect(op.getByTestId('collateral-overdue')).toContainText(BRIDGE);169 await expect(op.getByTestId('collateral-overdue').getByRole('listitem')).toHaveCount(5);170 await expect(op).toContainText('pas encore — en attente');171 await shot(page, '07-deposit-overdue');172 expect(await page.getByRole('dialog').evaluate((d) => d.scrollWidth <= d.clientWidth + 1 && [...d.querySelectorAll('*')].every((e) => e.scrollWidth <= e.clientWidth + 1 || getComputedStyle(e).overflowX !== 'visible' || e.clientWidth === 0))).toBe(true);173 await sheet.getByRole('button', { name: 'Fermer' }).last().click();174 // the account card keeps saying so, and a second deposit is refused while this one is on its way175 await expect(panel.getByTestId('collateral-ops').getByTestId('collateral-op').first()).toHaveAttribute('data-state', 'awaiting_credit');176 await expect(panel.getByTestId('collateral-venue-balance')).toContainText(/0,00\s\$/);177 await panel.getByTestId('collateral-deposit').click();178 sheet = page.getByTestId('deposit-sheet');179 await sheet.getByTestId('deposit-amount').fill('10');180 await sheet.getByTestId('deposit-review').click();181 await expect(sheet.getByTestId('collateral-error')).toContainText('Un dépôt est déjà en route');182 expect(errors).toEqual([]);183 });184185 test('USDC on another network: nothing is bridged silently, a pre-filled link to the cross-network transfer; testnet has no deposit', async ({ page }) => {186 await page.goto(`${BASE}/futures?v=accounts&mock=1&mainnet=1`);187 await expect(page.getByTestId('live-card')).toBeVisible();188 await live(page, 'fund', 'mainnet', '0');189 await coll(page, 'wallet', '2', [{ network: 'base', name: 'Base', usdc: '31.5' }]); // 2 USDC on Arbitrum, 31.5 on Base190 await page.getByTestId('live-create-mainnet').getByRole('button').click();191 const panel = page.getByTestId('live-account-mainnet').getByTestId('collateral-panel');192 await expect(panel.getByTestId('deposit-why')).toHaveAttribute('data-reason', 'usdc_elsewhere');193 await expect(panel.getByTestId('collateral-deposit')).toBeDisabled();194 await expect(panel.getByTestId('deposit-why')).toContainText('Rien n’est transféré automatiquement');195 const link = panel.getByTestId('collateral-bridge-links').getByRole('link');196 await expect(link).toHaveAttribute('href', '/swap?bridgeFrom=base&bridgeTo=arbitrum');197 await expect(link).toContainText('Amener de l’USDC de Base vers Arbitrum One (31,5 USDC disponibles)');198 await shot(page, '08-usdc-elsewhere');199 await live(page, 'fund', 'testnet', '100');200 await page.getByTestId('live-create-testnet').getByRole('button').click();201 const t = page.getByTestId('live-account-testnet').getByTestId('collateral-panel');202 await expect(t.getByTestId('collateral-deposit')).toBeDisabled();203 await expect(t.getByTestId('deposit-why')).toHaveAttribute('data-reason', 'no_route');204 await expect(t.getByTestId('collateral-withdraw')).toBeEnabled();205 // no horizontal overflow on any size: a long address must wrap206 expect(await page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1)).toBe(true);207 });208 });209}210