Repository Public
1import { readFileSync } from 'node:fs';2import { expect, test } from '@playwright/test';34// Requires scripts/dev-stack.sh (throwaway signer keystore, broadcast off). Uses a virtual passkey via CDP.5const SHOTS = 'test-results/shots';6test('owner setup, lock/unlock, every screen on a phone viewport, send guarded by real chain checks', async ({ page, context }) => {7 const cdp = await context.newCDPSession(page);8 await cdp.send('WebAuthn.enable');9 await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });10 const errors: string[] = [];11 page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });12 page.on('pageerror', (e) => errors.push(String(e)));1314 await page.goto('/');15 await expect(page.getByText('Configuration initiale')).toBeVisible();16 await page.screenshot({ path: `${SHOTS}/01-setup.png` });17 await page.getByRole('textbox').fill('definitely-wrong-token');18 await page.getByRole('button', { name: /Créer ma clé/ }).click();19 await expect(page.getByRole('alert')).toContainText(/Jeton de configuration invalide/);20 await page.getByRole('textbox').fill(readFileSync(`${process.env.SPW_DEV_DIR ?? '/tmp/spw-dev'}/setup-token`, 'utf8').trim());21 await page.getByRole('button', { name: /Créer ma clé/ }).click();22 await expect(page.getByText('Valeur totale estimée')).toBeVisible({ timeout: 30_000 });23 await expect(page.getByRole('heading', { name: 'Actifs' })).toBeVisible();24 await expect(page.getByText(/21\/21 réseaux opérationnels/)).toBeVisible({ timeout: 90_000 });25 await page.waitForTimeout(1500);26 await page.screenshot({ path: `${SHOTS}/02-home.png`, fullPage: true });2728 // dedicated asset page: price, chart, balance, details29 await page.getByRole('button', { name: /USD Coin/ }).first().click();30 await expect(page.getByText('Votre solde')).toBeVisible();31 await expect(page.getByText('Contrat du jeton')).toBeVisible();32 await page.waitForTimeout(2500);33 await page.screenshot({ path: `${SHOTS}/02b-asset.png`, fullPage: true });34 await page.getByRole('button', { name: 'Retour' }).click();35 await page.getByRole('button', { name: /Tous les actifs/ }).click();36 await page.getByRole('searchbox').fill('pepe');37 await expect(page.getByRole('button', { name: /Pepe/ })).toHaveCount(1);38 await page.screenshot({ path: `${SHOTS}/02c-assets.png` });39 await page.getByRole('button', { name: 'Retour' }).click();4041 // no horizontal overflow on a phone42 expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);4344 // lock, then unlock with the passkey45 await page.getByRole('button', { name: 'Verrouiller' }).first().click();46 await expect(page.getByRole('button', { name: /Déverrouiller/ })).toBeVisible();47 await page.screenshot({ path: `${SHOTS}/03-lock.png` });48 await page.getByRole('button', { name: /Déverrouiller/ }).click();49 await expect(page.getByText('Valeur totale estimée')).toBeVisible({ timeout: 30_000 });5051 // receive: signer-derived address + QR52 await page.getByRole('navigation').getByRole('button', { name: 'Accueil' }).click();53 await page.getByRole('main').getByRole('button', { name: 'Recevoir' }).click();54 await expect(page.locator('img[alt^="QR 0x"]')).toBeVisible();55 await expect(page.getByText(/Envoyez uniquement ETH \/ USDC \/ .*sur le réseau Base/)).toBeVisible();56 await page.screenshot({ path: `${SHOTS}/04-receive.png`, fullPage: true });57 await page.getByRole('radio', { name: 'Solana' }).click();58 await expect(page.getByText(/Envoyez uniquement SOL \/ USDC \/ .*sur le réseau Solana/)).toBeVisible();59 await page.getByRole('radio', { name: 'Bitcoin', exact: true }).click();60 await expect(page.locator('img[alt^="QR bc1q"]')).toBeVisible();61 for (const [name, prefix] of [['Bitcoin Cash', 'bitcoincash:q'], ['Dogecoin', 'D'], ['Litecoin', 'ltc1q'], ['TRON', 'T'], ['XRP Ledger', 'r'], ['Sui', '0x'], ['Aptos', '0x'], ['Cosmos Hub', 'cosmos1'], ['NEAR Protocol', ''], ['TON', 'UQ'], ['Stellar', 'G']] as const) {62 await page.getByRole('radio', { name, exact: true }).click();63 await expect(page.locator(`img[alt^="QR ${prefix}"]`)).toBeVisible();64 }65 await expect(page.getByText(/un compte doit détenir au moins 1 XLM/)).toBeVisible();66 await page.screenshot({ path: `${SHOTS}/04b-receive-stellar.png`, fullPage: true });6768 // swap: route comparison screen (no provider is wired in this build: it must say so, and execution must be visibly disabled)69 await page.getByRole('navigation').getByRole('button', { name: 'Échanger' }).click();70 await expect(page.getByText(/Devis en direct de plusieurs fournisseurs/)).toBeVisible();71 await page.getByLabel('Montant').fill('25');72 // live quotes from real providers (USDC -> ETH on Base); the empty test wallet cannot execute, and must be told why73 await expect(page.getByText('kyberswap').first()).toBeVisible({ timeout: 30_000 });74 await expect(page.getByText(/min\. garanti/).first()).toBeVisible();75 await page.screenshot({ path: `${SHOTS}/04c-swap.png`, fullPage: true });76 await page.getByRole('button', { name: /Échanger via KyberSwap/ }).click();77 await expect(page.getByRole('alert')).toContainText(/Solde insuffisant/, { timeout: 30_000 });78 expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);7980 // send: invalid address is refused, then an empty wallet cannot draft a transfer (live mainnet balance check)81 await page.getByRole('navigation').getByRole('button', { name: 'Envoyer' }).click();82 await page.locator('textarea').fill('0x1234');83 await page.getByRole('radio', { name: 'USDC', exact: true }).click();84 await page.getByPlaceholder('0.00').fill('5');85 await page.getByRole('button', { name: 'Vérifier' }).click();86 await expect(page.getByRole('alert')).toContainText(/Adresse invalide/);87 await page.locator('textarea').fill('0x70997970C51812dc3A010C7d01b50e0d17dc79C8');88 await page.getByRole('button', { name: 'Vérifier' }).click();89 await expect(page.getByRole('alert')).toContainText(/Solde insuffisant/, { timeout: 30_000 });90 await page.screenshot({ path: `${SHOTS}/05-send.png`, fullPage: true });9192 await page.getByRole('navigation').getByRole('button', { name: 'Activité' }).click();93 await expect(page.getByText('Aucune activité')).toBeVisible();94 await page.getByRole('banner').getByRole('button', { name: 'Sécurité' }).click();95 await expect(page.getByText('Signature active')).toBeVisible();96 await expect(page.getByText('Chaîne d’empreintes intacte')).toBeVisible();97 await page.screenshot({ path: `${SHOTS}/06-security.png`, fullPage: true });9899 // English + light theme100 await page.getByRole('navigation').getByRole('button', { name: 'Réglages' }).click();101 await page.getByRole('radio', { name: 'English' }).click();102 await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();103 await page.getByRole('radio', { name: 'Dark' }).click();104 await page.screenshot({ path: `${SHOTS}/07-settings-en-dark.png`, fullPage: true });105 await page.getByRole('navigation').getByRole('button', { name: 'Home' }).click();106 await page.waitForTimeout(600);107 await page.screenshot({ path: `${SHOTS}/08-home-dark.png`, fullPage: true });108 await page.getByRole('navigation').getByRole('button', { name: 'Settings' }).click();109 await page.getByRole('radio', { name: 'Light' }).click();110 await page.getByRole('radio', { name: 'Français' }).click();111112 // emergency halt from the phone113 await page.getByRole('banner').getByRole('button', { name: 'Sécurité' }).click();114 await page.getByRole('button', { name: 'Arrêt d’urgence' }).click();115 await page.getByLabel('STOP').fill('stop');116 await page.getByRole('button', { name: 'Arrêt d’urgence' }).click();117 await expect(page.getByText('Signature arrêtée')).toBeVisible();118 await page.getByRole('navigation').getByRole('button', { name: 'Accueil' }).click();119 await expect(page.getByText(/Arrêt d’urgence actif/)).toBeVisible();120 await expect(page.getByRole('main').getByRole('button', { name: 'Envoyer' })).toBeDisabled();121122 expect(errors.filter((e) => !/status of 4\d\d/.test(e))).toEqual([]);123});124125test('a second browser without the passkey cannot get in', async ({ browser }) => {126 const ctx = await browser.newContext();127 const page = await ctx.newPage();128 const cdp = await ctx.newCDPSession(page);129 await cdp.send('WebAuthn.enable');130 await cdp.send('WebAuthn.addVirtualAuthenticator', { options: { protocol: 'ctap2', transport: 'internal', hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true } });131 await page.goto('/');132 await expect(page.getByRole('button', { name: /Déverrouiller/ })).toBeVisible();133 await expect(page.getByText('Configuration initiale')).toHaveCount(0); // setup is closed for good134 for (const p of ['/api/v1/portfolio', '/api/v1/accounts', '/api/v1/security/audit', '/api/v1/export/activity.csv'])135 expect((await page.request.get(p)).status()).toBe(401);136 expect((await page.request.post('/api/v1/auth/setup/options', { headers: { 'x-spw-csrf': '1', origin: process.env.SPW_E2E_BASE ?? 'http://localhost:8650' }, data: {} })).status()).toBe(403);137 await ctx.close();138});139