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// « 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