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 Locator, type Page } from '@playwright/test';67// Controlled LIVE trading screens of the Futures terminal against the in-browser mock (`?mock=1`, development build only):8// no backend, no venue, no signer, a simulated passkey — see apps/web/src/derivatives/mockLive.ts for what is real in it9// (canonical objects and hashes, acknowledgement text, risk engine, order of the backend's checks) and what is not.10// The spec starts its own Vite dev server (or uses FUTURES_URL). FUTURES_SHOTS=1 writes docs/v4/screens/live-*.png.11// npx playwright test -c tests/e2e/playwright.config.ts futures-live.spec.ts12const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');13const PORT = 5190;14const BASE = process.env.FUTURES_URL ?? `http://localhost:${PORT}`;15const SHOTS = resolve(ROOT, 'docs/v4/screens');16let vite: ChildProcess | null = null;1718test.beforeAll(async () => {19 if (process.env.FUTURES_URL) return;20 vite = spawn(resolve(ROOT, 'apps/web/node_modules/.bin/vite'), ['--port', String(PORT), '--strictPort'], { cwd: resolve(ROOT, 'apps/web'), stdio: 'ignore' });21 for (let i = 0; i < 80; i++) {22 try { if ((await fetch(`${BASE}/`)).ok) return; } catch { /* not up yet */ }23 await new Promise((r) => setTimeout(r, 250));24 }25 throw new Error('the Vite dev server did not start');26});27test.afterAll(() => { vite?.kill(); });2829const 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(String(e))); return errors; };30const 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);31type Hook = 'mainnet' | 'fund' | 'passkey' | 'venue' | 'signerOffsetBps' | 'signerDown' | 'ttl' | 'diverge' | 'settle' | 'unreadable';32const hook = (page: Page, name: Hook, ...args: unknown[]) => page.evaluate(([n, a]) => (window as any).__spwMock.live[n as string](...(a as unknown[])), [name, args] as const);33const liveAccounts = (page: Page): Promise<{ id: string; network: string; mode: string; orders: number }[]> => page.evaluate(() => (window as any).__spwMock.live.accounts());3435const SIZES = [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;3940for (const size of SIZES) {41 const phone = size.name === 'phone';42 test.describe(`futures live — ${size.name}`, () => {43 test.use({ ...size.use, locale: 'fr-CA' });44 const shot = async (page: Page, name: string, fullPage = false) => {45 if (process.env.FUTURES_SHOTS !== '1') return;46 mkdirSync(SHOTS, { recursive: true });47 await page.screenshot({ path: `${SHOTS}/live-${size.name}-${name}.png`, fullPage });48 };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 view = async (page: Page, name: 'Terminal' | 'Comptes') => { await page.getByRole('tablist', { name: 'Vues' }).getByRole('tab', { name }).click(); };51 const blotterTab = (page: Page, name: RegExp) => page.getByTestId('blotter').getByRole('tab', { name }).click();52 const openAccounts = async (page: Page) => { await page.goto(`${BASE}/futures?v=accounts&mock=1`); await expect(page.getByTestId('live-card')).toBeVisible(); await expect(page.getByTestId('live-create-testnet')).toBeVisible(); };53 /** the limits form with room for the test orders, then the review, the acknowledgement and the (simulated) passkey */54 const authorize = async (page: Page, card: Locator, o: { shots?: boolean } = {}) => {55 await card.getByTestId('authorize').click();56 const sheet = page.getByTestId('authorize-sheet');57 await expect(sheet).toHaveAttribute('data-step', 'form');58 if (o.shots) await shot(page, '03-authorize-form');59 // the per-order limit may not exceed the per-position limit: the form says so and offers nothing to approve60 await sheet.getByRole('textbox', { name: 'Valeur maximale par ordre' }).fill('200');61 await expect(sheet.getByTestId('policy-problem')).toContainText('ne peut pas dépasser la limite par position');62 await expect(sheet.getByRole('button', { name: /Continuer/ })).toBeDisabled();63 await sheet.getByRole('textbox', { name: 'Position maximale par marché' }).fill('400');64 await sheet.getByRole('textbox', { name: 'Exposition brute maximale' }).fill('600');65 await sheet.getByRole('button', { name: /Continuer/ }).click();66 await expect(sheet).toHaveAttribute('data-step', 'review');67 // 1. the jurisdiction text, in full, in the screen's language; nothing can be approved before it is acknowledged68 await expect(sheet.getByTestId('ack-text')).toContainText('Hyperliquid — conditions de la plateforme et votre juridiction (Québec, Canada)');69 await expect(sheet.getByTestId('ack-text')).toContainText('En approuvant, vous confirmez avoir lu ce résumé');70 await expect(sheet.getByTestId('auth-approve')).toBeDisabled();71 // 2. what is signed, 3. the trading key, what it can and cannot do, 4. the limits as the server recorded them72 await expect(sheet.getByTestId('what-is-signed')).toContainText('approveAgent');73 await expect(sheet.getByTestId('agent-can')).toContainText('placer des ordres');74 await expect(sheet.getByTestId('agent-can')).toContainText('retirer des fonds');75 await expect(sheet.getByTestId('policy-rows')).toContainText(/200,00\s\$/);76 await expect(sheet.getByTestId('integrity')).toHaveCount(0);77 if (o.shots) await shot(page, '04-authorize-review');78 await sheet.getByTestId('ack-check').check();79 if (o.shots) { await sheet.getByTestId('auth-approve').scrollIntoViewIfNeeded(); await shot(page, '05-authorize-ready'); }80 await sheet.getByTestId('auth-approve').click();81 };82 const setup = async (page: Page, o: { shots?: boolean } = {}) => {83 await openAccounts(page);84 await page.getByTestId('live-create-testnet').getByRole('button', { name: 'Créer le compte testnet' }).click();85 const card = page.getByTestId('live-account-testnet');86 await expect(card).toHaveAttribute('data-mode', 'READ_ONLY');87 await authorize(page, card, o);88 await expect(card).toHaveAttribute('data-mode', 'MANUAL_LIVE', { timeout: 10_000 });89 return card;90 };91 const toTerminal = async (page: Page) => {92 await view(page, 'Terminal');93 await expect(page.getByTestId('terminal')).toHaveAttribute('data-tone', 'testnet');94 await panel(page, 'Ordre');95 await expect(page.getByTestId('order-panel')).toHaveAttribute('data-account-kind', 'venue');96 };97 const form = async (page: Page, o: { kind?: 'Au marché' | 'À cours limité'; price?: string; size: string; side?: 'Short' }) => {98 const p = page.getByTestId('order-panel');99 if (o.side) await p.getByRole('radio', { name: o.side }).click();100 if (o.kind) await p.getByRole('radio', { name: o.kind, exact: true }).click();101 if (o.price !== undefined) await p.getByRole('textbox', { name: /Prix limite/ }).fill(o.price);102 await p.getByRole('textbox', { name: 'Taille' }).fill(o.size);103 };104 const review = async (page: Page) => {105 await expect(page.getByTestId('preview')).toContainText('Valeur notionnelle');106 await expect(page.getByTestId('live-review')).toBeEnabled();107 await page.getByTestId('live-review').click();108 const sheet = page.getByTestId('approval-sheet');109 await expect(sheet).toHaveAttribute('data-phase', 'ready');110 return sheet;111 };112 const approve = async (sheet: Locator) => { await sheet.getByRole('button', { name: /Approuver avec la clé d’accès/ }).click(); };113 const limitPrice = async (page: Page, factor: number) => String(Math.round((await mark(page)) * factor));114115 test('testnet: create → authorise → limit order open → cancel → market → position → halt blocks a new order, allows the close → revoke', async ({ page }) => {116 test.setTimeout(150_000);117 const errors = errorsOf(page);118 await openAccounts(page);119 // before anything: off, mainnet shown and blocked with the reason, nothing that pretends120 const live = page.getByTestId('live-card');121 await expect(live.getByTestId('live-status')).toContainText('désactivée');122 await expect(live.getByTestId('mainnet-setting')).toContainText('n’est pas un interrupteur de cette interface');123 await expect(live.getByTestId('live-create-mainnet').getByRole('button', { name: 'Créer le compte réel' })).toBeDisabled();124 await expect(live.getByTestId('mainnet-blocked')).toContainText('le serveur garde le réseau principal désactivé');125 await live.scrollIntoViewIfNeeded();126 await shot(page, '01-accounts-off');127 await live.getByRole('button', { name: 'Créer le compte testnet' }).click();128 const card = page.getByTestId('live-account-testnet');129 await expect(card).toHaveAttribute('data-mode', 'READ_ONLY');130 await expect(card.getByTestId('mode-band')).toHaveAttribute('data-tone', 'testnet');131 await expect(card.getByTestId('mode-band')).toContainText(/Testnet — argent fictif/i);132 await expect(card.getByTestId('venue-state')).toContainText(/1\s000,00\s\$/);133 await expect(card.getByTestId('venue-state')).toContainText('plateforme');134 await expect(card.getByTestId('trading-key')).toContainText('0x7e57');135 await expect(card.getByTestId('trading-key')).toContainText('Elle ne peut ni retirer des fonds');136 // funding is explained, never offered: deposits and withdrawals are not built137 await expect(card.getByTestId('funding-box')).toContainText('« Déposer » et « Retirer » se font ci-dessous');138 await expect(card.getByTestId('funding-box').getByRole('button')).toHaveCount(0);139 await card.scrollIntoViewIfNeeded();140 await shot(page, '02-account-readonly', true);141 await authorize(page, card, { shots: true });142 await expect(card).toHaveAttribute('data-mode', 'MANUAL_LIVE', { timeout: 10_000 });143 await expect(live.getByTestId('live-status')).toContainText('autorisée');144 await expect(card.getByTestId('live-authorization')).toContainText('BTC');145 await card.scrollIntoViewIfNeeded();146 await shot(page, '06-account-authorized', true);147148 // ---- the terminal: the band says which money, permanently149 await toTerminal(page);150 const band = page.getByTestId('terminal').getByTestId('mode-band').first();151 await expect(band).toContainText(/Testnet — argent fictif/i);152 await expect(band.getByRole('button')).toHaveCount(0); // nothing dismisses it153 await expect(page.getByTestId('paper-banner')).toContainText('Compte TESTNET');154 await expect(page.getByTestId('order-panel').getByTestId('free-collateral')).toContainText(/1\s000,00\s\$/);155156 // ---- limit order: preview → proposal → the canonical order → passkey → open (from the server)157 await form(page, { price: await limitPrice(page, 0.99), size: '0,001' });158 await expect(page.getByTestId('preview')).toContainText('estimation');159 await shot(page, '07-order-preview', phone);160 let sheet = await review(page);161 await expect(page.getByTestId('live-review')).toBeDisabled(); // a proposal is pending: no second one162 const canonical = sheet.getByTestId('canonical');163 await expect(canonical).toContainText('hyperliquid:BTC');164 await expect(canonical).toContainText('Achat');165 await expect(canonical).toContainText('Limite');166 await expect(canonical).toContainText('0,001 BTC');167 await expect(canonical).toContainText('jusqu’à annulation (GTC)');168 await expect(canonical).toContainText('testnet');169 await expect(sheet.getByTestId('countdown')).toContainText(/Approbation possible encore 1:\d\d/);170 await expect(sheet.getByTestId('mode-band')).toHaveAttribute('data-tone', 'testnet');171 await shot(page, '08-approve-sheet');172 await approve(sheet);173 await expect(sheet.getByTestId('live-outcome')).toHaveAttribute('data-state', 'open', { timeout: 10_000 });174 await expect(sheet.getByTestId('live-outcome')).toContainText('Ordre ouvert sur la plateforme');175 await shot(page, '09-order-open');176 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();177 await expect(page.getByTestId('order-panel').getByTestId('live-outcome')).toHaveAttribute('data-state', 'open');178 await panel(page, 'Positions');179 const blotter = page.getByTestId('blotter');180 await blotterTab(page, /^Ordres ouverts \(1\)/);181 await expect(blotter).toContainText('ouvert');182 await expect(blotter).toContainText('plateforme');183 await shot(page, '10-open-order', phone);184 await blotter.getByRole('button', { name: 'Annuler l’ordre sur BTC' }).click(); // no passkey185 await expect(blotter).toContainText('Aucun ordre ouvert.');186 await blotterTab(page, /^Historique/);187 await expect(blotter.locator('[data-state="cancelled"]').first()).toBeVisible();188189 // ---- market order: it is a limit IOC at its worst price, and the sheet says so190 await panel(page, 'Ordre');191 await form(page, { kind: 'Au marché', size: '0,002' });192 sheet = await review(page);193 await expect(sheet.getByTestId('canonical')).toContainText('ordre au marché = limite « immédiat ou annulé » au pire prix');194 await expect(sheet.getByTestId('canonical')).toContainText('Pire prix accepté');195 await expect(sheet.getByTestId('canonical')).toContainText('immédiat ou annulé (IOC)');196 await approve(sheet);197 await expect(sheet.getByTestId('live-outcome')).toHaveAttribute('data-state', 'filled', { timeout: 10_000 });198 await expect(sheet.getByTestId('live-outcome')).toContainText(/Ordre exécuté\s: 0,002 BTC à/);199 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();200 await panel(page, 'Positions');201 await blotterTab(page, /^Positions \(1\)/);202 await expect(blotter).toContainText('Long');203 await shot(page, '11-position', phone);204 await blotterTab(page, /^Exécutions/);205 await expect(blotter).toContainText('Achat');206207 // ---- halt: a new order is refused, the close still passes208 await panel(page, 'Ordre');209 await page.getByTestId('live-controls').getByRole('button', { name: 'Suspendre les nouveaux ordres' }).click();210 await expect(page.getByTestId('halt-banner').first()).toContainText('Négociation suspendue sur ce compte.');211 await form(page, { kind: 'À cours limité', price: await limitPrice(page, 0.99), size: '0,001' });212 await expect(page.getByTestId('risk-refusal')).toContainText('La négociation est suspendue sur ce compte');213 await expect(page.getByTestId('live-review')).toBeDisabled();214 await page.getByTestId('halt-banner').first().scrollIntoViewIfNeeded();215 await shot(page, '12-halted', phone);216 await panel(page, 'Positions');217 await blotterTab(page, /^Positions \(1\)/);218 await blotter.getByRole('button', { name: 'Fermer 100 % de la position BTC' }).click();219 sheet = page.getByTestId('approval-sheet');220 await expect(sheet).toHaveAttribute('data-phase', 'ready');221 await expect(sheet.getByTestId('canonical')).toContainText('Vente');222 await expect(sheet.getByTestId('canonical')).toContainText(/Réduction seulement\s*oui/);223 await shot(page, '13-close-sheet');224 await approve(sheet);225 await expect(sheet.getByTestId('live-outcome')).toHaveAttribute('data-state', 'filled', { timeout: 10_000 });226 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();227 await expect(blotter).toContainText('Aucune position.');228 await blotterTab(page, /^Risque/);229 await expect(blotter).toContainText('Négociation suspendue');230 await panel(page, 'Ordre');231 await page.getByTestId('halt-banner').first().getByRole('button', { name: 'Reprendre la négociation' }).click();232 await expect(page.getByTestId('halt-banner')).toHaveCount(0);233234 // ---- revoke: no passkey, the account is read-only again235 await view(page, 'Comptes');236 await page.getByTestId('live-account-testnet').getByTestId('revoke').click();237 const rv = page.getByTestId('revoke-sheet');238 await expect(rv).toContainText('pas même une annulation');239 await expect(rv.getByTestId('revoke-cancel-first')).toBeChecked();240 await shot(page, '14-revoke');241 await rv.getByRole('button', { name: 'Révoquer maintenant' }).click();242 await expect(rv.getByTestId('revoke-done')).toContainText('Autorisation révoquée');243 await rv.getByRole('button', { name: 'Fermer', exact: true }).last().click();244 await expect(page.getByTestId('live-account-testnet')).toHaveAttribute('data-mode', 'READ_ONLY');245 await view(page, 'Terminal');246 await panel(page, 'Ordre');247 await expect(page.getByTestId('live-readonly')).toContainText('révoquée');248 await expect(page.getByTestId('order-panel').getByRole('textbox')).toHaveCount(0);249 expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);250 expect(errors).toEqual([]);251 });252253 test('refusals: price band (risk engine, then signer), order notional, cancelled passkey, expired proposal', async ({ page }) => {254 test.setTimeout(150_000);255 await setup(page);256 await toTerminal(page);257 // far from the market: the risk engine refuses in the preview, and nothing can be reviewed258 await form(page, { price: await limitPrice(page, 0.9), size: '0,001' });259 await expect(page.getByTestId('risk-refusal')).toContainText('price_deviation');260 await expect(page.getByTestId('live-review')).toBeDisabled();261 // above the notional the signer will sign per order262 await form(page, { price: await limitPrice(page, 0.99), size: '0,01' });263 await expect(page.getByTestId('risk-refusal')).toContainText('order_notional_exceeded');264 await expect(page.getByTestId('risk-refusal')).toContainText('Le signataire plafonne chaque ordre');265 await expect(page.getByTestId('live-review')).toBeDisabled();266 await shot(page, '15-refused-notional', phone);267 // the signer's own price is elsewhere: the API accepts, the signer refuses at approval, the proposal is consumed268 await hook(page, 'signerOffsetBps', 1500);269 await form(page, { price: await limitPrice(page, 0.99), size: '0,001' });270 let sheet = await review(page);271 await approve(sheet);272 await expect(sheet).toHaveAttribute('data-phase', 'void', { timeout: 10_000 });273 await expect(sheet.getByTestId('live-error')).toHaveAttribute('data-code', 'signer_price');274 await expect(sheet.getByTestId('live-error')).toContainText('sort de la bande de prix');275 await expect(sheet.getByRole('button', { name: /Approuver avec la clé d’accès/ })).toHaveCount(0);276 await shot(page, '16-refused-signer');277 await hook(page, 'signerOffsetBps', 0);278 // a dismissed passkey prompt: nothing was sent, the proposal is withdrawn, "prepare again" makes a new one279 await hook(page, 'passkey', 'cancel');280 await sheet.getByTestId('prepare-again').click();281 await expect(sheet).toHaveAttribute('data-phase', 'ready');282 await approve(sheet);283 await expect(sheet.getByTestId('live-error')).toHaveAttribute('data-code', 'passkey_cancelled');284 await expect(sheet.getByTestId('live-error')).toContainText('Rien n’a été signé ni envoyé');285 await shot(page, '17-passkey-cancelled');286 await sheet.getByTestId('prepare-again').click();287 await expect(sheet).toHaveAttribute('data-phase', 'ready');288 await approve(sheet);289 await expect(sheet.getByTestId('live-outcome')).toHaveAttribute('data-state', 'open', { timeout: 10_000 });290 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();291 // an expired proposal cannot be approved: the button goes away, "prepare again" is offered292 await hook(page, 'ttl', 7000);293 await form(page, { price: await limitPrice(page, 0.99), size: '0,001' });294 sheet = await review(page);295 await expect(sheet).toHaveAttribute('data-phase', 'void', { timeout: 12_000 });296 await expect(sheet.getByTestId('live-error')).toHaveAttribute('data-code', 'proposal_expired');297 await expect(sheet.getByRole('button', { name: /Approuver avec la clé d’accès/ })).toHaveCount(0);298 await shot(page, '18-expired');299 await hook(page, 'ttl', 90_000);300 await sheet.getByRole('button', { name: 'Annuler', exact: true }).click();301 // every proposal of this test has its own key; the three that were given up are withdrawn or refused, one order rests302 await panel(page, 'Positions');303 await blotterTab(page, /^Ordres ouverts \(1\)/);304 await blotterTab(page, /^Historique/);305 const states = await page.getByTestId('blotter').locator('[data-state]').evaluateAll((els) => els.map((e) => e.getAttribute('data-state')));306 expect(states.filter((s) => s === 'open')).toHaveLength(1);307 expect(states.filter((s) => s === 'risk_checked' || s === 'proposed')).toHaveLength(0);308 });309310 test('venue answers: unknown → reconciled, a venue refusal in its own words; double click makes one proposal; leverage change', async ({ page }) => {311 test.setTimeout(120_000);312 await setup(page);313 await toTerminal(page);314 const before = (await liveAccounts(page))[0]!.orders;315 await form(page, { price: await limitPrice(page, 0.99), size: '0,001' });316 await expect(page.getByTestId('preview')).toContainText('Valeur notionnelle');317 await expect(page.getByTestId('live-review')).toBeEnabled();318 await page.getByTestId('live-review').dblclick(); // two clicks, one review, one Idempotency-Key319 let sheet = page.getByTestId('approval-sheet');320 await expect(sheet).toHaveAttribute('data-phase', 'ready');321 expect((await liveAccounts(page))[0]!.orders).toBe(before + 1);322 await hook(page, 'venue', 'unknown');323 await sheet.getByRole('button', { name: /Approuver avec la clé d’accès/ }).dblclick(); // two clicks, one prompt, one approval324 await expect(sheet.getByTestId('live-outcome')).toHaveAttribute('data-state', 'unknown', { timeout: 10_000 });325 await expect(sheet.getByTestId('live-outcome')).toContainText('En cours de vérification auprès de la plateforme — ne pas renvoyer');326 await expect(sheet.getByTestId('prepare-again')).toHaveCount(0); // nothing that could send it again327 await shot(page, '19-unknown');328 await expect(sheet.getByTestId('live-outcome')).toHaveAttribute('data-state', 'open', { timeout: 15_000 }); // resolved by reconciliation, from the server329 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();330 expect((await liveAccounts(page))[0]!.orders).toBe(before + 1);331 // the venue refuses: its words are shown332 await hook(page, 'venue', 'reject');333 await form(page, { price: await limitPrice(page, 0.985), size: '0,001' });334 sheet = await review(page);335 await approve(sheet);336 await expect(sheet.getByTestId('live-outcome')).toHaveAttribute('data-state', 'rejected', { timeout: 10_000 });337 await expect(sheet.getByTestId('live-outcome')).toContainText('Order has invalid price.');338 await shot(page, '20-venue-rejected');339 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();340 // leverage: a venue setting, changed with the passkey, read back from the venue341 const lev = page.getByTestId('venue-leverage');342 await expect(lev).toContainText('20×');343 await lev.getByRole('button', { name: 'Modifier…' }).click();344 const ls = page.getByTestId('leverage-sheet');345 await ls.getByRole('slider').fill('2');346 await ls.getByRole('button', { name: 'Préparer le changement' }).click();347 await expect(ls).toHaveAttribute('data-phase', 'ready');348 await expect(ls.getByTestId('canonical')).toContainText('2×');349 await shot(page, '21-leverage');350 await ls.getByRole('button', { name: /Approuver avec la clé d’accès/ }).click();351 await expect(ls.getByTestId('leverage-result')).toHaveAttribute('data-state', 'confirmed', { timeout: 10_000 });352 await ls.getByRole('button', { name: 'Fermer', exact: true }).last().click();353 await expect(lev).toContainText('2×', { timeout: 10_000 });354 });355356 test('an unfunded account: the venue refuses the authorization, in its words, with the funding explanation', async ({ page }) => {357 await openAccounts(page);358 await hook(page, 'fund', 'testnet', '0');359 await page.getByTestId('live-create-testnet').getByRole('button', { name: 'Créer le compte testnet' }).click();360 const card = page.getByTestId('live-account-testnet');361 await expect(card.getByTestId('venue-state')).toContainText(/0,00\s\$/);362 await authorize(page, card);363 const sheet = page.getByTestId('authorize-sheet');364 await expect(sheet).toHaveAttribute('data-step', 'refused', { timeout: 10_000 });365 await expect(sheet.getByTestId('venue-words')).toContainText('Must deposit before performing actions. User: 0x5ad0');366 await expect(sheet.getByTestId('auth-refused')).toContainText('La plateforme a refusé d’enregistrer la clé de négociation.');367 await expect(sheet.getByTestId('auth-refused')).toContainText('n’accepte aucune action d’un compte qui n’a jamais reçu de dépôt');368 await expect(sheet.getByTestId('funding-box')).toContainText('robinet');369 await shot(page, '22-unfunded');370 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();371 await expect(card).toHaveAttribute('data-mode', 'READ_ONLY');372 await expect(page.getByTestId('live-status')).toContainText('désactivée');373 });374375 test('mainnet: real-money band when enabled; blocked with the reason when the server setting is off; divergence; English', async ({ page }) => {376 test.setTimeout(120_000);377 await page.goto(`${BASE}/futures?v=accounts&mock=1&mainnet=1`); // the mock starts with the server setting ON378 await expect(page.getByTestId('live-create-mainnet').getByRole('button', { name: 'Créer le compte réel' })).toBeEnabled();379 await expect(page.getByTestId('mainnet-setting')).toHaveCount(0);380 await page.getByTestId('live-create-mainnet').getByRole('button', { name: 'Créer le compte réel' }).click();381 const card = page.getByTestId('live-account-mainnet');382 await expect(card.getByTestId('mode-band')).toHaveAttribute('data-tone', 'mainnet');383 await expect(card.getByTestId('mode-band')).toContainText(/Réel — argent réel/i);384 await authorize(page, card);385 await expect(card).toHaveAttribute('data-mode', 'MANUAL_LIVE', { timeout: 10_000 });386 await view(page, 'Terminal');387 await expect(page.getByTestId('terminal')).toHaveAttribute('data-tone', 'mainnet');388 await expect(page.getByTestId('terminal').getByTestId('mode-band').first()).toContainText(/Réel — argent réel/i);389 await panel(page, 'Ordre');390 await expect(page.getByTestId('live-foot')).toContainText('ARGENT RÉEL');391 await form(page, { price: await limitPrice(page, 0.99), size: '0,001' });392 const sheet = await review(page);393 await expect(sheet.getByTestId('mode-band')).toHaveAttribute('data-tone', 'mainnet');394 await expect(sheet.getByRole('button', { name: 'Approuver avec la clé d’accès — ARGENT RÉEL' })).toBeVisible();395 await shot(page, '23-real-approve');396 await sheet.getByRole('button', { name: 'Annuler', exact: true }).click(); // withdraws the proposal: nothing is signed397 await expect(sheet).toHaveCount(0);398 if (phone) await page.getByRole('tab', { name: /^Graphique/ }).click();399 await shot(page, '24-real-terminal');400 // a difference with the venue stops new orders; "accept the venue's state" is explained and confirmed401 await panel(page, 'Ordre');402 await hook(page, 'diverge', 'mainnet', 'position');403 const hb = page.getByTestId('halt-banner').first();404 await expect(hb).toHaveAttribute('data-kind', 'divergence', { timeout: 15_000 });405 await expect(hb).toContainText('ne concordent pas');406 await hb.scrollIntoViewIfNeeded();407 await shot(page, '25-divergence', phone);408 await hb.getByRole('button', { name: 'Revérifier maintenant' }).click();409 await expect(hb).toContainText('l’écart est toujours là');410 await hb.getByRole('button', { name: 'Accepter l’état de la plateforme…' }).click();411 await page.getByRole('dialog', { name: 'Accepter l’état de la plateforme' }).getByRole('button', { name: /Oui, prendre l’état de la plateforme/ }).click();412 await expect(page.getByTestId('halt-banner')).toHaveCount(0, { timeout: 10_000 });413 // the server setting goes off: the account is shown, every action is blocked with the reason414 await hook(page, 'mainnet', false);415 await expect(page.getByTestId('order-panel').getByTestId('mainnet-off')).toContainText('Réseau principal désactivé sur le serveur.', { timeout: 30_000 });416 await expect(page.getByTestId('order-panel').getByRole('textbox')).toHaveCount(0);417 const refused = await page.evaluate(async () => { const id = (window as any).__spwMock.live.accounts().find((a: any) => a.network === 'mainnet').id;418 const r = await fetch(`/api/v1/derivatives/live/accounts/${id}/orders/preview`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-spw-csrf': '1' }, body: JSON.stringify({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.001', price: '70000' }) });419 return { status: r.status, error: (await r.json()).error }; });420 expect(refused).toEqual({ status: 403, error: 'mainnet_disabled' });421 await shot(page, '26-mainnet-off', phone);422 await view(page, 'Comptes');423 await expect(page.getByTestId('live-account-mainnet').getByTestId('mainnet-off')).toBeVisible();424 await expect(page.getByTestId('live-account-mainnet').getByTestId('revoke')).toBeEnabled(); // stopping is always possible425 // English426 await page.getByRole('navigation').getByRole('button', { name: 'Réglages' }).click();427 await page.getByRole('radio', { name: 'English' }).click();428 await page.getByRole('navigation').getByRole('button', { name: 'Swap' }).click();429 await page.getByRole('tab', { name: 'Futures' }).click();430 await expect(page.getByTestId('terminal').getByTestId('mode-band').first()).toContainText(/Real — real money/i);431 await page.getByRole('tablist', { name: 'Views' }).getByRole('tab', { name: 'Accounts' }).click();432 await expect(page.getByTestId('live-account-mainnet')).toContainText('Main network disabled on the server.');433 await page.getByTestId('live-account-mainnet').scrollIntoViewIfNeeded();434 await shot(page, '27-english', true);435 });436 });437}438