Skip to content

Commit 8547a65

Browse files
committed
frontends/tests: add AOPP flow test.
1 parent c6f0b8f commit 8547a65

File tree

7 files changed

+473
-19
lines changed

7 files changed

+473
-19
lines changed

.github/workflows/playwright.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,6 @@ jobs:
6969
name: simulator-binary
7070
path: ./simulator-bin
7171

72-
7372
- name: Setup Node.js
7473
uses: actions/setup-node@v3
7574
with:
@@ -85,6 +84,17 @@ jobs:
8584
cd frontends/web
8685
npx playwright install --with-deps chromium webkit
8786
87+
- name: Setup Python 3
88+
uses: actions/setup-python@v4
89+
with:
90+
python-version: 3.11
91+
92+
- name: Install Python dependencies
93+
run: |
94+
sudo apt-get update
95+
sudo apt-get install -y build-essential libffi-dev libssl-dev libsecp256k1-dev python3-dev
96+
pip install --upgrade pip
97+
pip install flask coincurve bech32
8898
8999
- name: Restore executable permission
90100
run: chmod +x simulator-bin/simulator

frontends/web/src/components/aopp/aopp.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,11 @@ export const Aopp = () => {
226226
</p>
227227
<Field>
228228
<Label>{t('aopp.labelAddress')}</Label>
229-
<CopyableInput alignLeft flexibleHeight value={aopp.address} />
229+
<CopyableInput alignLeft flexibleHeight value={aopp.address} dataTestId="aopp-address"/>
230230
</Field>
231231
<Field style={{ marginBottom: 0 }}>
232232
<Label>{t('aopp.labelMessage')}</Label>
233-
<div className={styles.message}>
233+
<div className={styles.message} data-testid="aopp-message">
234234
{aopp.message}
235235
</div>
236236
</Field>

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ export const ActionButtons = ({ canSend, code, coinCode, exchangeSupported, acco
7777
primary
7878
to={`/account/${code}/receive`}
7979
>
80-
<span>{t('generic.receiveWithoutCoinCode')}</span>
80+
<span data-testid="receive-button">{t('generic.receiveWithoutCoinCode')}</span>
8181
</ButtonLink>
8282

8383
{(exchangeSupported && !isMobile) && (

frontends/web/tests/aopp.test.ts

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
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 { test } from './helpers/fixtures';
18+
import { expect } from '@playwright/test';
19+
import { ServeWallet } from './helpers/servewallet';
20+
import { launchRegtest, setupRegtestWallet, sendCoins, mineBlocks, cleanupRegtest } from './helpers/regtest';
21+
import { startSimulator, completeWalletSetupFlow, cleanFakeMemoryFiles } from './helpers/simulator';
22+
import { ChildProcess } from 'child_process';
23+
import { startAOPPServer, generateAOPPRequest } from './helpers/aopp';
24+
import { assertFieldsCount } from './helpers/dom';
25+
26+
27+
let servewallet: ServeWallet;
28+
let regtest: ChildProcess;
29+
let aoppServer: ChildProcess | undefined;
30+
let simulatorProc : ChildProcess | undefined;
31+
32+
test('AOPP', async ({ page, host, frontendPort, servewalletPort }, testInfo) => {
33+
34+
35+
await test.step('Start regtest and init wallet', async () => {
36+
regtest = await launchRegtest();
37+
// Give regtest some time to start
38+
await new Promise((resolve) => setTimeout(resolve, 3000));
39+
await setupRegtestWallet();
40+
});
41+
42+
43+
await test.step('Start servewallet', async () => {
44+
servewallet = new ServeWallet(page, servewalletPort, frontendPort, host, testInfo.title, testInfo.project.name, { regtest: true, testnet: false, simulator: true });
45+
await servewallet.start();
46+
});
47+
48+
await test.step('Start simulator', async () => {
49+
const simulatorPath = process.env.SIMULATOR_PATH;
50+
if (!simulatorPath) {
51+
throw new Error('SIMULATOR_PATH environment variable not set');
52+
}
53+
54+
simulatorProc = startSimulator(simulatorPath, testInfo.title, testInfo.project.name, true);
55+
console.log('Simulator started');
56+
});
57+
58+
59+
await test.step('Initialize wallet', async () => {
60+
await completeWalletSetupFlow(page);
61+
});
62+
63+
let recvAdd: string;
64+
await test.step('Grab receive address', async () => {
65+
await page.getByRole('link', { name: 'Bitcoin Regtest Bitcoin' }).click();
66+
await page.getByRole('button', { name: 'Receive RBTC' }).click();
67+
await page.getByRole('button', { name: 'Verify address on BitBox' }).click();
68+
const addressLocator = page.locator('[data-testid="receive-address"]');
69+
recvAdd = await addressLocator.inputValue();
70+
console.log(`Receive address: ${recvAdd}`);
71+
});
72+
73+
await test.step('Send RBTC to receive address', async () => {
74+
await page.waitForTimeout(2000);
75+
const sendAmount = '10';
76+
await sendCoins(recvAdd, sendAmount);
77+
await mineBlocks(12);
78+
console.log(`Sent ${sendAmount} RBTC to ${recvAdd}`);
79+
});
80+
81+
82+
await test.step('Add second RBTC account', async () => {
83+
await page.goto('/#/account-summary');
84+
await page.getByRole('link', { name: 'Settings' }).click();
85+
await page.getByRole('link', { name: 'Manage Accounts' }).click();
86+
await page.getByRole('button', { name: 'Add account' }).click();
87+
await page.getByRole('button', { name: 'Add account' }).click();
88+
await expect(page.locator('body')).toContainText('Bitcoin Regtest 2 has now been added to your accounts.');
89+
await page.getByRole('button', { name: 'Done' }).click();
90+
});
91+
92+
93+
await test.step('Grab receive address for second account', async () => {
94+
await page.goto('/#/account-summary');
95+
await page.getByRole('link', { name: 'Bitcoin Regtest 2' }).click();
96+
97+
await page.getByRole('button', { name: 'Receive RBTC' }).click();
98+
await page.getByRole('button', { name: 'Verify address on BitBox' }).click();
99+
const addressLocator = page.locator('[data-testid="receive-address"]');
100+
recvAdd = await addressLocator.inputValue();
101+
expect(recvAdd).toContain('bcrt1');
102+
console.log(`Receive address: ${recvAdd}`);
103+
});
104+
105+
await test.step('Send RBTC to receive address', async () => {
106+
await page.waitForTimeout(2000);
107+
const sendAmount = '10';
108+
sendCoins(recvAdd, sendAmount);
109+
mineBlocks(12);
110+
console.log(`Sent ${sendAmount} RBTC to ${recvAdd}`);
111+
});
112+
113+
let aoppRequest: string;
114+
await test.step('Start AOPP server and generate AOPP request', async () => {
115+
console.log('Starting AOPP server...');
116+
aoppServer = await startAOPPServer();
117+
console.log('AOPP server started.');
118+
console.log('Generating AOPP request...');
119+
aoppRequest = await generateAOPPRequest('rbtc');
120+
console.log(`AOPP Request URI: ${aoppRequest}`);
121+
});
122+
123+
await test.step('Kill the simulator', async () => {
124+
// We kill the simulator so that we can verify that with no BB connected,
125+
// the app shows "Address request in progress. Please connect your device to continue"
126+
if (simulatorProc) {
127+
simulatorProc.kill('SIGTERM');
128+
simulatorProc = undefined;
129+
console.log('Simulator killed.');
130+
}
131+
});
132+
133+
await test.step('Kill servewallet and restart with AOPP request', async () => {
134+
await servewallet.stop();
135+
console.log('Servewallet stopped.');
136+
servewallet = new ServeWallet(page, servewalletPort, frontendPort, host, testInfo.title, testInfo.project.name, { regtest: true, testnet: false, simulator: true });
137+
await servewallet.start({ extraFlags: { aoppUrl: aoppRequest } });
138+
console.log('Servewallet restarted with AOPP request.');
139+
});
140+
141+
await test.step('Address request in progress', async () => {
142+
await page.goto('/');
143+
const body = page.locator('body');
144+
await expect(body).toContainText('localhost:8888 is requesting a receiving address');
145+
await page.getByRole('button', { name: 'Continue' }).click();
146+
await expect(body).toContainText('Address request in progress. Please connect your device to continue');
147+
});
148+
149+
// Restart the simulator to continue the AOPP flow
150+
await test.step('Restart simulator to continue AOPP flow', async () => {
151+
const simulatorPath = process.env.SIMULATOR_PATH;
152+
if (!simulatorPath) {
153+
throw new Error('SIMULATOR_PATH environment variable not set');
154+
}
155+
156+
simulatorProc = startSimulator(simulatorPath, testInfo.title, testInfo.project.name, true);
157+
console.log('Simulator restarted.');
158+
});
159+
160+
let aoppAddress : string | null;
161+
await test.step('Verify AOPP flow is in progress', async () => {
162+
await page.goto('/');
163+
const body = page.locator('body');
164+
165+
// Verify that we can select one of two accounts
166+
await assertFieldsCount(page, 'id', 'account', 1);
167+
const options = page.locator('select[id="account"] option');
168+
await expect(options).toHaveCount(2);
169+
170+
// Select the first account.
171+
await page.selectOption('#account', { index: 0 });
172+
173+
await page.getByRole('button', { name: 'Next' }).click();
174+
175+
// The simulator automatically accepts and signs the message request,
176+
// so we should see the success message immediately.
177+
await expect(body).toContainText('Address successfully sent');
178+
await expect(body).toContainText('Proceed on localhost:8888');
179+
180+
const address = page.locator('[data-testid="aopp-address"]');
181+
aoppAddress = await address.textContent();
182+
183+
const message = page.locator('[data-testid="aopp-message"]');
184+
const messageValue = await message.textContent();
185+
expect(messageValue).toContain('I confirm that I solely control this address.'); //TODO extract ID
186+
await page.getByRole('button', { name: 'Done' }).click();
187+
});
188+
189+
190+
await test.step('Compare receive address with aopp address', async () => {
191+
await page.goto('/');
192+
await page.getByRole('link', { name: 'Bitcoin Regtest Bitcoin' }).click();
193+
const receiveButton = page.locator('[data-testid="receive-button"]');
194+
await receiveButton.click();
195+
await page.getByRole('button', { name: 'Verify address on BitBox' }).click();
196+
const addressLocator = page.locator('[data-testid="receive-address"]');
197+
recvAdd = await addressLocator.inputValue();
198+
console.log(`Receive address: ${recvAdd}`);
199+
expect(recvAdd).toBe(aoppAddress);
200+
});
201+
});
202+
203+
204+
// Ensure a clean state before running all tests.
205+
test.beforeAll(async () => {
206+
cleanFakeMemoryFiles();
207+
});
208+
209+
test.afterAll(async () => {
210+
await servewallet.stop();
211+
if (aoppServer) {
212+
aoppServer.kill('SIGTERM');
213+
aoppServer = undefined;
214+
}
215+
await cleanupRegtest(regtest);
216+
if (simulatorProc) {
217+
simulatorProc.kill('SIGTERM');
218+
simulatorProc = undefined;
219+
}
220+
});
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 { spawn, ChildProcessByStdio } from 'child_process';
18+
import path from 'path';
19+
import type { Readable } from 'stream';
20+
21+
/**
22+
* Starts the AOPP server and waits until it prints its "ready" line.
23+
* Returns the spawned child process.
24+
*/
25+
export async function startAOPPServer(): Promise<
26+
ChildProcessByStdio<null, Readable, Readable>
27+
> {
28+
const PROJECT_ROOT = process.env.GITHUB_WORKSPACE ||
29+
path.resolve(__dirname, '../../../..');
30+
31+
const scriptPath = path.resolve(PROJECT_ROOT, 'frontends/web/tests/util/aopp/server.py');
32+
33+
const child = spawn('python3', [scriptPath], {
34+
cwd: PROJECT_ROOT,
35+
stdio: ['ignore', 'pipe', 'pipe'],
36+
env: { ...process.env },
37+
});
38+
39+
const readyMsg = 'Listening on localhost:8888';
40+
41+
await new Promise<void>((resolve, reject) => {
42+
const onData = (data: Buffer) => {
43+
const text = data.toString();
44+
if (text.includes(readyMsg)) {
45+
child.stdout.off('data', onData);
46+
resolve();
47+
}
48+
};
49+
50+
const onError = (err: Error) => {
51+
child.stdout.off('data', onData);
52+
reject(err);
53+
};
54+
55+
child.stdout.on('data', onData);
56+
child.on('error', onError);
57+
});
58+
59+
return child;
60+
}
61+
62+
/**
63+
* Perform a POST request to the AOPP server and return the cleaned `uri` string.
64+
*/
65+
export async function generateAOPPRequest(
66+
asset: 'rbtc' | 'btc' | 'eth' | 'tbtc' = 'rbtc'
67+
): Promise<string> {
68+
const allowed = ['rbtc', 'btc', 'eth', 'tbtc'] as const;
69+
if (!allowed.includes(asset)) {
70+
throw new Error(`Invalid asset: ${asset}. Allowed: ${allowed.join(', ')}`);
71+
}
72+
73+
const url = `http://localhost:8888/generate?asset=${asset}`;
74+
75+
const res = await fetch(url, { method: 'POST' });
76+
77+
if (!res.ok) {
78+
throw new Error(`AOPP server responded with ${res.status}`);
79+
}
80+
81+
const json = await res.json();
82+
83+
if (!json.uri || typeof json.uri !== 'string') {
84+
throw new Error('AOPP server returned unexpected JSON');
85+
}
86+
87+
return json.uri;
88+
}

0 commit comments

Comments
 (0)