SPB Git

Repository Public

futures-router.spec.ts 12.2 KB 171 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// « Routeur (simulation) » against the in-browser mock (`?mock=1`, development build only): the REAL router of8// packages/derivatives runs in the page on the mock's simulated markets (mockRouter.ts). No backend, no venue.9// The spec starts its own Vite dev server (or uses FUTURES_URL). FUTURES_SHOTS=1 writes docs/v4/screens/router-*.png.10//   npx playwright test -c tests/e2e/playwright.config.ts futures-router.spec.ts11const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');12const PORT = 5191;13const BASE = process.env.FUTURES_URL ?? `http://localhost:${PORT}`;14const SHOTS = resolve(ROOT, 'docs/v4/screens');15let vite: ChildProcess | null = null;1617test.beforeAll(async () => {18  if (process.env.FUTURES_URL) return;19  vite = spawn(resolve(ROOT, 'apps/web/node_modules/.bin/vite'), ['--port', String(PORT), '--strictPort'], { cwd: resolve(ROOT, 'apps/web'), stdio: 'ignore' });20  for (let i = 0; i < 80; i++) {21    try { if ((await fetch(`${BASE}/`)).ok) return; } catch { /* not up yet */ }22    await new Promise((r) => setTimeout(r, 250));23  }24  throw new Error('the Vite dev server did not start');25});26test.afterAll(() => { vite?.kill(); });2728const shot = async (page: Page, name: string, fullPage = true) => {29  if (process.env.FUTURES_SHOTS !== '1') return;30  mkdirSync(SHOTS, { recursive: true });31  await page.screenshot({ path: `${SHOTS}/${name}.png`, fullPage });32};33const open = async (page: Page, path = '/futures?v=router', extra = '') => {34  await page.goto(`${BASE}${path}${path.includes('?') ? '&' : '?'}mock=1${extra}`);35  await expect(page.getByTestId('paper-banner')).toBeVisible();36};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; };38/** every request the page makes to the API: the router view must never call an order route */39const calls = (page: Page) => { const seen: string[] = []; page.on('request', (r) => { const u = new URL(r.url()); if (u.pathname.startsWith('/api/v1/')) seen.push(`${r.method()} ${u.pathname}`); }); return seen; };40const card = (page: Page, instrument: string) => page.locator(`[data-testid="router-card"][data-instrument="${instrument}"]`);4142const SIZES = [43  { name: 'phone', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } },44  { name: 'desktop', use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false } },45] as const;4647for (const size of SIZES) {48  test.describe(`router (simulation) — ${size.name}`, () => {49    test.use({ ...size.use, locale: 'fr-CA' });5051    test('form → one card per venue instrument, itemised and tagged; ranking worded honestly; dYdX unavailable with the reason', async ({ page }) => {52      const errors = errorsOf(page);53      await open(page);54      const view = page.getByTestId('router-view');55      await expect(view).toContainText('Routeur (simulation)');56      await expect(view).toContainText('Simulation seulement.');57      await expect(page.getByRole('tab', { name: 'Routeur (simulation)' })).toHaveAttribute('aria-selected', 'true');58      await expect(view).toContainText('Décrivez l’ordre, puis lancez l’estimation.');59      await shot(page, `router-${size.name}-01-form`);60      await page.getByTestId('router-run').click();61      const results = page.getByTestId('router-results');62      await expect(results).toBeVisible();63      // ranking: honest wording, never « meilleur », nothing selected64      await expect(page.getByTestId('router-least')).toContainText('Le moins coûteux d’après ces estimations');65      const text = (await results.innerText()).toLowerCase();66      expect(text).not.toContain('meilleur'); expect(text).not.toContain('recommand');67      await expect(results).toContainText('Le classement est une information ; rien n’est sélectionné.');68      // one card per instrument: Hyperliquid, the GMX pools, dYdX69      const cards = page.getByTestId('router-card');70      expect(await cards.count()).toBeGreaterThanOrEqual(3);71      const hl = card(page, 'hyperliquid:BTC'), gmx = card(page, 'gmx:BTC/USD [BTC-USDC]'), dy = card(page, 'dydx:BTC-USD');72      await expect(hl.getByTestId('router-status')).toHaveAttribute('data-status', 'setup');73      await expect(hl.getByTestId('router-costs').locator('li[data-cost]')).toHaveCount(4);74      await expect(hl).toContainText('Écart et glissement'); await expect(hl).toContainText('Frais de négociation'); await expect(hl).toContainText('Financement sur la durée');75      await expect(hl.getByTestId('router-allin')).toContainText('Tout compris sur 24 h');76      // every figure of the simulated execution and of the costs carries its source77      expect(await hl.locator('[data-src="venue"]').count()).toBeGreaterThan(2);78      expect(await hl.locator('[data-src="estimate"]').count()).toBeGreaterThan(5);79      await hl.locator('li[data-cost="trading_fee"] summary').click();80      await expect(hl.locator('li[data-cost="trading_fee"] [data-src="constant"]').first()).toBeVisible();81      // GMX: no book — oracle, impact, execution fee, gas, borrowing; cannot be traded from this wallet82      await expect(gmx.getByTestId('router-status')).toHaveAttribute('data-status', 'setup');83      await expect(gmx).toContainText('Impact de prix (formule)'); await expect(gmx).toContainText('Frais d’exécution (exécutant, en ETH)'); await expect(gmx).toContainText('Emprunt sur la durée');84      await expect(gmx.locator('li[data-reason="wallet_cannot_trade_here"]')).toContainText('ne fait que lire cette plateforme');85      await expect(gmx.getByTestId('router-open-paper')).toHaveCount(0);86      // dYdX: shown, marked unavailable with the reason, never in the ranking87      await expect(dy.getByTestId('router-status')).toHaveAttribute('data-status', 'restricted');88      await expect(dy.getByTestId('router-status')).toHaveText('Non disponible pour vous');89      await expect(dy).toContainText('excluent le Canada'); await expect(dy.getByTestId('router-open-paper')).toHaveCount(0);90      await expect(results.getByRole(size.name === 'phone' ? 'list' : 'table', { name: 'Classement par coût estimé' })).not.toContainText('dYdX');91      // the differences: these contracts are not interchangeable92      await expect(results).toContainText('Ces contrats ne sont pas interchangeables.'); await expect(results).toContainText('Prix de référence (marge, liquidation)');93      await shot(page, `router-${size.name}-02-results`);94      await hl.scrollIntoViewIfNeeded();95      await shot(page, `router-${size.name}-03-card-hyperliquid`, false);96      await gmx.scrollIntoViewIfNeeded();97      await shot(page, `router-${size.name}-04-card-gmx`, false);98      await dy.scrollIntoViewIfNeeded();99      await shot(page, `router-${size.name}-05-card-dydx`, false);100      expect(errors).toEqual([]);101    });102103    test('a size the book snapshot cannot fill is said, not extrapolated; a refused leverage names its reason', async ({ page }) => {104      await open(page);105      await page.getByRole('textbox', { name: 'Taille' }).fill('900000000');106      await page.getByTestId('router-run').click();107      const hl = card(page, 'hyperliquid:BTC');108      await expect(hl.getByTestId('router-status')).toHaveAttribute('data-status', 'not_eligible');109      await expect(hl).toContainText('Le relevé du carnet s’arrête.'); await expect(hl.locator('li[data-reason="book_snapshot_ends"]')).toContainText('rien n’est extrapolé');110      await expect(card(page, 'gmx:BTC/USD [BTC-USDC]').locator('li[data-reason="above_capacity"]')).toBeVisible();111      await expect(page.getByTestId('router-results')).toContainText('Aucun instrument admissible pour cet ordre.');112      await shot(page, `router-${size.name}-06-book-ends`);113      await page.getByRole('textbox', { name: 'Taille' }).fill('1000');114      await page.getByRole('textbox', { name: 'Levier' }).fill('60');115      await expect(page.getByTestId('router-results')).toHaveAttribute('data-stale', '1');           // the shown results are for the previous order, and say so116      await page.getByTestId('router-run').click();117      await expect(card(page, 'hyperliquid:BTC').locator('li[data-reason="leverage_above_venue_max"]')).toBeVisible();118    });119120    test('risk view with the paper account, then « Ouvrir sur Hyperliquid en simulation » pre-fills the paper order panel — no order route is called', async ({ page }) => {121      const seen = calls(page);122      await open(page);123      await page.getByRole('textbox', { name: 'Taille' }).fill('2000');124      await page.getByRole('combobox', { name: 'Compte pour la vue risque' }).selectOption({ label: 'Simulation Hyperliquid — Hyperliquid' });125      await page.getByTestId('router-run').click();126      const hl = card(page, 'hyperliquid:BTC');127      await expect(hl.getByTestId('router-status')).toHaveAttribute('data-status', 'paper');128      await expect(hl.getByTestId('router-policy')).toHaveAttribute('data-accepted', 'true');129      // the default paper policy caps leverage at 3: the account's own risk engine refuses, with its code130      await page.getByRole('textbox', { name: 'Levier' }).fill('5');131      await page.getByTestId('router-run').click();132      await expect(hl.getByTestId('router-policy')).toHaveAttribute('data-accepted', 'false');133      await expect(hl.getByTestId('router-policy')).toContainText('leverage_exceeded');134      await shot(page, `router-${size.name}-07-risk-view`);135      await page.getByRole('textbox', { name: 'Levier' }).fill('3');136      await page.getByTestId('router-run').click();137      await expect(hl.getByTestId('router-policy')).toHaveAttribute('data-accepted', 'true');138      const sizeBase = await hl.locator('[data-testid="router-open-paper"]').evaluate(() => (JSON.parse(sessionStorage.getItem('spw-fut-prefill') ?? 'null') as { size?: string } | null)?.size ?? null);139      expect(sizeBase).toBeNull();                                                                     // nothing is handed over before the click140      await hl.getByTestId('router-open-paper').click();141      // the paper terminal of Hyperliquid BTC, order panel first, form filled; still a form142      await expect(page.getByTestId('terminal')).toHaveAttribute('data-tone', 'paper');143      await expect(page).toHaveURL(/\/futures\/hyperliquid%3ABTC/);144      const sizeBox = page.getByRole('textbox', { name: 'Taille' });145      await expect(sizeBox).toBeVisible();146      expect(Number((await sizeBox.inputValue()).replace(',', '.'))).toBeGreaterThan(0.02);147      await shot(page, `router-${size.name}-08-paper-panel-prefilled`, false);148      // no order was placed by any of this: only reads, the route simulation and the paper PREVIEW149      const writes = seen.filter((c) => c.startsWith('POST'));150      expect(writes.every((c) => c === 'POST /api/v1/derivatives/route' || /\/orders\/preview$/.test(c))).toBe(true);151      expect(seen.some((c) => /\/live\/.*(orders|approve|authoriz)/.test(c) && c.startsWith('POST'))).toBe(false);152      expect(await page.evaluate(() => (window as unknown as { __spwMock: { accounts(): number } }).__spwMock.accounts())).toBe(1);153    });154155    test('English: honest wording there too', async ({ page }) => {156      await open(page);157      await page.evaluate(() => localStorage.setItem('spw-locale', 'en'));158      await page.reload();159      const view = page.getByTestId('router-view');160      await expect(view).toBeVisible();161      if (!(await view.innerText()).includes('Simulation only.')) test.skip(true, 'the locale is not switched through localStorage in this build');162      await page.getByTestId('router-run').click();163      await expect(page.getByTestId('router-least')).toContainText('Lowest cost according to these estimates');164      const text = (await page.getByTestId('router-results').innerText()).toLowerCase();165      expect(text).not.toMatch(/\bbest venue\b|\brecommended\b/);166      await expect(card(page, 'dydx:BTC-USD').getByTestId('router-status')).toHaveText('Not available to you');167      await shot(page, `router-${size.name}-09-english`);168    });169  });170}171
Files main
Menu

Theme