SPB Git

Repository Public

futures-live-real.spec.ts 11.5 KB 142 lines typescript

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

Theme