Repository Public
1// QA pass of 2026-11-11, kept as a spec: the screens that were merged without ever being opened in a browser, against the2// REAL local stack (real API, real signer with a throwaway in-memory seed, public read-only network access, broadcast OFF).3//4// npx tsx tests/e2e/qa-real-server.mts --port 8690 --db spw_qa_polish --fork base,arbitrum (a fresh stack, another terminal)5// QA_REAL=1 npx playwright test -c tests/e2e/playwright.config.ts qa-agent-fund qa-real6//7// Runs after qa-agent-fund.spec.ts on the same stack (file order); the last test here leaves a signed, never-broadcast8// transfer behind, which blocks the network for anything that would run after it.9import { expect, test, type BrowserContext, type Page } from '@playwright/test';10import { enter, passkeyFile, probe, virtualPasskey, watch } from './qa-helpers.ts';1112const BASE = process.env.QA_BASE ?? 'http://localhost:8690', PORT = Number(new URL(BASE).port);13test.skip(!process.env.QA_REAL, 'needs the real local stack: set QA_REAL=1 (see the header)');14test.use({ baseURL: BASE });15test.describe.configure({ mode: 'serial' });1617async function start(page: Page, context: BrowserContext) {18 const pk = await virtualPasskey(context, page, passkeyFile(PORT));19 const w = watch(page);20 await enter(page, pk, process.env.QA_SETUP_TOKEN ?? 'qa-setup-token');21 return { pk, w };22}23const api = (page: Page, method: string, path: string, body?: unknown) => page.evaluate(async ([m, p, b]) => { const r = await fetch('/api/v1' + p, { method: m as string, credentials: 'same-origin', headers: m === 'GET' ? {} : { 'x-spw-csrf': '1', 'content-type': 'application/json' }, body: m === 'GET' ? undefined : JSON.stringify(b ?? {}) }); return { status: r.status, json: await r.json().catch(() => null) as any }; }, [method, path, body] as const);24const clean = async (page: Page) => { const p = await probe(page); expect(p.overflow, p.wide.join(' | ')).toBe(false); return p; };25const BSC_ETH = '0x2170Ed0880ac9A755fd29B2688956BD959F933F8';2627test('registry of 249 deployments: issuer of pegged tokens, ordering, picker above the navigation, French numbers', async ({ page, context }) => {28 test.setTimeout(180_000);29 const { pk, w } = await start(page, context);30 await expect(page.getByText(/[+−]\d+,\d{2} % aujourd’hui/)).toBeVisible({ timeout: 60_000 }); // decimal comma in French31 // popular assets show the original issue, not a bridged copy32 await expect(page.getByRole('button', { name: /Tether USD/ })).toBeVisible();33 await expect(page.getByRole('main').getByText(/Bridged USDT/)).toHaveCount(0);3435 // a token that keeps the underlying's ticker says who issues it36 await page.goto(`/asset/bsc/${BSC_ETH}`);37 await expect(page.getByRole('heading', { name: 'Binance-Peg Ethereum' })).toBeVisible({ timeout: 30_000 });38 await expect(page.getByTestId('asset-issuer')).toContainText('n’est pas l’émission d’origine de ETH');39 await expect(page.getByTestId('asset-issuer')).toContainText('Binance');40 await clean(page);4142 // the full list: balances first, then majors by market rank; smooth with 249 rows43 await page.goto('/assets');44 const rows = page.locator('main li');45 await expect(rows).toHaveCount(249, { timeout: 30_000 });46 const order = await rows.evaluateAll((els) => els.map((e) => (e as HTMLElement).innerText.replace(/\s+/g, ' ')));47 const at = (s: string) => order.findIndex((x) => x.includes(s));48 expect(at('USDC · Base')).toBeLessThan(3); // held49 expect(at('BTC · Bitcoin')).toBeGreaterThan(-1);50 expect(at('BTC · Bitcoin')).toBeLessThan(at('APT · Aptos')); // rank, not alphabet51 const ms = await page.evaluate(`(async () => { const i = document.querySelector('input[type=search]'); const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set; const t0 = performance.now(); set.call(i, 'usd'); i.dispatchEvent(new Event('input', { bubbles: true })); await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); return performance.now() - t0; })()`) as number;52 expect(ms).toBeLessThan(250);53 await clean(page);5455 // swap: the picker is a sheet ABOVE the bottom navigation, and names the issuer56 await page.goto('/swap');57 await page.getByRole('button', { name: /^Vous recevez/ }).click();58 const dlg = page.getByRole('dialog');59 await dlg.getByRole('searchbox').fill('eth');60 await expect(dlg.getByText(/BNB Smart Chain · adossé — Binance/).first()).toBeVisible();61 const top = await page.evaluate(() => { const e = document.elementFromPoint(window.innerWidth / 2, window.innerHeight - 24); return !!e?.closest('[role=dialog]'); });62 expect(top).toBe(true);63 await dlg.getByRole('button', { name: 'Annuler' }).click();64 // a same-network route says it is executable here; the slippage control stays on one line and is 44 px high65 await page.getByLabel('Montant').fill('25');66 await expect(page.getByText('exécutable ici').first()).toBeVisible({ timeout: 40_000 });67 const slip = await page.getByRole('radio', { name: '0,5 %' }).boundingBox();68 expect(slip!.height).toBeGreaterThanOrEqual(44); expect(slip!.height).toBeLessThan(50);69 const p = await clean(page);70 expect(p.small).toEqual([]); // every control on this screen is at least 44 px71 await pk.save();72 expect(w.errors.filter((e) => !/status of 4\d\d/.test(e))).toEqual([]);73});7475test('agent creation: BNB Chain cash leg and representations, warnings kept on the agent page', async ({ page, context }) => {76 test.setTimeout(180_000);77 const { pk, w } = await start(page, context);78 await page.goto('/agents');79 await page.getByRole('button', { name: 'Nouvel agent' }).click();80 const net = page.locator('select').first();81 await expect(net.locator('option')).toHaveCount(8, { timeout: 60_000 });82 await net.selectOption('bsc');83 // the 18-decimal Binance-Peg cash leg is named, with its issuer84 await expect(page.getByTestId('cash-issuer')).toContainText('USDC est « Binance-Peg USD Coin »');85 await expect(page.getByTestId('cash-issuer')).toContainText('18 décimales');86 await page.getByPlaceholder('ETH Trend').fill('QA BNB repr');87 await page.getByRole('checkbox', { name: /^BTC/ }).click();88 // both representations are shown; the default rule is unchanged (WBTC), the liquid one is pointed out89 const group = page.getByTestId('repr-BTC');90 await expect(group.getByRole('radio')).toHaveCount(2);91 await expect(group.getByRole('radio', { name: /WBTC/ })).toHaveAttribute('aria-checked', 'true');92 await expect(group.getByRole('radio', { name: /WBTC/ })).toContainText('choix par défaut');93 await expect(group.getByRole('radio', { name: /BTCB/ })).toContainText('le plus liquide sur BNB Smart Chain');94 await expect(group).toContainText('BTCB est la forme la plus liquide de BTC ; la règle par défaut retient WBTC');95 await group.getByRole('radio', { name: /BTCB/ }).click();96 await expect(group.getByRole('radio', { name: /BTCB/ })).toHaveAttribute('aria-checked', 'true');97 await page.getByRole('checkbox', { name: /^ETH/ }).click();98 await clean(page);99 await page.getByRole('button', { name: 'Créer l’agent' }).click();100 await expect(page.getByRole('heading', { name: 'QA BNB repr' })).toBeVisible({ timeout: 30_000 });101 await expect(page.getByText(/sous forme de BTCB — Binance-Peg BTCB · Émetteur : Binance/)).toBeVisible({ timeout: 30_000 });102 await expect(page.getByText(/sous forme de ETH — Binance-Peg Ethereum · Émetteur : Binance/)).toBeVisible();103 await expect(page.getByText(/sous forme de USDC — Binance-Peg USD Coin/)).toBeVisible();104 await expect(page.getByRole('main').getByText('{native}')).toHaveCount(0); // no raw placeholder105 await clean(page);106107 // Ethereum, small orders and a paper-only symbol: what the creation answer warned about stays on the page, in French108 const r = await api(page, 'POST', '/agents', { name: 'QA Eth avert', mission: '', network: 'ethereum', universe: ['ETH', 'EUR'], interval: '1h', capitalUsdc: '100', risk: { maxExposurePct: 50, maxLossPct: 5, maxOrderUsd: 1, maxDailyUsd: 100, maxOrdersPerDay: 12, mandateDays: 30, slippageBps: 50 } });109 expect(r.status).toBe(200);110 expect((r.json.warnings as { code: string }[]).map((x) => x.code)).toContain('paper_only');111 await page.goto(`/agents/${r.json.id}`);112 const warn = page.getByTestId('agent-warnings');113 await expect(warn).toContainText('EUR : le signataire ne peut pas en vérifier le prix', { timeout: 60_000 });114 if ((r.json.warnings as { code: string }[]).some((x) => x.code === 'fees')) await expect(warn).toContainText(/Sur Ethereum, un ordre coûte environ .* \$ US de frais de réseau/);115 await pk.save();116 expect(w.errors.filter((e) => !/status of 4\d\d/.test(e))).toEqual([]);117});118119test('security: the 249 limits can be searched, by network name', async ({ page, context }) => {120 const { pk } = await start(page, context);121 await page.goto('/security');122 await page.getByRole('button', { name: /Tout afficher \(249\)/ }).click();123 await expect(page.locator('main [role=progressbar]')).toHaveCount(249);124 await page.getByRole('searchbox').fill('bnb');125 await expect(page.getByText(/\d+ sur 249/)).toBeVisible();126 expect(await page.locator('main [role=progressbar]').count()).toBeLessThan(40);127 await expect(page.getByText('USDC · BNB Smart Chain')).toBeVisible();128 await expect(page.getByRole('main').getByText('· Bsc')).toHaveCount(0);129 await clean(page);130 await pk.save();131});132133test('broadcast off: a transfer ends as "signed, not sent" — never as sent', async ({ page, context }) => {134 test.setTimeout(240_000);135 const { pk } = await start(page, context);136 await page.goto('/send');137 await page.locator('textarea').fill('0x1234');138 await page.getByPlaceholder('0.00').fill('5');139 await page.getByRole('button', { name: 'Vérifier' }).click();140 await expect(page.getByRole('alert')).toHaveText('Adresse invalide pour ce réseau.'); // no English server sentence appended141 await page.locator('textarea').fill('0x70997970C51812dc3A010C7d01b50e0d17dc79C8');142 // the API allows 240 requests a minute; the tests before this one can have used them up: wait and try again143 const review = page.getByRole('heading', { name: 'Vérification' }), limited = page.getByRole('alert').filter({ hasText: 'Trop de requêtes' });144 for (let k = 0; k < 5; k++) {145 await page.getByRole('button', { name: 'Vérifier' }).click();146 await review.or(limited).first().waitFor({ timeout: 40_000 });147 if (await review.isVisible()) break;148 await page.waitForTimeout(20_000);149 }150 await expect(review).toBeVisible();151 await page.getByRole('checkbox').check();152 await page.getByRole('button', { name: /Approuver/ }).click();153 await expect(page.getByText('Signé, non envoyé (test)')).toBeVisible({ timeout: 40_000 });154 await expect(page.getByText(/Mode test : la diffusion est désactivée sur ce serveur/)).toBeVisible();155 await expect(page.getByText('Voir dans l’explorateur')).toHaveCount(0);156 await page.goto('/activity');157 await expect(page.getByText('Signé, non envoyé (test)').first()).toBeVisible({ timeout: 30_000 });158 await clean(page);159 await pk.save();160});161