From bf323d3bbb7b43575fa050cef0d57ad7ab9894ea Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Thu, 30 Oct 2025 11:39:08 +0000 Subject: [PATCH 1/3] tests: add backup banner test. --- .../components/amount/amount-with-unit.tsx | 2 +- frontends/web/src/components/copy/Copy.tsx | 4 + .../src/routes/account/receive/receive.tsx | 6 +- frontends/web/tests/banner.test.ts | 126 ++++++++++++ frontends/web/tests/helpers/dom.ts | 14 +- frontends/web/tests/helpers/regtest.ts | 180 ++++++++++++++++++ frontends/web/tests/helpers/servewallet.ts | 22 ++- 7 files changed, 333 insertions(+), 21 deletions(-) create mode 100644 frontends/web/tests/banner.test.ts create mode 100644 frontends/web/tests/helpers/regtest.ts diff --git a/frontends/web/src/components/amount/amount-with-unit.tsx b/frontends/web/src/components/amount/amount-with-unit.tsx index a614fd1b2a..e95a006076 100644 --- a/frontends/web/src/components/amount/amount-with-unit.tsx +++ b/frontends/web/src/components/amount/amount-with-unit.tsx @@ -107,7 +107,7 @@ export const AmountUnit = ({ rotateUnit, unit, className = '' }: TAmountUnitProp const classRototable = rotateUnit ? (style.rotatable || '') : ''; const textStyle = `${style.unit || ''} ${classRototable} ${className}`; return ( - + {unit} ); diff --git a/frontends/web/src/components/copy/Copy.tsx b/frontends/web/src/components/copy/Copy.tsx index a0c7287779..4cc9c223ae 100644 --- a/frontends/web/src/components/copy/Copy.tsx +++ b/frontends/web/src/components/copy/Copy.tsx @@ -31,6 +31,7 @@ type TProps = { flexibleHeight?: boolean; displayValue?: string; value: string; + dataTestId?: string; }; export const CopyableInput = ({ @@ -44,6 +45,7 @@ export const CopyableInput = ({ disabled, flexibleHeight, displayValue, + dataTestId, }: TProps) => { const [success, setSuccess] = useState(false); const { t } = useTranslation(); @@ -87,6 +89,7 @@ export const CopyableInput = ({ } }; + 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..cb11bdf743 --- /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 }, testInfo) => { + + + 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, testInfo.title, testInfo.project.name, { 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('[data-testid="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/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..4570fbe28a --- /dev/null +++ b/frontends/web/tests/helpers/regtest.ts @@ -0,0 +1,180 @@ +/** +* 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.resolve(__dirname, 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.resolve(__dirname, 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 12983f0c38..c1c5da938f 100644 --- a/frontends/web/tests/helpers/servewallet.ts +++ b/frontends/web/tests/helpers/servewallet.ts @@ -34,6 +34,7 @@ export interface ServeWalletOptions { simulator?: boolean; timeout?: number; testnet?: boolean; + regtest?: boolean; } export class ServeWallet { @@ -49,6 +50,7 @@ export class ServeWallet { private readonly testName: string; private readonly projectName: string; private readonly logPath: string; + private readonly regtest: boolean; constructor( page: Page, @@ -59,7 +61,7 @@ export class ServeWallet { projectName: 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'); @@ -75,6 +77,7 @@ export class ServeWallet { this.testName = testName; this.projectName = projectName; + this.regtest = regtest; this.logPath = getLogFilePath(this.testName, this.projectName, 'servewallet.log'); this.openOutStream(false); // On the first time, open the file in "w" mode. } @@ -94,8 +97,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'); @@ -116,10 +121,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 } From db8c4391ee4cf776cf8b699406cfa9b7ce73473d Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Thu, 30 Oct 2025 12:21:46 +0000 Subject: [PATCH 2/3] fix: only upload video and traces on failure. This was accidentally changed in 21ebd32309d3a4e37d2191b34d63e3cbc372bd87 --- frontends/web/playwright.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontends/web/playwright.config.ts b/frontends/web/playwright.config.ts index 017c25cedd..aef4f0a71c 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}`, From f64bb2523e6d550ba978d3f1d3faf22dba9b0e19 Mon Sep 17 00:00:00 2001 From: Nikolas De Giorgis Date: Mon, 3 Nov 2025 14:51:29 +0000 Subject: [PATCH 3/3] chore: reduce tests' flakyness. --- .../web/src/components/amount/amount-with-unit.tsx | 2 +- frontends/web/tests/base.test.ts | 13 ++++++++++--- frontends/web/tests/helpers/regtest.ts | 5 ++--- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/frontends/web/src/components/amount/amount-with-unit.tsx b/frontends/web/src/components/amount/amount-with-unit.tsx index e95a006076..603f1a6f56 100644 --- a/frontends/web/src/components/amount/amount-with-unit.tsx +++ b/frontends/web/src/components/amount/amount-with-unit.tsx @@ -107,7 +107,7 @@ export const AmountUnit = ({ rotateUnit, unit, className = '' }: TAmountUnitProp const classRototable = rotateUnit ? (style.rotatable || '') : ''; const textStyle = `${style.unit || ''} ${classRototable} ${className}`; return ( - + {unit} ); diff --git a/frontends/web/tests/base.test.ts b/frontends/web/tests/base.test.ts index 6e1a0107d0..81d59d13a2 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/regtest.ts b/frontends/web/tests/helpers/regtest.ts index 4570fbe28a..b7073b5f64 100644 --- a/frontends/web/tests/helpers/regtest.ts +++ b/frontends/web/tests/helpers/regtest.ts @@ -81,7 +81,7 @@ export function launchRegtest(): Promise { const PROJECT_ROOT = process.env.GITHUB_WORKSPACE || path.resolve(__dirname, '../../../..'); // First, clean up cache and headers. try { - const basePath = path.resolve(__dirname, PROJECT_ROOT, 'appfolder.dev/cache'); + const basePath = path.join(PROJECT_ROOT, 'appfolder.dev/cache'); // Remove headers-rbtc.bin if it exists const headersPath = path.join(basePath, 'headers-rbtc.bin'); @@ -101,8 +101,7 @@ export function launchRegtest(): Promise { } catch (err) { console.warn('Warning: Failed to clean up cache or headers before regtest launch:', err); } - - const scriptPath = path.resolve(__dirname, PROJECT_ROOT, '/scripts/run_regtest.sh'); + const scriptPath = path.join(PROJECT_ROOT, 'scripts/run_regtest.sh'); return new Promise((resolve, reject) => { const proc = spawn('bash', [scriptPath], {