diff --git a/frontends/web/playwright.config.ts b/frontends/web/playwright.config.ts index 708fb13a2e..c9f8925099 100644 --- a/frontends/web/playwright.config.ts +++ b/frontends/web/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ timeout: 120_000, }, ], - timeout: 120_000, + timeout: 180_000, workers: 1, // Tests are not parallel-safe yet. use: { baseURL: `http://${HOST}:${FRONTEND_PORT}`, diff --git a/frontends/web/src/components/amount/amount-with-unit.tsx b/frontends/web/src/components/amount/amount-with-unit.tsx index 8e5ef37be9..63e6cd46a7 100644 --- a/frontends/web/src/components/amount/amount-with-unit.tsx +++ b/frontends/web/src/components/amount/amount-with-unit.tsx @@ -104,7 +104,7 @@ export const AmountUnit = ({ rotateUnit, unit }: TAmountUnitProps) => { const classRototable = rotateUnit ? (style.rotatable || '') : ''; const textStyle = `${style.unit || ''} ${classRototable}`; return ( - + {unit} ); diff --git a/frontends/web/src/components/copy/Copy.tsx b/frontends/web/src/components/copy/Copy.tsx index 1a629a7c47..94f545d971 100644 --- a/frontends/web/src/components/copy/Copy.tsx +++ b/frontends/web/src/components/copy/Copy.tsx @@ -27,10 +27,11 @@ type TProps = { className?: string; disabled?: boolean; flexibleHeight?: boolean; + name?: string; value: string; }; -export const CopyableInput = ({ alignLeft, alignRight, borderLess, value, className, disabled, flexibleHeight }: TProps) => { +export const CopyableInput = ({ alignLeft, alignRight, borderLess, value, className, disabled, flexibleHeight, name }: TProps) => { const [success, setSuccess] = useState(false); const { t } = useTranslation(); @@ -68,6 +69,7 @@ export const CopyableInput = ({ alignLeft, alignRight, borderLess, value, classN } }; + return (
{t('receive.verifyInstruction')}

- +
)} diff --git a/frontends/web/tests/banner.test.ts b/frontends/web/tests/banner.test.ts new file mode 100644 index 0000000000..4519dee6a2 --- /dev/null +++ b/frontends/web/tests/banner.test.ts @@ -0,0 +1,126 @@ +/** +* Copyright 2025 Shift Crypto AG +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { expect, Page } from '@playwright/test'; +import { test } from './helpers/fixtures'; +import { ServeWallet } from './helpers/servewallet'; +import { launchRegtest, setupRegtestWallet, sendCoins, mineBlocks, cleanupRegtest } from './helpers/regtest'; +import { ChildProcess } from 'child_process'; + +let servewallet: ServeWallet; +let regtest: ChildProcess; + +test('Backup reminder banner is shown when currency is > 1000', async ({ page, host, frontendPort, servewalletPort }) => { + + + await test.step('Start regtest and init wallet', async () => { + regtest = await launchRegtest(); + // Give regtest some time to start + await new Promise((resolve) => setTimeout(resolve, 3000)); + await setupRegtestWallet(); + }); + + + await test.step('Start servewallet', async () => { + servewallet = new ServeWallet(page, servewalletPort, frontendPort, host, { regtest: true, testnet: false }); + await servewallet.start(); + }); + + let recvAdd: string; + await test.step('Grab receive address', async () => { + await page.getByRole('button', { name: 'Test wallet' }).click(); + await page.getByRole('button', { name: 'Unlock' }).click(); + await page.getByRole('link', { name: 'Bitcoin Regtest Bitcoin' }).click(); + await page.getByRole('button', { name: 'Receive RBTC' }).click(); + await page.getByRole('button', { name: 'Verify address on BitBox' }).click(); + const addressLocator = page.locator('[name="receive-address"]'); + recvAdd = await addressLocator.inputValue(); + console.log(`Receive address: ${recvAdd}`); + }); + + await test.step('Verify that the backup banner is NOT shown initially', async () => { + await page.goto('/'); + await verifyBackupBanner(page, undefined, false); + }); + + await test.step('Send RBTC to receive address', async () => { + await page.waitForTimeout(2000); + const sendAmount = '10'; + sendCoins(recvAdd, sendAmount); + mineBlocks(12); + }); + + await test.step('Verify that the backup banner is shown with the correct currency', async () => { + await page.goto('/'); + await page.waitForTimeout(5000); + const units = ['USD', 'EUR', 'CHF']; + let currentIndex = 0; + // First, verify that the banner shows USD by default. + await verifyBackupBanner(page, units[currentIndex]!); + + // Then, cycle through the currency units and verify the banner updates accordingly. + for (let i = 0; i < units.length; i++) { + await page.locator(`header [data-testid="amount-unit-${units[currentIndex]!}"]`).click(); + const nextIndex = (currentIndex + 1) % units.length; + await page.waitForTimeout(1000); // wait for the UI to update + await verifyBackupBanner(page, units[nextIndex]!); + currentIndex = nextIndex; + } + }); +}); + +// Helper function to verify the banner presence or absence +async function verifyBackupBanner( + page: Page, + expectedCurrency?: string, + shouldExist = true +) { + await test.step( + shouldExist + ? `Verify that the backup banner is shown for ${expectedCurrency!}` + : 'Verify that the backup banner is NOT shown', + async () => { + const textContent = await page.textContent('body'); + + if (shouldExist) { + if (!expectedCurrency) { + throw new Error('Currency must be provided when expecting banner.'); + } + + const regex = new RegExp( + `Your wallet\\s+Software keystore [a-f0-9]+\\s+passed ${expectedCurrency} 1[’,']000\\.00!` + ); + expect(textContent).toMatch(regex); + + expect(textContent).toContain( + 'We recommend creating a paper backup for extra protection. It\'s quick and simple.' + ); + } else { + // Check that the banner text is NOT present + const bannerRegex = /Your wallet Software keystore [a-f0-9]+ passed [A-Z]{3} 1,000\.00!/; + expect(textContent).not.toMatch(bannerRegex); + expect(textContent).not.toContain( + 'We recommend creating a paper backup for extra protection. It\'s quick and simple.' + ); + } + } + ); +} + +test.afterAll(async () => { + await servewallet.stop(); + await cleanupRegtest(regtest); +}); diff --git a/frontends/web/tests/base.test.ts b/frontends/web/tests/base.test.ts index b65a4dcb72..0d31fb6e1a 100644 --- a/frontends/web/tests/base.test.ts +++ b/frontends/web/tests/base.test.ts @@ -17,6 +17,7 @@ import { test } from './helpers/fixtures'; import { ServeWallet } from './helpers/servewallet'; import { expect } from '@playwright/test'; +import { deleteAccountsFile, deleteConfigFile } from './helpers/fs'; let servewallet: ServeWallet; @@ -31,10 +32,16 @@ test('App main page loads', async ({ page, host, frontendPort, servewalletPort } await test.step('Navigate to the app', async () => { await page.goto(`http://${host}:${frontendPort}`); const body = page.locator('body'); - await expect(body).toContainText('Please connect your BitBox and tap the side to continue.'); + await expect(body).toContainText('Please connect your BitBox and tap the side to continue.'), + { timeout: 15000 }; }); }); -test.afterAll(() => { - servewallet.stop(); +test.beforeAll(async () => { + deleteAccountsFile(); + deleteConfigFile(); +}); + +test.afterAll(async () => { + await servewallet.stop(); }); diff --git a/frontends/web/tests/helpers/dom.ts b/frontends/web/tests/helpers/dom.ts index 531e12b400..d333587041 100644 --- a/frontends/web/tests/helpers/dom.ts +++ b/frontends/web/tests/helpers/dom.ts @@ -16,18 +16,6 @@ import { Page, Locator, expect } from '@playwright/test'; -/** - * Returns a locator for elements matching a given attribute key/value pair. - * - * @param page - Playwright page - * @param attrKey - The attribute key to select (e.g., "data-label") - * @param attrValue - The value of the attribute to match - * @returns Locator for matching elements - */ -export function getFieldsByAttribute(page: Page, attrKey: string, attrValue: string): Locator { - return page.locator(`[${attrKey}="${attrValue}"]`); -} - /** * Finds elements by attribute key/value and asserts the expected count. * @@ -42,7 +30,7 @@ export async function assertFieldsCount( attrValue: string, expectedCount: number ) { - const locator = getFieldsByAttribute(page, attrKey, attrValue); + const locator = page.locator(`[${attrKey}="${attrValue}"]`); await expect(locator).toHaveCount(expectedCount); } diff --git a/frontends/web/tests/helpers/regtest.ts b/frontends/web/tests/helpers/regtest.ts new file mode 100644 index 0000000000..44e40e796a --- /dev/null +++ b/frontends/web/tests/helpers/regtest.ts @@ -0,0 +1,179 @@ +/** +* Copyright 2025 Shift Crypto AG +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +import { exec, spawn, ChildProcess } from 'child_process'; +import path from 'path'; +import util from 'util'; +import fs from 'fs'; + + +const execAsync = util.promisify(exec); + +const RPC_USER = 'dbb'; +const RPC_PASSWORD = 'dbb'; +const RPC_PORT = 10332; +const DATADIR = '/bitcoin'; + +let addr: string; + +// run bitcoin-cli command inside the bitcoind-regtest docker container. +async function runBitcoinCli(args: string[]): Promise { + const cmd = [ + 'docker exec', + '--user=$(id -u)', + 'bitcoind-regtest', + 'bitcoin-cli', + '-regtest', + `-datadir=${DATADIR}`, + `-rpcuser=${RPC_USER}`, + `-rpcpassword=${RPC_PASSWORD}`, + `-rpcport=${RPC_PORT}`, + ...args + ].join(' '); + + const { stdout } = await execAsync(cmd); + return stdout.trim(); +} + +// Setup regtest by +// - Creating a wallet +// - Getting a new address +// - Generating 101 blocks to that address +export async function setupRegtestWallet(): Promise { + await runBitcoinCli(['createwallet', 'testwallet']); + addr = await runBitcoinCli(['getnewaddress']); + await runBitcoinCli(['generatetoaddress', '101', addr]); +} + +// mineBlocks mines the given number of blocks to the regtest wallet address. +// This is useful to confirm transactions in tests. +export async function mineBlocks(numBlocks: number): Promise { + await runBitcoinCli(['generatetoaddress', numBlocks.toString(), addr]); +} + +/** + * Send coins to a given address in the regtest wallet. + * @param address The address to send to + * @param amount The amount in BTC + */ +export async function sendCoins(address: string, amount: number | string): Promise { + // bitcoin-cli expects amount as a string with decimal point + const amt = typeof amount === 'number' ? amount.toFixed(8) : amount; + const txid = await runBitcoinCli(['sendtoaddress', address, amt]); + return txid; // returns the transaction ID +} + + +export function launchRegtest(): Promise { + const PROJECT_ROOT = process.env.GITHUB_WORKSPACE || path.resolve(__dirname, '../../..'); + // First, clean up cache and headers. + try { + const basePath = path.join(PROJECT_ROOT, 'appfolder.dev/cache'); + + // Remove headers-rbtc.bin if it exists + const headersPath = path.join(basePath, 'headers-rbtc.bin'); + if (fs.existsSync(headersPath)) { + fs.rmSync(headersPath, { force: true }); + console.log(`Removed: ${headersPath}`); + } + // Remove all account-*rbtc* directories + const entries = fs.readdirSync(basePath); + for (const entry of entries) { + if (/^account-.*rbtc/i.test(entry)) { + const dirPath = path.join(basePath, entry); + fs.rmSync(dirPath, { recursive: true, force: true }); + console.log(`Removed directory: ${dirPath}`); + } + } + } catch (err) { + console.warn('Warning: Failed to clean up cache or headers before regtest launch:', err); + } + const scriptPath = path.join(PROJECT_ROOT, 'scripts/run_regtest.sh'); + + return new Promise((resolve, reject) => { + const proc = spawn('bash', [scriptPath], { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], // capture stdout/stderr + }); + + // Listen for the line we want + const onData = (data: Buffer) => { + const text = data.toString(); + process.stdout.write(text); // still print it to console + if (text.includes('waiting for 0 blocks to download (IBD)')) { + proc.stdout.off('data', onData); + proc.stderr.off('data', onData); + resolve(proc); // resolve when we see the line + } + }; + + proc.stdout.on('data', onData); + proc.stderr.on('data', onData); + + proc.on('error', reject); + }); +} + + +/** + * Cleans up all regtest-related processes and Docker containers. + * + * @param regtest The ChildProcess returned by launchRegtest() + */ +export async function cleanupRegtest( + regtest?: ChildProcess, +): Promise { + console.log('Cleaning up regtest environment'); + + + // Kill the regtest process group (spawned as detached) + if (regtest?.pid) { + try { + process.kill(-regtest.pid, 'SIGTERM'); + } catch (err) { + console.warn('Failed to kill regtest process:', err); + } + } + + + // Remove Docker containers + try { + await execAsync(` +docker container rm -f bitcoind-regtest electrs-regtest1 electrs-regtest2 >/dev/null 2>&1 || true +`); + console.log('Docker containers cleaned up.'); + } catch (err) { + console.warn('Docker cleanup failed:', err); + } + + + // Remove regtest data directories + const dirs = [ + '/tmp/regtest/btcdata', + '/tmp/regtest/electrsdata1', + '/tmp/regtest/electrsdata2', + ]; + + for (const dir of dirs) { + try { + await execAsync(`rm -rf ${dir}`); + console.log(`Deleted directory: ${dir}`); + } catch (err) { + console.warn(`Failed to delete directory ${dir}:`, err); + } + } + +} diff --git a/frontends/web/tests/helpers/servewallet.ts b/frontends/web/tests/helpers/servewallet.ts index 1bf7d0ede9..b2dd3f0ffa 100644 --- a/frontends/web/tests/helpers/servewallet.ts +++ b/frontends/web/tests/helpers/servewallet.ts @@ -32,6 +32,7 @@ export interface ServeWalletOptions { simulator?: boolean; timeout?: number; testnet?: boolean; + regtest?: boolean; } export class ServeWallet { @@ -43,6 +44,7 @@ export class ServeWallet { private readonly host: string; private readonly timeout: number; private readonly testnet: boolean; + private readonly regtest: boolean; constructor( page: Page, @@ -51,7 +53,7 @@ export class ServeWallet { host: string, options: ServeWalletOptions = {} ) { - const { simulator = false, timeout = 90000, testnet = true } = options; + const { simulator = false, timeout = 90000, testnet = true, regtest = false } = options; if (!testnet && simulator) { throw new Error('ServeWallet: mainnet simulator is not supported'); @@ -64,6 +66,7 @@ export class ServeWallet { this.simulator = simulator; this.timeout = timeout; this.testnet = testnet; + this.regtest = regtest; } async start(): Promise { @@ -73,8 +76,10 @@ export class ServeWallet { target = 'servewallet'; } else if (this.testnet && this.simulator) { target = 'servewallet-simulator'; - } else if (!this.testnet && !this.simulator) { + } else if (!this.testnet && !this.simulator && !this.regtest) { target = 'servewallet-mainnet'; + } else if (this.regtest) { + target = 'servewallet-regtest'; } else { // This should never happen because the constructor already guards against it throw new Error('Invalid ServeWallet configuration'); @@ -95,10 +100,15 @@ export class ServeWallet { await connectOnce(this.host, this.servewalletPort); try { await this.page.goto(`http://${this.host}:${this.frontendPort}`); - console.log( - `Servewallet ready on ${this.host}:${this.servewalletPort} after ${Date.now() - start} ms` - ); - return; + // Wait for body to be loaded + const bodyText = await this.page.textContent('body'); + + if (bodyText && (bodyText.includes('Welcome') || bodyText.includes('My portfolio'))) { + console.log( + `Servewallet ready on ${this.host}:${this.servewalletPort} after ${Date.now() - start} ms` + ); + return; + } } catch { // page.goto failed, likely connection refused; retry }