Skip to content

Commit 448aaea

Browse files
committed
tests: add backup banner test.
1 parent 43b29d9 commit 448aaea

File tree

7 files changed

+333
-22
lines changed

7 files changed

+333
-22
lines changed

frontends/web/src/components/amount/amount-with-unit.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export const AmountUnit = ({ rotateUnit, unit }: TAmountUnitProps) => {
104104
const classRototable = rotateUnit ? (style.rotatable || '') : '';
105105
const textStyle = `${style.unit || ''} ${classRototable}`;
106106
return (
107-
<span className={textStyle} onClick={rotateUnit}>
107+
<span id={`amount-unit-${unit}`} className={textStyle} onClick={rotateUnit}>
108108
{unit}
109109
</span>
110110
);

frontends/web/src/components/copy/Copy.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ type TProps = {
2727
className?: string;
2828
disabled?: boolean;
2929
flexibleHeight?: boolean;
30+
name?: string;
3031
value: string;
3132
};
3233

33-
export const CopyableInput = ({ alignLeft, alignRight, borderLess, value, className, disabled, flexibleHeight }: TProps) => {
34+
export const CopyableInput = ({ alignLeft, alignRight, borderLess, value, className, disabled, flexibleHeight, name }: TProps) => {
3435
const [success, setSuccess] = useState(false);
3536
const { t } = useTranslation();
3637

@@ -68,6 +69,7 @@ export const CopyableInput = ({ alignLeft, alignRight, borderLess, value, classN
6869
}
6970
};
7071

72+
7173
return (
7274
<div className={[
7375
'flex flex-row flex-start flex-items-start',
@@ -81,6 +83,7 @@ export const CopyableInput = ({ alignLeft, alignRight, borderLess, value, classN
8183
value={value}
8284
ref={textAreaRef}
8385
rows={1}
86+
{...(name ? { name } : {})}
8487
className={[
8588
style.inputField,
8689
flexibleHeight && style.flexibleHeight,

frontends/web/src/routes/account/receive/receive.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -324,7 +324,11 @@ export const Receive = ({
324324
<p>{t('receive.verifyInstruction')}</p>
325325
</div>
326326
<div className="m-bottom-half">
327-
<CopyableInput value={address} flexibleHeight />
327+
<CopyableInput
328+
value={address}
329+
name="receive-address"
330+
flexibleHeight
331+
/>
328332
</div>
329333
</>
330334
)}

frontends/web/tests/banner.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Copyright 2025 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { expect, Page } from '@playwright/test';
18+
import { test } from './helpers/fixtures';
19+
import { ServeWallet } from './helpers/servewallet';
20+
import { launchRegtest, setupRegtestWallet, sendCoins, mineBlocks, cleanupRegtest } from './helpers/regtest';
21+
import { ChildProcess } from 'child_process';
22+
23+
let servewallet: ServeWallet;
24+
let regtest: ChildProcess;
25+
26+
test('Backup reminder banner is shown when currency is > 1000', async ({ page, host, frontendPort, servewalletPort }) => {
27+
28+
29+
await test.step('Start regtest and init wallet', async () => {
30+
regtest = await launchRegtest();
31+
// Give regtest some time to start
32+
await new Promise((resolve) => setTimeout(resolve, 3000));
33+
await setupRegtestWallet();
34+
});
35+
36+
37+
await test.step('Start servewallet', async () => {
38+
servewallet = new ServeWallet(page, servewalletPort, frontendPort, host, { regtest: true, testnet: false });
39+
await servewallet.start();
40+
});
41+
42+
let recvAdd: string;
43+
await test.step('Grab receive address', async () => {
44+
await page.getByRole('button', { name: 'Test wallet' }).click();
45+
await page.getByRole('button', { name: 'Unlock' }).click();
46+
await page.getByRole('link', { name: 'Bitcoin Regtest Bitcoin' }).click();
47+
await page.getByRole('button', { name: 'Receive RBTC' }).click();
48+
await page.getByRole('button', { name: 'Verify address on BitBox' }).click();
49+
const addressLocator = page.locator('[name="receive-address"]');
50+
recvAdd = await addressLocator.inputValue();
51+
console.log(`Receive address: ${recvAdd}`);
52+
});
53+
54+
await test.step('Verify that the backup banner is NOT shown initially', async () => {
55+
await page.goto('/');
56+
await verifyBackupBanner(page, undefined, false);
57+
});
58+
59+
await test.step('Send RBTC to receive address', async () => {
60+
await page.waitForTimeout(2000);
61+
const sendAmount = '10';
62+
sendCoins(recvAdd, sendAmount);
63+
mineBlocks(12);
64+
});
65+
66+
await test.step('Verify that the backup banner is shown with the correct currency', async () => {
67+
await page.goto('/');
68+
await page.waitForTimeout(5000);
69+
const units = ['USD', 'EUR', 'CHF'];
70+
let currentIndex = 0;
71+
// First, verify that the banner shows USD by default.
72+
await verifyBackupBanner(page, units[currentIndex]!);
73+
74+
// Then, cycle through the currency units and verify the banner updates accordingly.
75+
for (let i = 0; i < units.length; i++) {
76+
await page.locator(`header [data-testid="amount-unit-${units[currentIndex]!}"]`).click();
77+
const nextIndex = (currentIndex + 1) % units.length;
78+
await page.waitForTimeout(1000); // wait for the UI to update
79+
await verifyBackupBanner(page, units[nextIndex]!);
80+
currentIndex = nextIndex;
81+
}
82+
});
83+
});
84+
85+
// Helper function to verify the banner presence or absence
86+
async function verifyBackupBanner(
87+
page: Page,
88+
expectedCurrency?: string,
89+
shouldExist = true
90+
) {
91+
await test.step(
92+
shouldExist
93+
? `Verify that the backup banner is shown for ${expectedCurrency!}`
94+
: 'Verify that the backup banner is NOT shown',
95+
async () => {
96+
const textContent = await page.textContent('body');
97+
98+
if (shouldExist) {
99+
if (!expectedCurrency) {
100+
throw new Error('Currency must be provided when expecting banner.');
101+
}
102+
103+
const regex = new RegExp(
104+
`Your wallet\\s+Software keystore [a-f0-9]+\\s+passed ${expectedCurrency} 1[’,']000\\.00!`
105+
);
106+
expect(textContent).toMatch(regex);
107+
108+
expect(textContent).toContain(
109+
'We recommend creating a paper backup for extra protection. It\'s quick and simple.'
110+
);
111+
} else {
112+
// Check that the banner text is NOT present
113+
const bannerRegex = /Your wallet Software keystore [a-f0-9]+ passed [A-Z]{3} 1,000\.00!/;
114+
expect(textContent).not.toMatch(bannerRegex);
115+
expect(textContent).not.toContain(
116+
'We recommend creating a paper backup for extra protection. It\'s quick and simple.'
117+
);
118+
}
119+
}
120+
);
121+
}
122+
123+
test.afterAll(async () => {
124+
await servewallet.stop();
125+
await cleanupRegtest(regtest);
126+
});

frontends/web/tests/helpers/dom.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,6 @@
1616

1717
import { Page, Locator, expect } from '@playwright/test';
1818

19-
/**
20-
* Returns a locator for elements matching a given attribute key/value pair.
21-
*
22-
* @param page - Playwright page
23-
* @param attrKey - The attribute key to select (e.g., "data-label")
24-
* @param attrValue - The value of the attribute to match
25-
* @returns Locator for matching elements
26-
*/
27-
export function getFieldsByAttribute(page: Page, attrKey: string, attrValue: string): Locator {
28-
return page.locator(`[${attrKey}="${attrValue}"]`);
29-
}
30-
3119
/**
3220
* Finds elements by attribute key/value and asserts the expected count.
3321
*
@@ -42,7 +30,7 @@ export async function assertFieldsCount(
4230
attrValue: string,
4331
expectedCount: number
4432
) {
45-
const locator = getFieldsByAttribute(page, attrKey, attrValue);
33+
const locator = page.locator(`[${attrKey}="${attrValue}"]`);
4634
await expect(locator).toHaveCount(expectedCount);
4735
}
4836

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Copyright 2025 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { exec, spawn, ChildProcess } from 'child_process';
18+
import path from 'path';
19+
import util from 'util';
20+
import fs from 'fs';
21+
22+
23+
const execAsync = util.promisify(exec);
24+
25+
const RPC_USER = 'dbb';
26+
const RPC_PASSWORD = 'dbb';
27+
const RPC_PORT = 10332;
28+
const DATADIR = '/bitcoin';
29+
30+
let addr: string;
31+
32+
// run bitcoin-cli command inside the bitcoind-regtest docker container.
33+
async function runBitcoinCli(args: string[]): Promise<string> {
34+
const cmd = [
35+
'docker exec',
36+
'--user=$(id -u)',
37+
'bitcoind-regtest',
38+
'bitcoin-cli',
39+
'-regtest',
40+
`-datadir=${DATADIR}`,
41+
`-rpcuser=${RPC_USER}`,
42+
`-rpcpassword=${RPC_PASSWORD}`,
43+
`-rpcport=${RPC_PORT}`,
44+
...args
45+
].join(' ');
46+
47+
const { stdout } = await execAsync(cmd);
48+
return stdout.trim();
49+
}
50+
51+
// Setup regtest by
52+
// - Creating a wallet
53+
// - Getting a new address
54+
// - Generating 101 blocks to that address
55+
export async function setupRegtestWallet(): Promise<void> {
56+
await runBitcoinCli(['createwallet', 'testwallet']);
57+
addr = await runBitcoinCli(['getnewaddress']);
58+
await runBitcoinCli(['generatetoaddress', '101', addr]);
59+
}
60+
61+
// mineBlocks mines the given number of blocks to the regtest wallet address.
62+
// This is useful to confirm transactions in tests.
63+
export async function mineBlocks(numBlocks: number): Promise<void> {
64+
await runBitcoinCli(['generatetoaddress', numBlocks.toString(), addr]);
65+
}
66+
67+
/**
68+
* Send coins to a given address in the regtest wallet.
69+
* @param address The address to send to
70+
* @param amount The amount in BTC
71+
*/
72+
export async function sendCoins(address: string, amount: number | string): Promise<string> {
73+
// bitcoin-cli expects amount as a string with decimal point
74+
const amt = typeof amount === 'number' ? amount.toFixed(8) : amount;
75+
const txid = await runBitcoinCli(['sendtoaddress', address, amt]);
76+
return txid; // returns the transaction ID
77+
}
78+
79+
80+
export function launchRegtest(): Promise<ChildProcess> {
81+
82+
// First, clean up cache and headers.
83+
try {
84+
const basePath = path.resolve(__dirname, '../../../../appfolder.dev/cache');
85+
86+
// Remove headers-rbtc.bin if it exists
87+
const headersPath = path.join(basePath, 'headers-rbtc.bin');
88+
if (fs.existsSync(headersPath)) {
89+
fs.rmSync(headersPath, { force: true });
90+
console.log(`Removed: ${headersPath}`);
91+
}
92+
// Remove all account-*rbtc* directories
93+
const entries = fs.readdirSync(basePath);
94+
for (const entry of entries) {
95+
if (/^account-.*rbtc/i.test(entry)) {
96+
const dirPath = path.join(basePath, entry);
97+
fs.rmSync(dirPath, { recursive: true, force: true });
98+
console.log(`Removed directory: ${dirPath}`);
99+
}
100+
}
101+
} catch (err) {
102+
console.warn('Warning: Failed to clean up cache or headers before regtest launch:', err);
103+
}
104+
105+
const scriptPath = path.resolve(__dirname, '../../../../scripts/run_regtest.sh');
106+
107+
return new Promise((resolve, reject) => {
108+
const proc = spawn('bash', [scriptPath], {
109+
detached: true,
110+
stdio: ['ignore', 'pipe', 'pipe'], // capture stdout/stderr
111+
});
112+
113+
// Listen for the line we want
114+
const onData = (data: Buffer) => {
115+
const text = data.toString();
116+
process.stdout.write(text); // still print it to console
117+
if (text.includes('waiting for 0 blocks to download (IBD)')) {
118+
proc.stdout.off('data', onData);
119+
proc.stderr.off('data', onData);
120+
resolve(proc); // resolve when we see the line
121+
}
122+
};
123+
124+
proc.stdout.on('data', onData);
125+
proc.stderr.on('data', onData);
126+
127+
proc.on('error', reject);
128+
});
129+
}
130+
131+
132+
/**
133+
* Cleans up all regtest-related processes and Docker containers.
134+
*
135+
* @param regtest The ChildProcess returned by launchRegtest()
136+
*/
137+
export async function cleanupRegtest(
138+
regtest?: ChildProcess,
139+
): Promise<void> {
140+
console.log('Cleaning up regtest environment');
141+
142+
143+
// Kill the regtest process group (spawned as detached)
144+
if (regtest?.pid) {
145+
try {
146+
process.kill(-regtest.pid, 'SIGTERM');
147+
} catch (err) {
148+
console.warn('Failed to kill regtest process:', err);
149+
}
150+
}
151+
152+
153+
// Remove Docker containers
154+
try {
155+
await execAsync(`
156+
docker container rm -f bitcoind-regtest electrs-regtest1 electrs-regtest2 >/dev/null 2>&1 || true
157+
`);
158+
console.log('Docker containers cleaned up.');
159+
} catch (err) {
160+
console.warn('Docker cleanup failed:', err);
161+
}
162+
163+
164+
// Remove regtest data directories
165+
const dirs = [
166+
'/tmp/regtest/btcdata',
167+
'/tmp/regtest/electrsdata1',
168+
'/tmp/regtest/electrsdata2',
169+
];
170+
171+
for (const dir of dirs) {
172+
try {
173+
await execAsync(`rm -rf ${dir}`);
174+
console.log(`Deleted directory: ${dir}`);
175+
} catch (err) {
176+
console.warn(`Failed to delete directory ${dir}:`, err);
177+
}
178+
}
179+
180+
}

0 commit comments

Comments
 (0)