Repository Public
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