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// Futures terminal against the in-browser mock (`?mock=1`, development build only): no backend, no venue, no dev stack needed.8// The spec starts its own Vite dev server (or uses FUTURES_URL). FUTURES_SHOTS=1 writes the screenshots to docs/v4/screens/.9// npx playwright test -c tests/e2e/playwright.config.ts futures.spec.ts10const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');11const PORT = 5188;12const BASE = process.env.FUTURES_URL ?? `http://localhost:${PORT}`;13const SHOTS = resolve(ROOT, 'docs/v4/screens');14let vite: ChildProcess | null = null;1516test.beforeAll(async () => {17 if (process.env.FUTURES_URL) return;18 vite = spawn(resolve(ROOT, 'apps/web/node_modules/.bin/vite'), ['--port', String(PORT), '--strictPort'], { cwd: resolve(ROOT, 'apps/web'), stdio: 'ignore' });19 for (let i = 0; i < 80; i++) {20 try { if ((await fetch(`${BASE}/`)).ok) return; } catch { /* not up yet */ }21 await new Promise((r) => setTimeout(r, 250));22 }23 throw new Error('the Vite dev server did not start');24});25test.afterAll(() => { vite?.kill(); });2627const shot = async (page: Page, name: string, fullPage = false) => {28 if (process.env.FUTURES_SHOTS !== '1') return;29 mkdirSync(SHOTS, { recursive: true });30 await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage });31};32const open = async (page: Page, path = '/futures', extra = '') => {33 await page.goto(`${BASE}${path}${path.includes('?') ? '&' : '?'}mock=1${extra}`);34 await expect(page.getByTestId('paper-banner')).toBeVisible();35};36const mark = (page: Page, id = 'hyperliquid:BTC') => page.evaluate(async (i) => Number((await (await fetch(`/api/v1/derivatives/market?instrument=${encodeURIComponent(i)}`)).json()).ticker.mark), id);37const errorsOf = (page: Page) => { const errors: string[] = []; page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); page.on('pageerror', (e) => errors.push(String(e))); return errors; };3839const SIZES = [40 { name: 'phone', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } },41 { name: 'desktop', use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false } },42] as const;4344for (const size of SIZES) {45 const phone = size.name === 'phone';46 test.describe(`futures terminal — ${size.name}`, () => {47 test.use({ ...size.use, locale: 'fr-CA' });48 /** on a phone the terminal is one panel at a time */49 const panel = async (page: Page, name: 'Graphique' | 'Carnet' | 'Ordre' | 'Positions') => { if (phone) await page.getByRole('tab', { name: new RegExp(`^${name}( \\(\\d+\\))?$`) }).click(); };50 const blotterTab = (page: Page, name: RegExp) => page.getByTestId('blotter').getByRole('tab', { name }).click();51 const fillSize = async (page: Page, v: string) => { await page.getByRole('textbox', { name: 'Taille' }).fill(v); };5253 test('opens, shows live data, switches market and interval', async ({ page }) => {54 const errors = errorsOf(page);55 await open(page);56 await expect(page.getByTestId('terminal')).toHaveAttribute('data-layout', phone ? 'phone' : 'desktop');57 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', 'live', { timeout: 15_000 });58 await expect(page.getByTestId('mark-price')).toHaveText(/^\d{2}\s\d{3}/);59 await expect(page.getByTestId('market-picker')).toContainText('BTC-USD');60 await expect(page.getByTestId('ohlc')).toContainText(/O .* H .* B .* C /);61 if (!phone) await expect(page.getByTestId('venue-strip')).toContainText('données de marché seulement'); // on a phone the strip is on the Compare and Accounts views62 await shot(page, `${size.name}-01-terminal-chart`);63 await panel(page, 'Carnet');64 await expect(page.getByTestId('spread')).toContainText(/pb/);65 await expect(page.getByTestId('trades')).toBeVisible();66 await shot(page, `${size.name}-02-book`);67 // market switch through the picker (search + venue filter + favourite)68 await page.getByTestId('market-picker').click();69 const dialog = page.getByRole('dialog', { name: 'Choisir un marché' });70 await dialog.getByRole('searchbox').fill('eth');71 await dialog.getByRole('radio', { name: 'Hyperliquid' }).click();72 await dialog.getByRole('button', { name: /Ajouter ETH Hyperliquid aux favoris/ }).click();73 await expect(dialog.getByRole('button', { name: /Retirer ETH Hyperliquid des favoris/ })).toBeVisible();74 await shot(page, `${size.name}-03-market-picker`);75 await dialog.getByRole('button', { name: /^ETH-USD/ }).first().click();76 await expect(page).toHaveURL(/\/futures\/hyperliquid%3AETH/);77 await expect(page.getByTestId('market-picker')).toContainText('ETH-USD');78 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', 'live', { timeout: 15_000 });79 expect(await page.evaluate(() => JSON.parse(localStorage.getItem('spw-fut-favs') ?? '[]'))).toContain('hyperliquid:ETH');80 // interval switch81 await panel(page, 'Graphique');82 await page.getByRole('radio', { name: '15 minutes' }).click();83 await expect(page.getByRole('radio', { name: '15 minutes' })).toHaveAttribute('aria-checked', 'true');84 await expect(page.getByTestId('ohlc')).toContainText(/O \d/);85 await expect(page.getByRole('img', { name: /chandeliers de 15m/ })).toBeVisible();86 expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); // no horizontal overflow87 expect(errors).toEqual([]);88 });8990 test('paper market order → position on the chart and in the blotter → close', async ({ page }) => {91 const errors = errorsOf(page);92 await open(page);93 await panel(page, 'Ordre');94 await fillSize(page, '1500');95 await page.getByRole('textbox', { name: 'Stop de protection' }).fill(String(Math.round((await mark(page)) * 0.97)));96 await expect(page.getByTestId('preview')).toContainText('Marge initiale');97 await expect(page.getByTestId('preview')).toContainText(/Prix de liquidation \(estimation\)/);98 await shot(page, `${size.name}-04-order-preview`, phone);99 await page.getByRole('button', { name: /vérifier l’ordre BTC/ }).click();100 const sheet = page.getByRole('dialog', { name: 'Confirmer l’ordre simulé' });101 await expect(sheet).toContainText('Simulation');102 await shot(page, `${size.name}-05-confirm`);103 await sheet.getByRole('button', { name: 'Placer l’ordre simulé' }).click();104 await expect(page.getByTestId('order-result')).toContainText('Ordre simulé exécuté');105 await panel(page, 'Positions');106 const blotter = page.getByTestId('blotter');107 await expect(blotter.getByRole('button', { name: 'Ouvrir le marché BTC' })).toBeVisible();108 await expect(blotter).toContainText('Long');109 await expect(blotter).toContainText(/Liquidation\s+\(estimation\)/);110 await shot(page, `${size.name}-06-position`, phone);111 await blotterTab(page, /^Ordres ouverts \(1\)/); // the attached stop is a separate reduce-only order112 await expect(blotter).toContainText('Stop au marché');113 await blotterTab(page, /^Exécutions/);114 await expect(blotter).toContainText('Achat');115 await blotterTab(page, /^Positions/);116 await blotter.getByRole('button', { name: 'Fermer 100 % de la position BTC' }).click();117 await expect(blotter).toContainText('Aucune position.');118 expect(errors).toEqual([]);119 });120121 test('limit order: place, see it resting, cancel it', async ({ page }) => {122 await open(page);123 await panel(page, 'Ordre');124 await page.getByRole('radio', { name: 'À cours limité' }).click();125 await page.getByRole('textbox', { name: /Prix limite/ }).fill(String(Math.round((await mark(page)) * 0.985)));126 await page.getByRole('radio', { name: 'BTC', exact: true }).click();127 await fillSize(page, '0,01');128 await expect(page.getByTestId('preview')).toContainText('Valeur notionnelle');129 await page.getByRole('button', { name: /vérifier l’ordre BTC/ }).click();130 await page.getByRole('dialog').getByRole('button', { name: 'Placer l’ordre simulé' }).click();131 await expect(page.getByTestId('order-result')).toContainText('placé dans le carnet');132 await panel(page, 'Positions');133 const blotter = page.getByTestId('blotter');134 await blotterTab(page, /^Ordres ouverts \(1\)/);135 await expect(blotter).toContainText('ouvert');136 await shot(page, `${size.name}-07-open-order`, phone);137 await blotter.getByRole('button', { name: 'Annuler l’ordre sur BTC' }).click();138 await expect(blotter).toContainText('Aucun ordre ouvert.');139 await blotterTab(page, /^Historique/);140 await expect(blotter).toContainText('annulé');141 });142143 test('a refused order says why, in plain words', async ({ page }) => {144 await open(page);145 await panel(page, 'Ordre');146 await fillSize(page, '80000');147 const refusal = page.getByTestId('risk-refusal');148 await expect(refusal).toContainText('Ordre refusé par le moteur de risque.');149 await expect(refusal).toContainText(/le levier du compte dépasserait votre limite/);150 await expect(refusal).toContainText('leverage_exceeded');151 await expect(page.getByRole('button', { name: /vérifier l’ordre BTC/ })).toBeDisabled();152 await shot(page, `${size.name}-08-refused`, phone);153 });154155 test('a stale stream is shown as stale, greyed, with its age', async ({ page }) => {156 await open(page);157 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', 'live', { timeout: 15_000 });158 await page.evaluate(() => window.__spwMock!.stale());159 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', 'stale');160 await expect(page.getByTestId('freshness')).toContainText(/Données périmées · il y a \d+\s(s|min)/);161 await expect(page.getByTestId('stale-banner')).toContainText('n’est plus à jour');162 await expect(page.locator('[data-stale]').first()).toHaveClass(/grayscale/);163 await page.waitForTimeout(2200);164 await shot(page, `${size.name}-09-stale`);165 await page.evaluate(() => window.__spwMock!.reconnect());166 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', /reconnecting|stale/);167 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', 'live', { timeout: 15_000 });168 await expect(page.getByTestId('stale-banner')).toHaveCount(0);169 });170171 test('compare view: a table and the differences, no recommendation', async ({ page }) => {172 await open(page, '/futures?v=compare');173 const view = page.getByTestId('compare-view');174 await expect(view).toContainText('Ces contrats ne sont pas interchangeables.');175 for (const v of ['Hyperliquid', 'GMX v2', 'dYdX v4']) await expect(view.getByText(v).first()).toBeVisible();176 await expect(view).toContainText('pas de carnet');177 await expect(view).toContainText('Ce qui diffère d’un contrat à l’autre');178 await expect(view.locator('text=/best|recommand/i')).toHaveCount(0);179 await shot(page, `${size.name}-10-compare`, true);180 await view.getByRole('radio', { name: 'ETH' }).click();181 await expect(view).toContainText('Contrats perpétuels ETH par plateforme');182 });183184 test('accounts: create a paper account, watch an address, live trading is off; GMX and dYdX say what they are', async ({ page }) => {185 await open(page, '/futures?v=accounts');186 const view = page.getByTestId('accounts-view');187 await expect(view.getByTestId('venue-list')).toContainText('excluent le Canada');188 await view.getByRole('button', { name: 'Nouveau compte' }).click();189 await view.getByRole('textbox', { name: 'Nom du compte' }).fill('Essai 2');190 await expect(view.getByTestId('account-form').getByRole('option', { name: /dYdX v4 — non offert/ })).toBeDisabled();191 await shot(page, `${size.name}-11-account-form`, true);192 await view.getByRole('button', { name: 'Créer le compte de simulation' }).click();193 await expect(view.getByText('Essai 2')).toBeVisible();194 await view.getByRole('textbox', { name: 'Adresse du compte' }).fill('0x3d62aa392daa9cc4cbe9e11d57681ea1371f3447');195 await view.getByRole('button', { name: 'Observer' }).click();196 await expect(view.getByTestId('watch-result')).toContainText('lecture seule');197 const live = view.getByTestId('live-card');198 await expect(live).toContainText('désactivée');199 await expect(live.getByRole('button', { name: 'Créer le compte réel' })).toBeDisabled(); // mainnet: shown, blocked by the server setting (live flows: futures-live.spec.ts)200 await shot(page, `${size.name}-12-accounts`, true);201 // GMX: no order book → liquidity panel; no paper trading202 await open(page, `/futures/${encodeURIComponent('gmx:BTC/USD [BTC-USDC]')}`);203 if (phone) await page.getByRole('tab', { name: 'Liquidité' }).click();204 await expect(page.getByTestId('gmx-liquidity')).toContainText('GMX n’a pas de carnet d’ordres');205 await shot(page, `${size.name}-13-gmx`, phone);206 await panel(page, 'Ordre');207 await expect(page.getByTestId('order-panel')).toContainText('GMX est en lecture seule');208 // a market outside the account's allowed list says so and offers to allow it (default policy: BTC and ETH only)209 await open(page, '/futures/hyperliquid%3ASOL');210 await panel(page, 'Ordre');211 await expect(page.getByTestId('not-allowed')).toContainText('SOL ne fait pas partie des marchés autorisés');212 await page.getByRole('button', { name: 'Autoriser SOL sur ce compte simulé' }).click();213 await expect(page.getByTestId('not-allowed')).toHaveCount(0);214 // dYdX: market data only, with the reason215 await open(page, '/futures/dydx%3ABTC-USD');216 await panel(page, 'Ordre');217 await expect(page.getByTestId('order-panel')).toContainText('excluent le Canada');218 await expect(page.getByTestId('order-panel').getByRole('textbox')).toHaveCount(0);219 await shot(page, `${size.name}-14-dydx`, phone);220 });221222 test('language switch', async ({ page }) => {223 await open(page);224 await page.getByRole('navigation').getByRole('button', { name: 'Réglages' }).click();225 await page.getByRole('radio', { name: 'English' }).click();226 await page.getByRole('navigation').getByRole('button', { name: 'Swap' }).click();227 await page.getByRole('tab', { name: 'Futures' }).click();228 await expect(page.getByTestId('paper-banner')).toContainText('live trading is not enabled');229 await expect(page.getByTestId('freshness')).toHaveText('Live', { timeout: 15_000 });230 if (phone) await page.getByRole('tab', { name: 'Trade' }).click();231 await expect(page.getByTestId('order-panel')).toContainText('Simulated order');232 await expect(page.getByTestId('order-panel')).toContainText('Paper only: nothing is sent to the venue.');233 await shot(page, `${size.name}-15-english`, phone);234 });235236 test('20 book updates per second stay smooth (measurement)', async ({ page, context }) => {237 await open(page, '/futures', '&rate=20');238 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', 'live', { timeout: 15_000 });239 await panel(page, 'Carnet');240 if (phone) { const cdp = await context.newCDPSession(page); await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); } // a mid-range phone241 const r = await page.evaluate(async () => {242 const store = (window as unknown as { __spwStore: { perf: { events: number; commits: number; bookCommits: number } } }).__spwStore;243 const p0 = { ...store.perf }, frames: number[] = [], long: number[] = [];244 let mutations = 0;245 const mo = new MutationObserver((l) => { mutations += l.length; }); mo.observe(document.querySelector('main')!, { subtree: true, childList: true, characterData: true, attributes: true });246 const po = new PerformanceObserver((l) => { for (const e of l.getEntries()) long.push(e.duration); }); po.observe({ entryTypes: ['longtask'] });247 const t0 = performance.now(); let last = t0;248 await new Promise<void>((done) => { const f = (t: number) => { frames.push(t - last); last = t; if (t - t0 < 8000) requestAnimationFrame(f); else done(); }; requestAnimationFrame(f); });249 mo.disconnect(); po.disconnect();250 const sorted = [...frames].sort((a, b) => a - b), s = (performance.now() - t0) / 1000;251 return { seconds: +s.toFixed(1), streamEventsPerSec: +((store.perf.events - p0.events) / s).toFixed(1), bookCommitsPerSec: +((store.perf.bookCommits - p0.bookCommits) / s).toFixed(1), fps: +(frames.length / s).toFixed(1),252 frameP50: +sorted[Math.floor(sorted.length * 0.5)]!.toFixed(1), frameP95: +sorted[Math.floor(sorted.length * 0.95)]!.toFixed(1), frameMax: +sorted[sorted.length - 1]!.toFixed(1),253 longTasks: long.length, longTaskMax: +(Math.max(0, ...long)).toFixed(0), domMutationsPerSec: +(mutations / s).toFixed(0) };254 });255 console.log(`[perf ${size.name}${phone ? ', CPU ×4 slower' : ''}] ${JSON.stringify(r)}`);256 test.info().annotations.push({ type: 'perf', description: JSON.stringify(r) });257 expect(r.streamEventsPerSec).toBeGreaterThan(30); // ~20 tickers + ~20 books (+ trades) per second really arrive258 expect(r.bookCommitsPerSec).toBeLessThanOrEqual(9); // …and the book is committed at most 8 times a second259 expect(r.frameP95).toBeLessThan(50);260 });261 });262}263