Repository Public
1import { spawn, type ChildProcess } from 'node:child_process';2import { existsSync, mkdirSync, readFileSync } from 'node:fs';3import { dirname, resolve } from 'node:path';4import { fileURLToPath } from 'node:url';5import { expect, test, type Page } from '@playwright/test';67// One pass of the live screens against the REAL backend, booted in-process by tests/e2e/live-real-server.mts: real API routes,8// real isolated signer (throwaway seed in memory), PostgreSQL, the BUILT web bundle (no mock), and Hyperliquid's TESTNET.9// The passkey is a Chrome virtual authenticator (CDP `WebAuthn.addVirtualAuthenticator`): the browser's own WebAuthn path runs,10// from the one-time owner setup to the approval of the venue authorization.11//12// The throwaway account never deposited, so the venue refuses the trading-key approval with "Must deposit before performing13// actions": the spec checks that the screens show that refusal, in the venue's words, with the funding explanation — and that14// nothing else breaks. Nothing is ever sent to the mainnet exchange endpoint (the server process refuses it, and the traffic15// it records is checked at the end).16//17// Off by default (it needs PostgreSQL, the network, and `dist/web`):18// (cd apps/web && node_modules/.bin/vite build) && LIVE_REAL=1 npx playwright test -c tests/e2e/playwright.config.ts futures-live-real.spec.ts19const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');20const SHOTS = resolve(ROOT, 'docs/v4/screens'), TRAFFIC = resolve(ROOT, 'test-results/live-real-traffic.json');21const SIZES = [22 { name: 'phone', port: 8672, use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } },23 { name: 'desktop', port: 8673, use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false } },24] as const;2526test.skip(process.env.LIVE_REAL !== '1', 'set LIVE_REAL=1 to run against the real backend and the Hyperliquid testnet');2728for (const size of SIZES) {29 const phone = size.name === 'phone', BASE = `http://localhost:${size.port}`;30 test.describe(`futures live, real backend — ${size.name}`, () => {31 test.use({ ...size.use, locale: 'fr-CA', baseURL: BASE });32 let server: ChildProcess | null = null, log = '';33 test.beforeAll(async () => {34 if (!existsSync(resolve(ROOT, 'dist/web/index.html'))) throw new Error('dist/web is missing: build the web bundle first (cd apps/web && node_modules/.bin/vite build)');35 mkdirSync(dirname(TRAFFIC), { recursive: true });36 server = spawn(resolve(ROOT, 'node_modules/.bin/tsx'), ['tests/e2e/live-real-server.mts', '--port', String(size.port), '--db', `spw_live_ui_e2e_${size.name}`, '--traffic', TRAFFIC], { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] });37 server.stdout!.on('data', (d) => { log += String(d); }); server.stderr!.on('data', (d) => { log += String(d); });38 for (let i = 0; i < 240 && !log.includes('READY'); i++) { if (server.exitCode !== null) break; await new Promise((r) => setTimeout(r, 250)); }39 if (!log.includes('READY')) throw new Error(`the real backend did not start:\n${log.slice(-2000)}`);40 });41 test.afterAll(async () => { server?.kill('SIGTERM'); await new Promise((r) => setTimeout(r, 1500)); });42 const shot = async (page: Page, name: string, fullPage = false) => { mkdirSync(SHOTS, { recursive: true }); await page.screenshot({ path: `${SHOTS}/live-real-${size.name}-${name}.png`, fullPage }); };4344 test('owner setup with a virtual passkey → testnet account → authorization refused by the venue (unfunded), shown with the funding explanation', async ({ page, context }) => {45 test.setTimeout(180_000);46 const errors: string[] = [], refused: string[] = [];47 page.on('pageerror', (e) => errors.push(String(e)));48 page.on('console', (m) => { if (m.type() === 'error' && !/Failed to load resource/.test(m.text())) errors.push(m.text()); });49 page.on('response', (r) => { if (r.status() >= 400 && r.url().includes('/api/')) refused.push(`${r.status()} ${new URL(r.url()).pathname.replace(/[0-9a-f-]{36}/g, ':id')}`); });50 // the browser's own WebAuthn, answered by a virtual platform authenticator with user verification51 const cdp = await context.newCDPSession(page);52 await cdp.send('WebAuthn.enable');53 await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });5455 // ---- one-time owner setup: the real signer enrols the passkey56 await page.goto('/');57 await page.getByRole('textbox').fill('e2e-setup-token');58 await page.getByRole('button', { name: 'Créer ma clé d’accès' }).click();59 await expect(page.getByRole('navigation').first()).toBeVisible({ timeout: 30_000 });6061 // ---- accounts: live trading is off, mainnet is shown and blocked (the server setting is off)62 await page.goto('/futures?v=accounts');63 const live = page.getByTestId('live-card');64 await expect(live.getByTestId('live-status')).toContainText('désactivée', { timeout: 30_000 });65 await expect(live.getByTestId('mainnet-setting')).toContainText('SPW_DERIV_LIVE_MAINNET');66 await expect(live.getByTestId('live-create-mainnet').getByRole('button', { name: 'Créer le compte réel' })).toBeDisabled();67 await live.getByRole('button', { name: 'Créer le compte testnet' }).click();68 const card = page.getByTestId('live-account-testnet');69 await expect(card).toHaveAttribute('data-mode', 'READ_ONLY', { timeout: 30_000 });70 await expect(card.getByTestId('mode-band')).toContainText(/Testnet — argent fictif/i);71 // the venue's own state of the throwaway account: nothing, read from the testnet72 await expect(card.getByTestId('venue-state')).toContainText(/0,00\s\$/, { timeout: 30_000 });73 await expect(card.getByTestId('venue-state')).toContainText('plateforme');74 const account = /account (0x[0-9a-f]{40})/.exec(log)![1]!;75 await expect(card).toContainText(account.slice(0, 6));76 await expect(card.getByTestId('funding-box')).toContainText('section 15'); // the server's own words about deposits77 await expect(card.getByTestId('funding-box').getByRole('button')).toHaveCount(0);78 await card.scrollIntoViewIfNeeded();79 await shot(page, '01-account-readonly', true);8081 // ---- authorization: limits → review (acknowledgement, what is signed, key, limits) → the REAL passkey prompt82 await card.getByTestId('authorize').click();83 const sheet = page.getByTestId('authorize-sheet');84 await sheet.getByRole('button', { name: /Continuer/ }).click();85 await expect(sheet).toHaveAttribute('data-step', 'review', { timeout: 30_000 });86 await expect(sheet.getByTestId('ack-text')).toContainText('Hyperliquid — conditions de la plateforme et votre juridiction (Québec, Canada)');87 await expect(sheet.getByTestId('integrity')).toHaveCount(0); // the displayed text hashes to what the authorization commits to88 await expect(sheet.getByTestId('what-is-signed')).toContainText('approveAgent');89 await expect(sheet.getByTestId('agent-can')).toContainText('placer des ordres');90 await expect(sheet.getByTestId('agent-can')).toContainText('retirer des fonds');91 await expect(sheet.getByTestId('auth-approve')).toBeDisabled();92 await shot(page, '02-authorize-review');93 await sheet.getByTestId('ack-check').check();94 await sheet.getByTestId('auth-approve').click();95 // the signer verified the passkey and signed approveAgent with the (throwaway) wallet key; the testnet refuses: no deposit96 await expect(sheet).toHaveAttribute('data-step', 'refused', { timeout: 60_000 });97 await expect(sheet.getByTestId('venue-words')).toContainText(`Must deposit before performing actions. User: ${account}`);98 await expect(sheet.getByTestId('auth-refused')).toContainText('La plateforme a refusé d’enregistrer la clé de négociation.');99 await expect(sheet.getByTestId('auth-refused')).toContainText('n’accepte aucune action d’un compte qui n’a jamais reçu de dépôt');100 await expect(sheet.getByTestId('funding-box')).toContainText('« Déposer » et « Retirer » se font ci-dessous');101 await shot(page, '03-venue-refused');102 await sheet.getByRole('button', { name: 'Fermer', exact: true }).last().click();103 await expect(card).toHaveAttribute('data-mode', 'READ_ONLY');104 await expect(live.getByTestId('live-status')).toContainText('désactivée');105 await expect(card.getByTestId('revoke')).toHaveCount(0); // nothing is left to revoke106107 // ---- the terminal with the testnet account: the band, real market data, and no way to send an order108 await page.getByRole('tablist', { name: 'Vues' }).getByRole('tab', { name: 'Terminal' }).click();109 await expect(page.getByTestId('terminal')).toHaveAttribute('data-tone', 'testnet');110 await expect(page.getByTestId('terminal').getByTestId('mode-band').first()).toContainText(/Testnet — argent fictif/i);111 await expect(page.getByTestId('freshness')).toHaveAttribute('data-state', 'live', { timeout: 30_000 });112 await shot(page, '04-terminal-testnet');113 if (phone) await page.getByRole('tab', { name: /^Ordre/ }).click();114 const panel = page.getByTestId('order-panel');115 await expect(panel).toHaveAttribute('data-account-kind', 'venue');116 await expect(panel.getByTestId('live-readonly')).toContainText('lecture seule');117 await expect(panel.getByRole('textbox')).toHaveCount(0);118 await expect(panel.getByTestId('live-review')).toHaveCount(0);119 await shot(page, '05-order-panel-readonly', phone);120 if (phone) await page.getByRole('tab', { name: /^Positions/ }).click();121 await expect(page.getByTestId('blotter')).toHaveAttribute('data-account-kind', 'venue');122 await expect(page.getByTestId('blotter')).toContainText('Aucune position.');123 // the server refuses an order on a READ_ONLY account whatever the screen does124 const direct = await page.evaluate(async () => { const id = (await (await fetch('/api/v1/derivatives/live/accounts')).json()).accounts[0].id;125 const r = await fetch(`/api/v1/derivatives/live/accounts/${id}/orders`, { method: 'POST', headers: { 'content-type': 'application/json', 'x-spw-csrf': '1', 'idempotency-key': `e2e-${crypto.randomUUID()}` }, body: JSON.stringify({ instrument: 'hyperliquid:BTC', side: 'buy', type: 'limit', size: '0.001', price: '50000' }) });126 return { status: r.status, error: (await r.json()).error }; });127 expect(direct).toEqual({ status: 422, error: 'mode_read_only' });128129 // ---- what happened on the wire130 await page.waitForTimeout(1500);131 const traffic = JSON.parse(readFileSync(TRAFFIC, 'utf8')) as Record<string, number>;132 console.log(`[live-real ${size.name}] outgoing requests of the backend: ${JSON.stringify(traffic)}`);133 console.log(`[live-real ${size.name}] API answers ≥ 400 seen by the page: ${JSON.stringify(refused)}`);134 expect(Object.keys(traffic).filter((k) => k.startsWith('REFUSED'))).toEqual([]); // nothing even tried the mainnet exchange endpoint135 expect(traffic['api.hyperliquid.xyz/exchange']).toBeUndefined();136 expect(traffic['api.hyperliquid-testnet.xyz/exchange']).toBe(1); // exactly one action reached the testnet: the approveAgent the venue refused137 expect(refused.filter((r) => !/^422 .*authorization\/approve$/.test(r) && !/^422 .*\/orders$/.test(r))).toEqual([]);138 expect(errors).toEqual([]);139 });140 });141}142