SPB Git

Repository Public

futures.spec.ts 17.5 KB 263 lines typescript

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
Files main
Menu

Theme