SPB Git

Repository Public

perp-agent-live.spec.ts 23.5 KB 331 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// v7 — the REAL mode of a perp agent against the in-browser mock (`?mock=1`, development build only): no backend, no venue,8// no signer, a simulated passkey. What is real in the mock: the canonical mandate (schema, hash, validity against the9// authorization), the jurisdiction text and its hash, the server's French and English sentences (a copy of live-texts.ts);10// what is not: the engine, the venue (a paper account — play money whatever the network is called) and the signer.11// See apps/web/src/derivatives/{mockLive,mockPerpLive}.ts. The spec starts its own Vite dev server (or uses FUTURES_URL).12// PERP_SHOTS=1 writes docs/v7/screens/*.png.13//   npx playwright test -c tests/e2e/playwright.config.ts perp-agent-live.spec.ts14const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');15const PORT = 5191;16const BASE = process.env.FUTURES_URL ?? `http://localhost:${PORT}`;17const SHOTS = resolve(ROOT, 'docs/v7/screens');18const AGENT = '7e57a9e1-0000-4000-8000-000000000001';19let vite: ChildProcess | null = null;2021test.beforeAll(async () => {22  if (process.env.FUTURES_URL) return;23  vite = spawn(resolve(ROOT, 'apps/web/node_modules/.bin/vite'), ['--port', String(PORT), '--strictPort'], { cwd: resolve(ROOT, 'apps/web'), stdio: 'ignore' });24  for (let i = 0; i < 80; i++) {25    try { if ((await fetch(`${BASE}/`)).ok) return; } catch { /* not up yet */ }26    await new Promise((r) => setTimeout(r, 250));27  }28  throw new Error('the Vite dev server did not start');29});30test.afterAll(() => { vite?.kill(); });3132const 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; };33type Scenario = 'paper' | 'no_strategy' | 'ready' | 'small' | 'live' | 'mainnet_locked';34const perp = (page: Page, name: 'scenario' | 'holdStop' | 'exit' | 'stopOpen', ...args: unknown[]) => page.evaluate(([n, a]) => (window as any).__spwMock.perp[n as string](...(a as unknown[])), [name, args] as const);35const liveHook = (page: Page, name: string, ...args: unknown[]) => page.evaluate(([n, a]) => (window as any).__spwMock.live[n as string](...(a as unknown[])), [name, args] as const);36const perpState = (page: Page): Promise<{ state: string; mode: string; accountId: string | null }> => page.evaluate(() => (window as any).__spwMock.perp.state());3738const SIZES = [39  { name: 'phone', use: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } },40  { name: 'desktop', use: { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1, isMobile: false, hasTouch: false } },41] as const;4243for (const size of SIZES) {44  test.describe(`perp agent live — ${size.name}`, () => {45    test.use({ ...size.use, locale: 'fr-CA' });46    const shot = async (page: Page, name: string, fullPage = false) => {47      if (process.env.PERP_SHOTS !== '1') return;48      mkdirSync(SHOTS, { recursive: true });49      await page.screenshot({ path: `${SHOTS}/${size.name}-${name}.png`, fullPage });50    };51    /** the agent page with the mock's scripted agent in a scenario (`&perp=`; the mock lives in the page, a full load starts the scenario again) */52    const open = async (page: Page, scenario: Scenario) => {53      await page.goto(`${BASE}/agents/${AGENT}?mock=1&perp=${scenario}`);54      await expect(page.getByRole('heading', { name: 'BTC momentum' })).toBeVisible({ timeout: 30_000 });   // the dev server compiles the page on its first load55    };56    /** read the agent again without reloading the page: back to the list, then the agent */57    const refresh = async (page: Page) => {58      await page.getByRole('button', { name: 'Retour' }).click();59      await page.getByRole('button', { name: /BTC momentum/ }).click();60      await expect(page.getByRole('heading', { name: 'BTC momentum' })).toBeVisible();61    };62    const golive = (page: Page) => page.getByTestId('pl-golive');63    const noOverflow = async (page: Page) => expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);6465    test('go-live checklist: each step names the next action; the account step sends to Futures → Comptes', async ({ page }) => {66      const errors = errorsOf(page);67      // 1 — nothing approved: the strategy is the next step; the page is a SIMULATION and says so68      await open(page, 'no_strategy');69      await expect(page.getByText('SIMULATION').first()).toBeVisible();70      await expect(page.getByTestId('agent-band')).toHaveCount(0);71      await expect(golive(page)).toHaveAttribute('data-next', 'strategy');72      await expect(page.getByTestId('pl-step-strategy')).toHaveAttribute('data-state', 'next');73      await expect(page.getByTestId('pl-step-account')).toHaveAttribute('data-state', 'later');74      await expect(page.getByTestId('pl-next')).toHaveText('Choisir et approuver une stratégie');75      await golive(page).scrollIntoViewIfNeeded();76      await shot(page, '01-wizard-strategy');77      // approving the tested version moves the checklist on78      await page.getByRole('button', { name: 'Approuver cette version' }).click();79      await expect(golive(page)).toHaveAttribute('data-next', 'account');80      await expect(page.getByTestId('pl-step-strategy')).toHaveAttribute('data-state', 'done');81      await expect(page.getByTestId('pl-step-strategy')).toContainText('son empreinte sera inscrite dans le mandat');8283      // 2 — no venue account: created and authorised by the owner in Futures → Comptes, linked from here84      await expect(page.getByTestId('pl-account-why')).toHaveAttribute('data-code', 'no_account');85      await expect(page.getByTestId('pl-step-funded')).toContainText('Pourquoi 50 USDC');86      await golive(page).scrollIntoViewIfNeeded();87      await shot(page, '02-wizard-account');88      await page.getByTestId('pl-next').click();89      await expect(page).toHaveURL(/\/futures\?v=accounts/);90      await expect(page.getByTestId('live-card')).toBeVisible();91      // the accounts view speaks of mandates with the V6 config: available, and locked on mainnet by a SERVER SETTING (the server's own French sentence)92      await expect(page.getByTestId('mandate-mode-note')).toContainText('Il s’active depuis la page d’un agent');93      await expect(page.getByTestId('mandate-mainnet-setting')).toContainText('réglage du serveur');94      await expect(page.getByTestId('mandate-mainnet-setting')).toContainText('SPW_DERIV_LIVE_MAINNET=on ET SPW_DERIV_MANDATE_MAINNET=on');95      await expect(page.getByTestId('mandate-mainnet-setting').getByRole('button')).toHaveCount(0);96      await expect(page.getByTestId('mandate-mainnet-setting').getByRole('checkbox')).toHaveCount(0);97      await shot(page, '03-accounts-mandate-setting', true);9899      // 3 — an authorised account below the minimum: the funded step is next, with the computed minimum explained100      await open(page, 'small');101      await expect(golive(page)).toHaveAttribute('data-next', 'fund');102      await expect(page.getByTestId('pl-step-account')).toHaveAttribute('data-state', 'done');103      await expect(page.getByTestId('pl-step-account')).toContainText('testnet — argent fictif');104      await expect(page.getByTestId('pl-funded-why')).toHaveAttribute('data-code', 'below_minimum');105      await expect(page.getByTestId('pl-funded-why')).toContainText('Il en faut au moins 50');106      await expect(page.getByTestId('pl-fund-why')).toContainText('l’ordre minimal de la plateforme est de 10 USD');107      await expect(page.getByTestId('pl-next')).toHaveText(/Voir comment alimenter le compte/);108      await golive(page).scrollIntoViewIfNeeded();109      await shot(page, '04-wizard-fund');110      await noOverflow(page);111      expect(errors).toEqual([]);112    });113114    test('mandate review: limits from the agent\'s settings, the acknowledgement checkbox is mandatory, ONE passkey, then LIVE on the testnet', async ({ page }) => {115      const errors = errorsOf(page);116      await open(page, 'ready');117      await expect(golive(page)).toHaveAttribute('data-next', 'mandate');118      for (const s of ['strategy', 'account', 'funded']) await expect(page.getByTestId(`pl-step-${s}`)).toHaveAttribute('data-state', 'done');119      await expect(page.getByTestId('pl-step-mandate')).toHaveAttribute('data-state', 'next');120      await golive(page).scrollIntoViewIfNeeded();121      await shot(page, '05-wizard-mandate-next');122      await page.getByTestId('pl-next').click();123      const sheet = page.getByTestId('mandate-sheet');124      await expect(sheet).toHaveAttribute('data-phase', 'form');125      await expect(sheet.getByTestId('mode-band')).toHaveAttribute('data-tone', 'testnet');126      // the reference capital is the venue account's value, not a field; a duration beyond what the authorization leaves: nothing to prepare127      await expect(sheet.getByTestId('mandate-capital')).toContainText('1 000,00 USDC — la valeur du compte de la plateforme');128      await expect(sheet.getByTestId('mandate-capital').getByRole('textbox')).toHaveCount(0);129      await sheet.getByRole('textbox', { name: 'Durée du mandat' }).fill('31');130      await expect(sheet.getByTestId('mandate-problem')).toContainText(/La durée doit être de 1 à 2[89] jours/);131      await expect(sheet.getByTestId('mandate-continue')).toBeDisabled();132      await sheet.getByRole('textbox', { name: 'Durée du mandat' }).fill('14');133      await shot(page, '06-mandate-form');134      await sheet.getByTestId('mandate-continue').click();135      await expect(sheet).toHaveAttribute('data-phase', 'review');136137      // what is shown before the passkey: the server's warning, every limit, the pinned strategy hash, the network, the texts138      await expect(sheet.getByTestId('mandate-warning')).toContainText('SANS demander votre clé d\'accès');139      await expect(sheet.getByTestId('mandate-network')).toHaveText('testnet — argent fictif');140      await expect(sheet.getByTestId('mandate-sha')).toHaveText('c0de5eed'.repeat(8));141      const limits = sheet.getByTestId('mandate-limits');142      await expect(limits).toContainText('BTC : levier max. / position max.');143      await expect(limits).toContainText('2× / 1 000,00 $');144      await expect(limits).toContainText('Plancher de valeur du compte');145      await expect(limits).toContainText('900,00 $');                                  // 1 000 less the 10 % total-loss limit146      await expect(sheet.getByTestId('ack-text')).toContainText('Hyperliquid — conditions de la plateforme et votre juridiction (Québec, Canada)');147      await expect(sheet.getByTestId('mandate-enforced')).toContainText('lues par le signataire lui-même');148      await expect(sheet.getByTestId('mandate-trusted')).toContainText('point d\'accès public d\'information de la plateforme');149      await expect(sheet.getByTestId('mandate-not-enforced')).toContainText('consignée, pas vérifiée');150      await expect(sheet.getByTestId('mandate-winddown')).toContainText('Pendant 24 heures');151      await expect(sheet.getByTestId('mandate-integrity')).toHaveCount(0);152      await shot(page, '07-mandate-review-top');153      // the checkbox is mandatory154      await expect(sheet.getByTestId('mandate-approve')).toBeDisabled();155      await expect(sheet.getByTestId('mandate-approve')).toHaveText(/testnet/);156      await sheet.getByTestId('mandate-approve').scrollIntoViewIfNeeded();157      await shot(page, '08-mandate-review-bottom');158      // a dismissed passkey prompt consumes nothing on the venue and leaves the agent in simulation159      await sheet.getByTestId('ack-check').check();160      await liveHook(page, 'passkey', 'cancel');161      await sheet.getByTestId('mandate-approve').click();162      await expect(sheet).toHaveAttribute('data-phase', 'void');163      await expect(sheet.getByTestId('live-error')).toHaveAttribute('data-code', 'passkey_cancelled');164      expect((await perpState(page)).mode).toBe('PAPER');165      await sheet.getByTestId('mandate-again').click();166      await sheet.getByTestId('mandate-continue').click();167      await expect(sheet).toHaveAttribute('data-phase', 'review');168      await sheet.getByTestId('ack-check').check();169      await sheet.getByTestId('mandate-approve').click();                                // ONE passkey prompt170      await expect(sheet.getByTestId('mandate-done')).toBeVisible();171      expect(await perpState(page)).toMatchObject({ state: 'LIVE', mode: 'LIVE' });172      await shot(page, '09-mandate-approved');173      await sheet.getByTestId('mandate-close').click();174175      // the page is now the LIVE page of a TESTNET agent: the band says play money, never « réel »176      const band = page.getByTestId('agent-band');177      await expect(band).toHaveAttribute('data-tone', 'testnet');178      await expect(band).toContainText('Testnet — argent fictif');179      await expect(page.getByTestId('pl-signal-note')).toHaveText('Sur le testnet : signaux calculés sur les barres du marché réel ; ordres exécutés sur le carnet du testnet, argent fictif.');180      await expect(page.getByTestId('agent-live-state')).toContainText('En marche');181      await expect(page.getByTestId('agent-live-state')).not.toContainText('Réel');182      await expect(page.getByText('SIMULATION', { exact: true })).toHaveCount(0);183      await expect(page.getByTestId('pl-mandate-phase')).toHaveText('En vigueur');184      await noOverflow(page);185      expect(errors).toEqual([]);186    });187188    test('LIVE testnet agent: venue account, mandate card with the signer\'s counters, holds in plain words, pause, stop → wind-down → STOPPED flat', async ({ page }) => {189      const errors = errorsOf(page);190      await open(page, 'live');191      const band = page.getByTestId('agent-band');192      await expect(band).toHaveAttribute('data-tone', 'testnet');193      await expect(band.getByTestId('mode-band')).toContainText('Testnet — argent fictif');194      await expect(page.getByText('Réel — argent réel')).toHaveCount(0);195      // the venue account: read on the venue, one long position196      const acct = page.getByTestId('pl-account');197      await expect(acct).toHaveAttribute('data-network', 'testnet');198      await expect(acct.getByTestId('mode-tag')).toHaveAttribute('data-tone', 'testnet');199      await expect(acct).toContainText('Capital de référence : 1 000,00 USDC');200      await expect(acct.getByText('BTC', { exact: true }).first()).toBeVisible();201      await expect(acct).toContainText(/Long|Achat/i);202      await shot(page, '10-live-top');203      // the mandate card: limits, expiry, the signer's counters of the day204      const mandate = page.getByTestId('pl-mandate');205      await expect(mandate).toHaveAttribute('data-phase', 'active');206      await expect(mandate.getByTestId('pl-usage')).toContainText('Ordres ajoutant de l’exposition');207      await expect(mandate.getByTestId('pl-usage')).toContainText('1 / 48');208      await expect(mandate.getByTestId('pl-floor')).toContainText('Plancher 900,00 USDC');209      await mandate.scrollIntoViewIfNeeded();210      await shot(page, '11-live-mandate-card');211      // holds and refusals: our sentence (what happened, what to do), then the server's own words212      const holds = page.getByTestId('pl-holds');213      await expect(holds.locator('[data-code="signer_position_exceeded"]')).toContainText('la position sur ce marché aurait pu dépasser la limite du mandat');214      await expect(holds.locator('[data-code="signer_position_exceeded"]')).toContainText('worst-case BTC position');215      await expect(holds.locator('[data-code="live_cooldown"]')).toContainText('L’agent attend sa prochaine passe');216      await holds.scrollIntoViewIfNeeded();217      await shot(page, '12-live-holds-refusals');218      // controls: what pause and stop do is written; a manual order's effect is explained219      const controls = page.getByTestId('pl-controls');220      await expect(controls).toContainText('Les positions ouvertes sont CONSERVÉES');221      await expect(controls).toContainText('sans clé d’accès');222      await expect(controls.getByTestId('pl-manual-warn')).toContainText('crée une divergence');223      await controls.scrollIntoViewIfNeeded();224      await shot(page, '13-live-controls');225      await controls.getByTestId('pl-pause').click();226      await expect(page.getByTestId('pl-paused')).toContainText('Ses positions restent ouvertes');227      await expect(page.getByTestId('agent-live-state')).toHaveAttribute('data-state', 'PAUSED');228      expect((await perpState(page)).state).toBe('PAUSED');229      await page.getByTestId('pl-paused').scrollIntoViewIfNeeded();230      await shot(page, '14-live-paused');231      await controls.getByTestId('pl-resume').click();232      await expect(page.getByTestId('agent-live-state')).toHaveAttribute('data-state', 'LIVE');233234      // the Futures terminal says an agent drives this account235      await page.goto(`${BASE}/futures?v=accounts&mock=1`);236      const note = page.getByTestId('live-account-testnet').getByTestId('agent-on-account');237      await expect(note).toContainText('Compte piloté par l’agent « BTC momentum »');238      await expect(note).toContainText('un ordre manuel sur ce compte');239      await note.scrollIntoViewIfNeeded();240      await shot(page, '15-terminal-account-used-by-agent');241      await note.getByRole('link', { name: /Voir l’agent/ }).click();242      await expect(page.getByRole('heading', { name: 'BTC momentum' })).toBeVisible();243244      // stop: no passkey; the mandate is revoked, the wind-down starts, the engine closes with reduce-only orders245      await perp(page, 'holdStop', true);246      await page.getByTestId('pl-stop').click();247      const stop = page.getByTestId('pl-stop-sheet');248      await expect(stop.getByTestId('mode-band')).toHaveAttribute('data-tone', 'testnet');249      await expect(stop).toContainText('Positions ouvertes sur le compte : 1.');250      await shot(page, '16-stop-confirm');251      await stop.getByTestId('pl-stop-confirm').click();252      await expect(stop.getByTestId('pl-stop-done')).toContainText('Pendant 24 heures');253      await expect(stop.getByTestId('pl-stop-result')).toContainText('stopping: 1 position(s) still open (long');254      await stop.getByRole('button', { name: 'Fermer' }).last().click();255      await expect(page.getByTestId('pl-stopping')).toContainText('Positions encore ouvertes : 1.');256      await expect(page.getByTestId('pl-stopping')).toContainText(/Fermetures encore possibles pendant (23 h \d\d min|24 h 00 min)/);257      await expect(page.getByTestId('agent-live-state')).toHaveAttribute('data-state', 'STOPPING');258      await expect(page.getByTestId('pl-exit')).toHaveAttribute('data-code', 'owner_stop');259      await expect(page.getByTestId('pl-mandate')).toHaveAttribute('data-phase', 'wind_down');260      await expect(page.getByTestId('pl-controls')).toHaveCount(0);                       // nothing left to pause or stop261      await page.getByTestId('pl-exit').scrollIntoViewIfNeeded();262      await shot(page, '17-stopping-wind-down');263      await page.getByTestId('pl-mandate').scrollIntoViewIfNeeded();264      await shot(page, '18-stopping-mandate-countdown');265      // the engine closes (reduce-only), the agent is STOPPED and flat; the band still names the testnet266      await perp(page, 'holdStop', false);267      await expect.poll(async () => (await perpState(page)).state, { timeout: 15_000 }).toBe('STOPPED');268      await refresh(page);269      await expect(page.getByTestId('agent-live-state')).toHaveAttribute('data-state', 'STOPPED');270      await expect(page.getByTestId('agent-band')).toHaveAttribute('data-tone', 'testnet');271      await expect(page.getByTestId('pl-account')).toContainText('Aucune position');272      await expect(page.getByTestId('pl-exit')).toContainText('Vous avez arrêté l’agent');273      await expect(page.getByTestId('pl-exit-detail')).toContainText('every position was closed with reduce-only orders');274      await expect(page.getByTestId('pl-open-left')).toHaveCount(0);275      await expect(page.getByTestId('pa-start-paper')).toHaveText('Revenir en simulation');276      await expect(golive(page)).toHaveAttribute('data-next', 'mandate');                 // a NEW mandate is needed each time277      await shot(page, '19-stopped-flat');278      await noOverflow(page);279      expect(errors).toEqual([]);280    });281282    test('refusals: the agent leaves LIVE on a divergence; the mainnet-autonomous lock is a server setting, in the server\'s words', async ({ page }) => {283      const errors = errorsOf(page);284      await open(page, 'live');285      await perp(page, 'holdStop', true);286      await perp(page, 'exit', 'divergence', 'the venue\'s state and the wallet\'s records differ (order 9000001 on hyperliquid:BTC rests on the venue and was not placed by the wallet): the agent leaves LIVE');287      await refresh(page);288      const exit = page.getByTestId('pl-exit');289      await expect(exit).toHaveAttribute('data-code', 'divergence');290      await expect(exit).toContainText('ne correspond plus à ce que le portefeuille a enregistré');291      await expect(exit).toContainText('rests on the venue and was not placed by the wallet');292      await expect(page.getByTestId('agent-band')).toHaveAttribute('data-tone', 'testnet');293      await shot(page, '20-exit-divergence');294      await perp(page, 'holdStop', false);295      await expect.poll(async () => (await perpState(page)).state, { timeout: 15_000 }).toBe('STOPPED');296297      // the agent stopped WITHOUT being able to close: the open position is the first thing the page says298      await open(page, 'live');299      await perp(page, 'stopOpen', 'signer_no_authorization');300      await refresh(page);301      const left = page.getByTestId('pl-open-left');302      await expect(left).toContainText('1 position(s) RESTENT OUVERTES');303      await expect(left).toContainText('long');304      await expect(left.getByRole('button', { name: /Ouvrir ce compte dans le terminal Futures/ })).toBeVisible();305      await expect(page.getByTestId('pl-exit')).toHaveAttribute('data-code', 'signer_no_authorization');306      await shot(page, '20b-stopped-positions-left-open');307308      // mainnet: the account is authorised (manual trading with a passkey works), autonomous trading is locked by the server309      await open(page, 'mainnet_locked');310      await expect(golive(page)).toHaveAttribute('data-next', 'locked');311      await expect(golive(page).getByTestId('mode-tag')).toHaveAttribute('data-tone', 'mainnet');312      await expect(page.getByTestId('pl-step-account')).toContainText('réseau principal — ARGENT RÉEL');313      await expect(page.getByTestId('pl-step-mandate')).toHaveAttribute('data-state', 'locked');314      const lock = page.getByTestId('server-lock');315      await expect(lock).toContainText('fermée par un réglage du serveur');316      await expect(lock).toContainText('Ce n’est pas un interrupteur de cette application');317      await expect(lock.getByTestId('server-lock-reason')).toContainText('la négociation autonome (mandat) sur le MAINNET de Hyperliquid est désactivée sur ce serveur : elle exige SPW_DERIV_LIVE_MAINNET=on ET SPW_DERIV_MANDATE_MAINNET=on');318      await expect(page.getByTestId('pl-next')).toHaveCount(0);                           // no primary action: nothing here can open it319      await expect(golive(page).getByRole('checkbox')).toHaveCount(0);320      await expect(golive(page).getByRole('switch')).toHaveCount(0);321      // the page itself is still a simulation: the agent trades nothing real322      await expect(page.getByText('SIMULATION').first()).toBeVisible();323      await expect(page.getByTestId('agent-band')).toHaveCount(0);324      await golive(page).scrollIntoViewIfNeeded();325      await shot(page, '21-mainnet-locked-server-setting');326      await noOverflow(page);327      expect(errors).toEqual([]);328    });329  });330}331
Files main
Menu

Theme