diff --git a/.gitignore b/.gitignore index 3931979a..0ae6e738 100644 --- a/.gitignore +++ b/.gitignore @@ -259,3 +259,12 @@ yarn-error.log* next-env.d.ts .env + +# E2E Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ + +# E2E Chrome Extensions +extensions diff --git a/e2e/app.spec.ts b/e2e/app.spec.ts new file mode 100644 index 00000000..724926ae --- /dev/null +++ b/e2e/app.spec.ts @@ -0,0 +1,8 @@ +import { expect, test } from "@playwright/test"; + +test.describe("App", () => { + test("should have a title", async ({ page }) => { + await page.goto("/"); + await expect(page).toHaveTitle(/Staking Dashboard/); + }); +}); diff --git a/e2e/balanceAddress.spec.ts b/e2e/balanceAddress.spec.ts new file mode 100644 index 00000000..5632f3d8 --- /dev/null +++ b/e2e/balanceAddress.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "@playwright/test"; + +import { setupWalletConnection } from "./helper/connect"; + +test.describe("Balance and address checks after connection", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await setupWalletConnection(page); + }); + + test("balance is correct", async ({ page }) => { + const balance = await page.getByTestId("balance").textContent(); + expect(balance).toBe("0.12345678 BTC"); + }); + + test("address is correct", async ({ page }) => { + const address = await page.getByTestId("address").textContent(); + expect(address).toBe("bc1p...97sd"); + }); +}); diff --git a/e2e/constants/staking.ts b/e2e/constants/staking.ts new file mode 100644 index 00000000..0aaf1bab --- /dev/null +++ b/e2e/constants/staking.ts @@ -0,0 +1,6 @@ +import { satoshiToBtc } from "@/utils/btcConversions"; + +export const STAKING_AMOUNT_SAT = 50000; +export const STAKING_AMOUNT_BTC = satoshiToBtc(STAKING_AMOUNT_SAT); +export const STAKING_TX_HASH = + "47af61d63bcc6c513561d9a1198d082052cc07a81f50c6f130653f0a6ecc0fc1"; diff --git a/e2e/helper/connect.ts b/e2e/helper/connect.ts new file mode 100644 index 00000000..4017adb0 --- /dev/null +++ b/e2e/helper/connect.ts @@ -0,0 +1,54 @@ +import { Page, expect } from "@playwright/test"; + +import { injectBTCWallet } from "./injectBTCWallet"; + +export const clickConnectButton = async (page: Page) => { + const connectButton = page.getByRole("button", { + name: "Connect to BTC", + }); + await connectButton.click(); +}; + +export const acceptTermsAndConditions = async (page: Page) => { + const termsCheckbox = page + .locator("label") + .filter({ hasText: "I certify that I have read" }); + + const inscriptionsCheckbox = page + .locator("label") + .filter({ hasText: "I certify that there are no" }); + + const hwCheckbox = page + .locator("label") + .filter({ hasText: "I acknowledge that Keystone via QR code" }); + + await termsCheckbox.click(); + await inscriptionsCheckbox.click(); + await hwCheckbox.click(); + + expect(await termsCheckbox.isChecked()).toBe(true); + expect(await inscriptionsCheckbox.isChecked()).toBe(true); + expect(await hwCheckbox.isChecked()).toBe(true); +}; + +export const clickInjectableWalletButton = async (page: Page) => { + const browserButton = page + .getByTestId("modal") + .getByRole("button", { name: "Browser" }); + await browserButton.click(); +}; + +export const clickConnectWalletButton = async (page: Page) => { + const connectWalletButton = page.getByTestId("modal").getByRole("button", { + name: "Connect to BTC network", + }); + await connectWalletButton.click(); +}; + +export const setupWalletConnection = async (page: Page) => { + await injectBTCWallet(page); + await clickConnectButton(page); + await acceptTermsAndConditions(page); + await clickInjectableWalletButton(page); + await clickConnectWalletButton(page); +}; diff --git a/e2e/helper/injectBTCWallet.ts b/e2e/helper/injectBTCWallet.ts new file mode 100644 index 00000000..3f2c9da8 --- /dev/null +++ b/e2e/helper/injectBTCWallet.ts @@ -0,0 +1,47 @@ +import { Page } from "@playwright/test"; + +// Sample wallet implementation for E2E testing purposes +export const injectBTCWallet = async (page: Page) => { + // Inject the wallet methods into window.btcwallet + await page.evaluate(() => { + // wallet + const btcWallet = { + connectWallet: () => { + return btcWallet; + }, + getWalletProviderName: () => "BTC Wallet", + getAddress: () => + "bc1p8gjpy0vyfdq3tty8sy0v86dvl69rquc85n2gpuztll9wxh9cpars7r97sd", + getPublicKeyHex: () => + "024c6e2954c75bcb53aa13b7cd5d8bcdb4c9a4dd0784d68b115bd4408813b45608", + on: () => {}, + getNetwork: () => "mainnet", + getBTCTipHeight: () => 859568, + getNetworkFees: () => ({ + fastestFee: 1, + halfHourFee: 1, + hourFee: 1, + economyFee: 1, + minimumFee: 1, + }), + getUtxos: () => [ + { + txid: "fa4908ad8876655ccb5ffba6a9eab58e1b785af73703cd58b19526c099d67c05", + vout: 0, + value: 12345678, + scriptPubKey: + "51203a24123d844b4115ac87811ec3e9acfe8a307307a4d480f04bffcae35cb80f47", + }, + ], + getInscriptions: () => [], + signPsbt: (_psbtHex: string) => { + return "70736274ff0100fd040102000000028a12de07985b7d06d83d9683eb3c0a86284fa3cbb2df998aed61009d700748ba0200000000fdffffff4ca53ae433b535b660a2dca99724199b2219a617508eed2ccf88762683a622430200000000fdffffff0350c3000000000000225120cf7c40c6fb1395430816dbb5e1ba9f172ef25573a3b609efa1723559cd82d5590000000000000000496a4762626234004c6e2954c75bcb53aa13b7cd5d8bcdb4c9a4dd0784d68b115bd4408813b45608094f5861be4128861d69ea4b66a5f974943f100f55400bf26f5cce124b4c9af7009604450000000000002251203a24123d844b4115ac87811ec3e9acfe8a307307a4d480f04bffcae35cb80f47340e0d000001012b50ba0000000000002251203a24123d844b4115ac87811ec3e9acfe8a307307a4d480f04bffcae35cb80f470108420140f94b4114bf4c77c449fefb45d60a86831a73897e58b03ba8250e1bf877912cdcc48d106fa266e8aa4085a43e9ad348652fb7b1ad0d820b6455c06edd92cadfef0001012b79510000000000002251203a24123d844b4115ac87811ec3e9acfe8a307307a4d480f04bffcae35cb80f470108420140e7abc0544c68c94a154e9136397ad8ab7d4dce0545c7c0db89aeb9a455e9377fb1c116ca20cdcb1c1ef4c9335a85c34499f45918ee37b010b69220626c4a8d7100000000"; + }, + pushTx: (_txHex: string) => { + return "47af61d63bcc6c513561d9a1198d082052cc07a81f50c6f130653f0a6ecc0fc1"; + }, + }; + + window.btcwallet = btcWallet; + }); +}; diff --git a/e2e/staking.spec.ts b/e2e/staking.spec.ts new file mode 100644 index 00000000..e0c721d0 --- /dev/null +++ b/e2e/staking.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from "@playwright/test"; + +import { + STAKING_AMOUNT_BTC, + STAKING_AMOUNT_SAT, + STAKING_TX_HASH, +} from "./constants/staking"; +import { setupWalletConnection } from "./helper/connect"; + +test.describe("Create staking transaction", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/"); + await setupWalletConnection(page); + }); + + test("prepare the staking", async ({ page }) => { + const previewButton = page.locator("button").filter({ hasText: "Preview" }); + + // Selects the first finality provider in the list + await page.locator("#finality-providers>div>div").first().click(); + expect(previewButton).toBeDisabled(); + + // Preview available after filling the amount + await page.getByPlaceholder("BTC").fill(`${STAKING_AMOUNT_BTC}`); + expect(previewButton).toBeEnabled(); + + await previewButton.click(); + const stakeButton = page.locator("button").filter({ hasText: "Stake" }); + await stakeButton.click(); + + // Success modal + const success = page + .getByTestId("modal") + .locator("div") + .filter({ hasText: "Congratulations!" }); + expect(success).toBeVisible(); + + // Check for local storage + const item = await page.evaluate(() => + localStorage.getItem( + "bbn-staking-delegations-4c6e2954c75bcb53aa13b7cd5d8bcdb4c9a4dd0784d68b115bd4408813b45608", + ), + ); + expect(item).not.toBeNull(); + + const parsed = JSON.parse(item as string); + expect(parsed).toHaveLength(1); + + // Check the staking delegation tx hash and staking value + const [delegation] = parsed; + expect(delegation.stakingValueSat).toBe(STAKING_AMOUNT_SAT); + expect(delegation.stakingTxHashHex).toBe(STAKING_TX_HASH); + }); +}); diff --git a/package-lock.json b/package-lock.json index 766a5c86..8a735bf7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", + "@playwright/test": "^1.46.0", "@tanstack/eslint-plugin-query": "^5.28.11", "@tanstack/react-query-devtools": "^5.28.14", "@testing-library/jest-dom": "^6.4.6", @@ -4778,6 +4779,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.46.0.tgz", + "integrity": "sha512-/QYft5VArOrGRP5pgkrfKksqsKA6CEFyGQ/gjNe6q0y4tZ1aaPfq4gIjudr1s3D+pXyrPRdsy4opKDrjBabE5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.46.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@rushstack/eslint-patch": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.10.3.tgz", @@ -13972,6 +13989,53 @@ "node": ">=4" } }, + "node_modules/playwright": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.46.0.tgz", + "integrity": "sha512-XYJ5WvfefWONh1uPAUAi0H2xXV5S3vrtcnXe6uAOgdGi3aSpqOSXX08IAjXW34xitfuOJsvXU5anXZxPSEQiJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.46.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.46.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.46.0.tgz", + "integrity": "sha512-9Y/d5UIwuJk8t3+lhmMSAJyNP1BUC/DqP3cQJDQQL/oWqAiuPTLgy7Q5dzglmTLwcBRdetzgNM/gni7ckfTr6A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", diff --git a/package.json b/package.json index 65f07a89..65688050 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,11 @@ "prepare": "husky", "sort-imports": "eslint --fix .", "test": "jest", - "test:watch": "jest --watch" + "test:watch": "jest --watch", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:report": "playwright show-report" }, "engines": { "node": ">=22.0.0" @@ -53,6 +57,7 @@ "@babel/preset-react": "^7.24.7", "@babel/preset-typescript": "^7.24.7", "@bitcoin-js/tiny-secp256k1-asmjs": "^2.2.3", + "@playwright/test": "^1.46.0", "@tanstack/eslint-plugin-query": "^5.28.11", "@tanstack/react-query-devtools": "^5.28.14", "@testing-library/jest-dom": "^6.4.6", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..4b458785 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,82 @@ +import { defineConfig, devices } from "@playwright/test"; +import path from "path"; + +// Use process.env.PORT by default and fallback to port 3000 +const PORT = process.env.PORT || 3000; + +// Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port +const baseURL = `http://localhost:${PORT}`; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + // Test directory + testDir: path.join(__dirname, "e2e"), + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: false, + /* Retry on CI only */ + retries: 2, + /* Opt out of parallel tests on CI. */ + workers: 2, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Base URL to use in actions like `await page.goto('/')`. */ + // Use baseURL so to make navigations relative. + // More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url + baseURL, + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + + // { + // name: 'firefox', + // use: { ...devices['Desktop Firefox'] }, + // }, + + // { + // name: 'webkit', + // use: { ...devices['Desktop Safari'] }, + // }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { ...devices['Pixel 5'] }, + // }, + // { + // name: 'Mobile Safari', + // use: { ...devices['iPhone 12'] }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { ...devices['Desktop Edge'], channel: 'msedge' }, + // }, + // { + // name: 'Google Chrome', + // use: { ...devices['Desktop Chrome'], channel: 'chrome' }, + // }, + ], + + /* Run your local dev server before starting the tests */ + webServer: { + command: "npm run dev", + url: baseURL, + timeout: 120 * 1000, + reuseExistingServer: true, + }, +}); diff --git a/src/app/components/Connect/ConnectSmall.tsx b/src/app/components/Connect/ConnectSmall.tsx index 21d33c78..31570d8e 100644 --- a/src/app/components/Connect/ConnectSmall.tsx +++ b/src/app/components/Connect/ConnectSmall.tsx @@ -79,7 +79,10 @@ export const ConnectSmall: React.FC = ({ )} -
+
{trim(address)}
diff --git a/src/app/components/Summary/Summary.tsx b/src/app/components/Summary/Summary.tsx index 344220d4..083189ee 100644 --- a/src/app/components/Summary/Summary.tsx +++ b/src/app/components/Summary/Summary.tsx @@ -92,7 +92,10 @@ export const Summary: React.FC = ({
{typeof btcWalletBalanceSat === "number" ? ( -

+

{maxDecimals(satoshiToBtc(btcWalletBalanceSat), 8)} {coinName}

) : (