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