SPB Git

Repository Public

qa-plan.spec.ts 15.0 KB 186 lines typescript

1// Any-to-any plans in the UI, against the REAL local stack (real API, real signer, real quotes, virtual passkey), broadcasting OFF.2//3//   node scripts/build.mjs && (cd apps/web && node_modules/.bin/vite build)4//   npx tsx tests/e2e/qa-real-server.mts --port 8760 --db spw_qa_plan --fork base,arbitrum     (another terminal; a FRESH stack per run)5//   QA_REAL=1 QA_BASE=http://localhost:8760 QA_DB=spw_qa_plan npx playwright test -c tests/e2e/playwright.config.ts qa-plan6//7// The stack points Base and Arbitrum at local anvil forks where the throwaway wallet holds 0.05 ETH (and 250 USDC on Base).8// Because broadcasting is off, a signed leg never reaches any chain: the plan must say it is waiting and stay resumable.9// To walk the next leg, `settle()` plays the network's part ON THE LOCAL FORK AND THE SCRATCH DATABASE ONLY: it gives the10// wallet what the leg would have delivered and marks the leg's proposal as settled. Nothing here can touch a real network.11// (The real chain behaviour of every leg is proven end to end by scripts/fork-plan.mts.)12import { execSync } from 'node:child_process';13import { expect, test, type BrowserContext, type Page } from '@playwright/test';14import { encodeAbiParameters, keccak256 } from 'viem';15import { enter, passkeyFile, probe, virtualPasskey, watch } from './qa-helpers.ts';1617const BASE = process.env.QA_BASE ?? 'http://localhost:8760', DB = process.env.QA_DB ?? 'spw_qa_plan', PORT = Number(new URL(BASE).port);18const FORK: Record<string, string> = { base: `http://127.0.0.1:${PORT + 1}`, arbitrum: `http://127.0.0.1:${PORT + 2}` };19const SHOTS = process.env.QA_SHOTS ?? 'docs/v7/screens';20const USDC_ARB = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831';21test.skip(!process.env.QA_REAL, 'needs the real local stack: set QA_REAL=1 (see the header)');22test.use({ baseURL: BASE });23test.describe.configure({ mode: 'serial' });2425let rid = 0;26const rpc = async <T>(net: string, method: string, params: unknown[] = []): Promise<T> => { const r = await fetch(FORK[net]!, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++rid, method, params }) }); const j = await r.json() as { result?: T; error?: { message: string } }; if (j.error) throw new Error(`${method}: ${j.error.message}`); return j.result as T; };27const psql = (q: string) => execSync(`psql -At -d ${DB}`, { input: q, encoding: 'utf8' }).trim();28/** Test-only stand-in for the network: the newest signed-but-not-broadcast leg is given its outcome (fork + scratch database). */29async function settle(): Promise<void> {30  const row = psql(`select id, kind, amount, from_addr, coalesce(swap->>'expectedOut', '') from proposals where state = 'broadcast' order by updated_at desc limit 1`);31  if (!row) throw new Error('no signed leg to settle');32  const [id, kind, amount, me, expected] = row.split('|') as [string, string, string, string, string];33  if (kind === 'bridge') { // the USDC "arrives" on the Arbitrum fork (FiatToken balance mapping at slot 9)34    const slot = keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [me as `0x${string}`, 9n]));35    const cur = BigInt(await rpc<string>('arbitrum', 'eth_getStorageAt', [USDC_ARB, slot, 'latest']));36    await rpc('arbitrum', 'anvil_setStorageAt', [USDC_ARB, slot, '0x' + (cur + BigInt(amount)).toString(16).padStart(64, '0')]);37    psql(`update proposals set state = 'finalized', error = null, finalized_at = now(), version = version + 1, out_amount = ${amount}, bridge = jsonb_set(bridge, '{phase}', '"done"') where id = '${id}'`);38  } else psql(`update proposals set state = 'finalized', error = null, finalized_at = now(), version = version + 1, out_amount = ${expected} where id = '${id}'`);39}40const 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);41async function start(page: Page, context: BrowserContext) {42  const pk = await virtualPasskey(context, page, passkeyFile(PORT));43  const w = watch(page);44  await enter(page, pk, process.env.QA_SETUP_TOKEN ?? 'qa-setup-token');45  return { pk, w };46}47const shot = async (page: Page, name: string, full = false) => { await page.waitForTimeout(500); await page.screenshot({ path: `${SHOTS}/plan-${name}.png`, fullPage: full }); };48const noOverflow = async (page: Page) => { const p = await probe(page); expect(p.overflow, p.wide.join(' | ')).toBe(false); };49/** choose the destination asset in the Swap screen's picker */50async function pickTo(page: Page, search: string, row: RegExp) {51  await page.getByRole('button', { name: /^Vous recevez/ }).click();52  const dlg = page.getByRole('dialog');53  await dlg.getByRole('searchbox').fill(search);54  await dlg.getByRole('button', { name: row }).first().click();55}56const amount = (page: Page) => page.getByRole('textbox', { name: 'Montant' });57const cards = (page: Page) => page.getByTestId('plan-card');58const progress = (page: Page) => page.getByTestId('plan-progress');5960test('USDC on Base → LINK on Arbitrum: "bridge then swap", two approvals, planned vs actual, resumable; refusals and non-executable pairs are said', async ({ page, context, browser }) => {61  test.setTimeout(420_000);62  const { pk, w } = await start(page, context);6364  // ── the plans for a cross-network pair ───────────────────────────────────────────────────────────────────────────────65  await page.goto('/swap');66  await pickTo(page, 'LINK', /LINK.*Arbitrum/);67  await amount(page).fill('20');68  await expect(cards(page).first()).toBeVisible({ timeout: 60_000 });69  await expect(cards(page)).toHaveCount(2);                                         // CCTP Standard and Fast, each followed by the swap70  const first = cards(page).first();71  await expect(first).toHaveAttribute('data-executable', 'true');72  await expect(first).toContainText('Transférer puis échanger'); await expect(first).toContainText('2 approbations');73  await expect(first).toContainText('attendu'); await expect(first).toContainText('garanti au pire');74  await expect(first).toContainText('Circle CCTP'); await expect(first).toContainText('KyberSwap');75  await expect(first.getByTestId('plan-gas')).toHaveCount(2);                       // ETH on Base and ETH on Arbitrum76  for (const g of await first.getByTestId('plan-gas').all()) await expect(g).toHaveAttribute('data-ok', 'true');77  await expect(first).toContainText(/Étape 1 : 20 USDC sur Base — limite 50 par transfert/);78  await expect(page.getByText('Exécution non activée')).toHaveCount(0);            // a startable plan replaces the dead end79  await first.scrollIntoViewIfNeeded(); await shot(page, '01-cards-phone', true);80  await noOverflow(page);8182  // ── start: the guarantees are said before anything exists ───────────────────────────────────────────────────────────83  await first.getByRole('button', { name: /Démarrer ce plan \(2 approbations\)/ }).click();84  const sheet = page.getByTestId('plan-start');85  await expect(sheet).toContainText('Le minimum de bout en bout n’est PAS garanti à l’avance');86  await expect(sheet).toContainText('Si vous abandonnez, rien n’est bloqué');87  await shot(page, '02-start-phone');88  await sheet.getByRole('button', { name: /Démarrer — rien n’est signé maintenant/ }).click();89  await expect(progress(page)).toHaveAttribute('data-next', 'prepare');90  expect((await api(page, 'GET', '/activity')).json.items.filter((i: any) => i.kind !== 'receive')).toHaveLength(0);   // starting prepared nothing91  await expect(progress(page).getByTestId('plan-leg')).toHaveCount(2);92  await shot(page, '03-progress-ready-phone');9394  // ── leg 1: prepare, review, passkey ─────────────────────────────────────────────────────────────────────────────────95  await page.getByTestId('plan-prepare').click();96  await expect(page.getByTestId('plan-review')).toContainText('Étape 1 sur 2 à approuver', { timeout: 60_000 });97  await expect(page.getByTestId('plan-review')).toContainText('20 USDC · Base');98  await shot(page, '04-leg1-review-phone');99  await page.getByTestId('plan-approve').click();                                   // the virtual authenticator answers100  await expect(progress(page)).toHaveAttribute('data-next', 'wait', { timeout: 60_000 });101  await expect(page.getByTestId('plan-wait')).toContainText('Étape 1 sur 2 en cours');102  await pk.save();103  await shot(page, '05-leg1-waiting-phone');104  // closing and coming back: the plan is found again from Activity105  await page.goto('/activity');106  await expect(page.getByTestId('plan-list')).toContainText('USDC (Base) → LINK (Arbitrum One)');107  await shot(page, '06-activity-phone');108  await settle();                                                                   // FORK + SCRATCH DB ONLY: the transfer "arrives"109  await page.getByTestId('plan-list').getByRole('button').first().click();110  await expect(progress(page)).toHaveAttribute('data-current', '1', { timeout: 30_000 });111  await expect(progress(page)).toHaveAttribute('data-next', 'prepare');112  await expect(progress(page).getByTestId('plan-leg').first()).toHaveAttribute('data-state', 'settled');113  await expect(page.getByTestId('plan-holding')).toContainText('20 USDC · Arbitrum One');114  await shot(page, '07-leg2-ready-phone');115116  // ── leg 2: a fresh quote on what was received, its own minimum, its own passkey ─────────────────────────────────────117  await page.getByTestId('plan-prepare').click();118  await expect(page.getByTestId('plan-review')).toContainText('Étape 2 sur 2 à approuver', { timeout: 90_000 });119  await expect(page.getByTestId('plan-minout')).toContainText('LINK');120  await shot(page, '08-leg2-review-phone');121  await page.getByTestId('plan-approve').click();122  await expect(progress(page)).toHaveAttribute('data-next', 'wait', { timeout: 60_000 });123  await pk.save();124  await settle();125  await expect(progress(page).getByTestId('plan-state')).toHaveAttribute('data-state', 'done', { timeout: 30_000 });126  await expect(page.getByTestId('plan-received')).toContainText('LINK');127  await shot(page, '09-done-phone');128  await noOverflow(page);129  const doneId = (await api(page, 'GET', '/plans')).json.plans[0].id as string;130131  // ── a refused step pauses the plan: retry, re-plan, stop ────────────────────────────────────────────────────────────132  await page.goto('/swap');133  await pickTo(page, 'LINK', /LINK.*Arbitrum/);134  await amount(page).fill('10');135  await expect(cards(page).first()).toBeVisible({ timeout: 60_000 });136  await cards(page).first().getByRole('button', { name: /Démarrer ce plan/ }).click();137  await page.getByTestId('plan-start').getByRole('button', { name: /Utiliser ce pire cas/ }).click();   // an overall floor138  await page.getByTestId('plan-start').getByRole('button', { name: /Démarrer — rien n’est signé maintenant/ }).click();139  await page.getByTestId('plan-prepare').click();140  await expect(page.getByTestId('plan-approve')).toBeVisible({ timeout: 60_000 });141  const open = (await api(page, 'GET', '/plans?open=1')).json.plans[0];142  expect(open.floorDisplay).not.toBeNull();143  await api(page, 'POST', `/transfers/proposals/${open.proposal.id}/reject`);       // the owner declines the step144  await expect(page.getByTestId('plan-paused')).toHaveAttribute('data-code', 'refused', { timeout: 20_000 });145  await expect(page.getByTestId('plan-paused')).toContainText('Les fonds sont à votre adresse : 10 USDC sur Base');146  for (const id of ['plan-retry', 'plan-replan', 'plan-stop']) await expect(page.getByTestId(id)).toBeVisible();147  await shot(page, '10-paused-phone');148  await page.getByTestId('plan-stop').click();149  await expect(progress(page).getByTestId('plan-state')).toHaveAttribute('data-state', 'stopped');150  await expect(progress(page)).toContainText('10 USDC restent sur Base, à votre adresse, comme un solde ordinaire');151  await page.getByRole('dialog').getByRole('button', { name: 'Fermer' }).last().click();152153  // ── what is not executable is said, with the reason; what is missing is named ───────────────────────────────────────154  await pickTo(page, 'SOL', /SOL\b.*Solana/);155  await amount(page).fill('10');156  await expect(page.getByTestId('plan-not-executable')).toContainText('Pas encore exécutable', { timeout: 30_000 });157  await expect(page.getByTestId('plan-not-executable')).toContainText('Circle CCTP prend en charge Solana');158  await expect(cards(page)).toHaveCount(0);159  await shot(page, '11-solana-not-executable-phone');160  await pickTo(page, 'OP', /^OP\b.*OP Mainnet|OP.*Optimism/);                       // a network where the throwaway wallet holds no ETH161  await amount(page).fill('12');162  await expect(cards(page).first()).toBeVisible({ timeout: 60_000 });163  await expect(cards(page).first()).toHaveAttribute('data-executable', 'false');164  await expect(cards(page).first().getByTestId('plan-issues')).toContainText(/Il manque du ETH sur (OP Mainnet|Optimism)/);165  await expect(cards(page).first().getByRole('button', { name: /Recevoir du ETH sur/ })).toBeVisible();166  await expect(cards(page).first().getByRole('button', { name: /Démarrer ce plan/ })).toBeDisabled();167  await cards(page).first().getByTestId('plan-issues').scrollIntoViewIfNeeded();168  await shot(page, '12-gas-missing-phone');169  await noOverflow(page);170  expect(w.errors.filter((e) => !/Failed to load resource/.test(e)), w.errors.join('\n')).toEqual([]);171172  // ── desktop ─────────────────────────────────────────────────────────────────────────────────────────────────────────173  const dctx = await browser.newContext({ baseURL: BASE, viewport: { width: 1360, height: 900 } }), d = await dctx.newPage();174  const dpk = await virtualPasskey(dctx, d, passkeyFile(PORT));175  await enter(d, dpk, 'unused');176  await d.goto(`/plan/${doneId}`);177  await expect(progress(d).getByTestId('plan-state')).toHaveAttribute('data-state', 'done');178  await shot(d, '13-progress-desktop');179  await d.goto('/swap');180  await pickTo(d, 'LINK', /LINK.*Arbitrum/);181  await amount(d).fill('15');182  await expect(cards(d).first()).toBeVisible({ timeout: 60_000 });183  await shot(d, '14-cards-desktop', true);184  await dctx.close();185});186
Files main
Menu

Theme