diff --git a/.changeset/fresh-baboons-provide.md b/.changeset/fresh-baboons-provide.md new file mode 100644 index 000000000000..b2f37d9c128f --- /dev/null +++ b/.changeset/fresh-baboons-provide.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/live-common": patch +--- + +Export Event type from 'app' device actions diff --git a/.changeset/late-tips-beg.md b/.changeset/late-tips-beg.md new file mode 100644 index 000000000000..65fc72e09f62 --- /dev/null +++ b/.changeset/late-tips-beg.md @@ -0,0 +1,5 @@ +--- +"live-mobile": patch +--- + +Introduces use\*DeviceAction() hooks in order to mock test all device actions. It impacts all screens that have device actions. diff --git a/.changeset/tidy-rabbits-wonder.md b/.changeset/tidy-rabbits-wonder.md new file mode 100644 index 000000000000..a46313c25fe5 --- /dev/null +++ b/.changeset/tidy-rabbits-wonder.md @@ -0,0 +1,5 @@ +--- +"@ledgerhq/native-ui": patch +--- + +Introduce testID prop on Form/SelectableList diff --git a/apps/ledger-live-mobile/e2e/bridge/client.ts b/apps/ledger-live-mobile/e2e/bridge/client.ts index a06ebe963237..32ccf8f48656 100644 --- a/apps/ledger-live-mobile/e2e/bridge/client.ts +++ b/apps/ledger-live-mobile/e2e/bridge/client.ts @@ -1,22 +1,96 @@ import { Platform } from "react-native"; import { DescriptorEventType } from "@ledgerhq/hw-transport"; import invariant from "invariant"; -import { Subject } from "rxjs"; +import { Subject, Observable } from "rxjs"; +import { AccountRaw } from "@ledgerhq/types-live"; +import { ConnectAppEvent } from "@ledgerhq/live-common/hw/connectApp"; +import { Event as AppEvent } from "@ledgerhq/live-common/hw/actions/app"; +import { ConnectManagerEvent } from "@ledgerhq/live-common/hw/connectManager"; import { store } from "../../src/context/LedgerStore"; import { importSettings } from "../../src/actions/settings"; import { setAccounts } from "../../src/actions/accounts"; import { acceptGeneralTermsLastVersion } from "../../src/logic/terms"; import accountModel from "../../src/logic/accountModel"; import { navigate } from "../../src/rootnavigation"; +import { BleState, SettingsState } from "../../src/reducers/types"; +import { importBle } from "../../src/actions/ble"; +import { InstallLanguageEvent } from "@ledgerhq/live-common/hw/installLanguage"; +import { LoadImageEvent } from "@ledgerhq/live-common/hw/staxLoadImage"; +import { SwapRequestEvent } from "@ledgerhq/live-common/exchange/swap/types"; +import { FetchImageEvent } from "@ledgerhq/live-common/hw/staxFetchImage"; +import { ExchangeRequestEvent } from "@ledgerhq/live-common/hw/actions/startExchange"; +import { CompleteExchangeRequestEvent } from "@ledgerhq/live-common/exchange/platform/types"; +import { RemoveImageEvent } from "@ledgerhq/live-common/hw/staxRemoveImage"; +import { RenameDeviceEvent } from "@ledgerhq/live-common/hw/renameDevice"; -type ClientData = +export type MockDeviceEvent = + | ConnectAppEvent + | AppEvent + | ConnectManagerEvent + | InstallLanguageEvent + | LoadImageEvent + | FetchImageEvent + | ExchangeRequestEvent + | SwapRequestEvent + | RemoveImageEvent + | RenameDeviceEvent + | CompleteExchangeRequestEvent + | { type: "complete" }; + +const mockDeviceEventSubject = new Subject(); + +// these adaptor will filter the event type to satisfy typescript (workaround), it works because underlying exec usage will ignore unknown event type +export const connectAppExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const initSwapExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const startExchangeExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const connectManagerExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const staxFetchImageExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const staxLoadImageExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const staxRemoveImageExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const installLanguageExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const completeExchangeExecMock = (): Observable => + mockDeviceEventSubject as Observable; +export const renameDeviceExecMock = (): Observable => + mockDeviceEventSubject as Observable; + +export type MessageData = | { type: DescriptorEventType; payload: { id: string; name: string; serviceUUID: string }; } - | { type: "open" }; + | { type: "open" } + | { + type: "mockDeviceEvent"; + payload: MockDeviceEvent[]; + } + | { type: "acceptTerms" } + | { type: "navigate"; payload: string } + | { type: "importSettings"; payload: Partial } + | { + type: "importAccounts"; + payload: { + data: AccountRaw; + version: number; + }[]; + } + | { + type: "importBle"; + payload: BleState; + } + | { + type: "setGlobals"; + payload: { [key: string]: unknown }; + }; -export const e2eBridgeClient = new Subject(); +export const e2eBridgeClient = new Subject(); let ws: WebSocket; @@ -35,19 +109,16 @@ export function init(port = 8099) { ws.onmessage = onMessage; } -function onMessage(event: { data: unknown }) { +function onMessage(event: WebSocketMessageEvent) { invariant(typeof event.data === "string", "[E2E Bridge Client]: Message data must be string"); - const msg = JSON.parse(event.data); + const msg: MessageData = JSON.parse(event.data); invariant(msg.type, "[E2E Bridge Client]: type is missing"); log(`Message\n${JSON.stringify(msg, null, 2)}`); - log(`Message type: ${msg.type}`); + + e2eBridgeClient.next(msg); switch (msg.type) { - case "add": - case "open": - e2eBridgeClient.next(msg); - break; case "setGlobals": Object.entries(msg.payload).forEach(([k, v]) => { // @ts-expect-error global bullshit @@ -61,10 +132,18 @@ function onMessage(event: { data: unknown }) { store.dispatch(setAccounts(msg.payload.map(accountModel.decode))); break; } + case "mockDeviceEvent": { + msg.payload.forEach(e => mockDeviceEventSubject.next(e)); + break; + } case "importSettings": { store.dispatch(importSettings(msg.payload)); break; } + case "importBle": { + store.dispatch(importBle(msg.payload)); + break; + } case "navigate": navigate(msg.payload, {}); break; diff --git a/apps/ledger-live-mobile/e2e/bridge/server.ts b/apps/ledger-live-mobile/e2e/bridge/server.ts index 5136909be7cb..c714e50ebead 100644 --- a/apps/ledger-live-mobile/e2e/bridge/server.ts +++ b/apps/ledger-live-mobile/e2e/bridge/server.ts @@ -1,8 +1,12 @@ import { Server } from "ws"; import path from "path"; import fs from "fs"; +import { toAccountRaw } from "@ledgerhq/live-common/account/index"; import { NavigatorName } from "../../src/const"; import { Subject } from "rxjs"; +import { MessageData, MockDeviceEvent } from "./client"; +import { BleState } from "../../src/reducers/types"; +import { Account } from "@ledgerhq/types-live"; type ServerData = { type: "walletAPIResponse"; @@ -47,6 +51,20 @@ export function loadConfig(fileName: string, agreed: true = true): void { } } +export function loadBleState(bleState: BleState) { + postMessage({ type: "importBle", payload: bleState }); +} + +export function loadAccounts(accounts: Account[]) { + postMessage({ + type: "importAccounts", + payload: accounts.map(account => ({ + version: 1, + data: toAccountRaw(account), + })), + }); +} + function navigate(name: string) { postMessage({ type: "navigate", @@ -54,6 +72,13 @@ function navigate(name: string) { }); } +export function mockDeviceEvent(...args: MockDeviceEvent[]) { + postMessage({ + type: "mockDeviceEvent", + payload: args, + }); +} + export function addDevices( deviceNames: string[] = ["Nano X de David", "Nano X de Arnaud", "Nano X de Didier Duchmol"], ): string[] { @@ -74,7 +99,7 @@ export function setInstalledApps(apps: string[] = []) { } export function open() { - postMessage({ type: "open", payload: null }); + postMessage({ type: "open" }); } function onMessage(messageStr: string) { @@ -96,10 +121,10 @@ function log(message: string) { } function acceptTerms() { - postMessage({ type: "acceptTerms", payload: null }); + postMessage({ type: "acceptTerms" }); } -function postMessage(message: { type: string; payload: unknown }) { +function postMessage(message: MessageData) { for (const ws of wss.clients.values()) { ws.send(JSON.stringify(message)); } diff --git a/apps/ledger-live-mobile/e2e/helpers.ts b/apps/ledger-live-mobile/e2e/helpers.ts index 104f5004c2d4..8803df76e289 100644 --- a/apps/ledger-live-mobile/e2e/helpers.ts +++ b/apps/ledger-live-mobile/e2e/helpers.ts @@ -1,4 +1,4 @@ -import { by, device, element, waitFor } from "detox"; +import { by, element, waitFor, device } from "detox"; import { Direction } from "react-native-modal"; const DEFAULT_TIMEOUT = 60000; diff --git a/apps/ledger-live-mobile/e2e/models/DeviceAction.ts b/apps/ledger-live-mobile/e2e/models/DeviceAction.ts new file mode 100644 index 000000000000..e4777b762684 --- /dev/null +++ b/apps/ledger-live-mobile/e2e/models/DeviceAction.ts @@ -0,0 +1,216 @@ +import BigNumber from "bignumber.js"; +import { DeviceModelId } from "@ledgerhq/types-devices"; +import { + deviceInfo155 as deviceInfo, + deviceInfo210lo5, + mockListAppsResult as innerMockListAppResult, +} from "@ledgerhq/live-common/apps/mock"; +import { AppOp } from "@ledgerhq/live-common/apps/types"; +import { AppType, DeviceInfo } from "@ledgerhq/types-live/lib/manager"; +import { Device } from "@ledgerhq/live-common/hw/actions/types"; +import { Transaction } from "@ledgerhq/live-common/generated/types"; +import { waitForElementById, tapById, delay } from "../helpers"; +import { mockDeviceEvent } from "../bridge/server"; +import { DeviceLike } from "../../src/reducers/types"; + +const mockListAppsResult = ( + appDesc: string, + installedDesc: string, + deviceInfo: DeviceInfo, + deviceModelId?: DeviceModelId, +) => { + const result = innerMockListAppResult(appDesc, installedDesc, deviceInfo, deviceModelId); + Object.keys(result?.appByName).forEach(key => { + result.appByName[key] = { ...result.appByName[key], type: AppType.currency }; + }); + return result; +}; + +// this implement mock for the "legacy" device action (the one working with live-common/lib/hw/actions/*) +export default class DeviceAction { + deviceLike: DeviceLike; + device: Device; + + constructor(deviceLike: DeviceLike) { + this.deviceLike = deviceLike; + this.device = this.deviceLikeToDevice(deviceLike); + } + + deviceLikeToDevice(d: DeviceLike): Device { + return { + deviceId: d.id, + deviceName: d.name, + modelId: d.modelId, + wired: false, + }; + } + + async selectMockDevice() { + const elId = "device-item-" + this.deviceLike.id; + await waitForElementById(elId); + await tapById(elId); + } + + async waitForSpinner() { + await waitForElementById("device-action-loading"); + } + + async openApp() { + await this.waitForSpinner(); + mockDeviceEvent({ type: "opened" }); + } + + async genuineCheck(appDesc = "Bitcoin", installedDesc = "Bitcoin") { + await this.waitForSpinner(); + + const { device } = this; + const result = mockListAppsResult(appDesc, installedDesc, deviceInfo); + mockDeviceEvent( + { + type: "deviceChange", + device, + }, + { + type: "listingApps", + deviceInfo, + }, + { + type: "result", + result, + }, + { type: "complete" }, + ); + } + + async accessManager( + appDesc = "Bitcoin,Tron,Litecoin,Ethereum,Ripple,Stellar", + installedDesc = "Bitcoin,Litecoin,Ethereum (outdated)", + ) { + await this.waitForSpinner(); + + const { device } = this; + + const result = mockListAppsResult(appDesc, installedDesc, deviceInfo, device.modelId); + mockDeviceEvent( + { + type: "deviceChange", + device, + }, + { + type: "listingApps", + deviceInfo, + }, + { + type: "result", + result, + }, + { type: "complete" }, + ); + } + + async accessManagerWithL10n( + appDesc = "Bitcoin,Tron,Litecoin,Ethereum,Ripple,Stellar", + installedDesc = "Bitcoin,Litecoin,Ethereum (outdated)", + ) { + await this.waitForSpinner(); + + const { device } = this; + + const result = mockListAppsResult(appDesc, installedDesc, deviceInfo210lo5); + mockDeviceEvent( + { + type: "deviceChange", + device, + }, + { + type: "listingApps", + deviceInfo: deviceInfo210lo5, + }, + { + type: "result", + result, + }, + { type: "complete" }, + ); + } + + async complete() { + mockDeviceEvent({ type: "complete" }); + } + + async initiateLanguageInstallation() { + mockDeviceEvent({ type: "devicePermissionRequested" }); + } + + async add50ProgressToLanguageInstallation() { + mockDeviceEvent({ type: "progress", progress: 0.5 }); + } + + async installSetOfAppsMocked( + progress: number, + itemProgress: number, + currentAppOp: AppOp, + installQueue: string[], + ) { + mockDeviceEvent({ + type: "inline-install", + progress: progress, + itemProgress: itemProgress, + currentAppOp: currentAppOp, + installQueue: installQueue, + }); + } + + async resolveDependenciesMocked(installQueue: string[]) { + mockDeviceEvent({ + type: "listed-apps", + installQueue: installQueue, + }); + } + + async completeLanguageInstallation() { + mockDeviceEvent({ type: "languageInstalled" }); + } + + async requestImageLoad() { + mockDeviceEvent({ type: "loadImagePermissionRequested" }); + } + + async loadImageWithProgress(progress: number) { + mockDeviceEvent({ type: "progress", progress }); + } + + async requestImageCommit() { + mockDeviceEvent({ type: "commitImagePermissionRequested" }); + } + + async confirmImageLoaded(imageSize: number, imageHash: string) { + mockDeviceEvent({ type: "imageLoaded", imageSize, imageHash }); + } + + async initiateSwap(estimatedFees: BigNumber) { + mockDeviceEvent({ type: "opened" }); + await delay(2000); // enough time to allow the UI to switch from one action to another + mockDeviceEvent({ type: "init-swap-requested", estimatedFees }); + } + + async confirmSwap(transaction: Transaction) { + mockDeviceEvent( + { + type: "init-swap-result", + initSwapResult: { + transaction, + swapId: "12345", + }, + }, + { + type: "complete", + }, + ); + } + + async silentSign() { + await this.waitForSpinner(); + mockDeviceEvent({ type: "opened" }, { type: "complete" }); + } +} diff --git a/apps/ledger-live-mobile/e2e/models/accounts/accountsPage.ts b/apps/ledger-live-mobile/e2e/models/accounts/accountsPage.ts index fbad3d448040..b466cd7a09d8 100644 --- a/apps/ledger-live-mobile/e2e/models/accounts/accountsPage.ts +++ b/apps/ledger-live-mobile/e2e/models/accounts/accountsPage.ts @@ -11,4 +11,7 @@ export default class accountsPage { async waitForAccountsPageToLoad() { await waitForElementById("accounts-list-title"); } + async waitForAccountsCoinPageToLoad(coin: string) { + await waitForElementById(`accounts-title-${coin}`); + } } diff --git a/apps/ledger-live-mobile/e2e/models/accounts/addAccountDrawer.ts b/apps/ledger-live-mobile/e2e/models/accounts/addAccountDrawer.ts new file mode 100644 index 000000000000..ba2330d7d0b7 --- /dev/null +++ b/apps/ledger-live-mobile/e2e/models/accounts/addAccountDrawer.ts @@ -0,0 +1,32 @@ +import { openDeeplink, tapById, waitForElementById } from "../../helpers"; + +export default class AddAccountDrawer { + async openViaDeeplink() { + await openDeeplink("add-account"); + } + + async importWithYourLedger() { + const id = "add-accounts-modal-add-button"; + await waitForElementById(id); + await tapById(id); + } + + async selectCurrency(currencyId: string) { + const id = "currency-row-" + currencyId; + await waitForElementById(id); + await tapById(id); + } + + async startAccountsDiscovery() { + await waitForElementById("add-accounts-continue-button"); + } + + async finishAccountsDiscovery() { + await tapById("add-accounts-continue-button"); + } + + async tapSuccessCta() { + await waitForElementById("add-accounts-success-cta"); + await tapById("add-accounts-success-cta"); + } +} diff --git a/apps/ledger-live-mobile/e2e/models/manager/managerPage.ts b/apps/ledger-live-mobile/e2e/models/manager/managerPage.ts index 23e284e98c97..af721cf2958e 100644 --- a/apps/ledger-live-mobile/e2e/models/manager/managerPage.ts +++ b/apps/ledger-live-mobile/e2e/models/manager/managerPage.ts @@ -1,3 +1,4 @@ +import { waitFor } from "detox"; import { getElementById, openDeeplink } from "../../helpers"; const baseLink = "myledger"; @@ -8,4 +9,8 @@ export default class ManagerPage { async openViaDeeplink() { await openDeeplink(baseLink); } + + async waitForManagerPageToLoad() { + await waitFor(this.managerTitle()).toBeVisible(); + } } diff --git a/apps/ledger-live-mobile/e2e/models/receive.ts b/apps/ledger-live-mobile/e2e/models/receive.ts new file mode 100644 index 000000000000..95ac93f269ca --- /dev/null +++ b/apps/ledger-live-mobile/e2e/models/receive.ts @@ -0,0 +1,12 @@ +import { openDeeplink, tapById, waitForElementById } from "../helpers"; + +export default class ReceivePage { + openViaDeeplink() { + return openDeeplink("receive"); + } + async selectCurrency(currencyId: string) { + const id = "currency-row-" + currencyId; + await waitForElementById(id); + await tapById(id); + } +} diff --git a/apps/ledger-live-mobile/e2e/models/send.ts b/apps/ledger-live-mobile/e2e/models/send.ts new file mode 100644 index 000000000000..967bf890ec58 --- /dev/null +++ b/apps/ledger-live-mobile/e2e/models/send.ts @@ -0,0 +1,38 @@ +import { getElementById, tapById, waitForElementById } from "../helpers"; + +export default class ReceivePage { + async selectAccount(accountId: string) { + const id = "account-card-" + accountId; + await waitForElementById(id); + await tapById(id); + } + + async setRecipient(address: string) { + const element = getElementById("recipient-input"); + await element.replaceText(address); + await element.tapReturnKey(); + } + + async recipientContinue() { + await tapById("recipient-continue-button"); + } + + async setAmount(amount: string) { + const element = getElementById("amount-input"); + await element.replaceText(amount); + await element.tapReturnKey(); + } + + async amountContinue() { + await tapById("amount-continue-button"); + } + + async summaryContinue() { + await tapById("summary-continue-button"); + } + + async successContinue() { + await waitForElementById("success-close-button"); + await tapById("success-close-button"); + } +} diff --git a/apps/ledger-live-mobile/e2e/models/wallet/portfolioPage.ts b/apps/ledger-live-mobile/e2e/models/wallet/portfolioPage.ts index a8484e8fb2ab..09bc58256ab9 100644 --- a/apps/ledger-live-mobile/e2e/models/wallet/portfolioPage.ts +++ b/apps/ledger-live-mobile/e2e/models/wallet/portfolioPage.ts @@ -16,6 +16,8 @@ export default class PortfolioPage { portfolioSettingsButton = () => getElementById("settings-icon"); transferButton = () => getElementById("transfer-button"); swapTransferMenuButton = () => getElementById("swap-transfer-button"); + sendTransferMenuButton = () => getElementById("transfer-send-button"); + sendMenuButton = () => getElementById("send-button"); marketTabButton = () => getElementById("tab-bar-market"); navigateToSettings() { @@ -35,6 +37,20 @@ export default class PortfolioPage { return waitForElementById("settings-icon", 120000); } + async navigateToSendFromTransferMenu() { + await tapByElement(this.sendTransferMenuButton()); + } + + async openAddAccount() { + const element = getElementById("add-account-button"); + await element.tap(); + } + + async receive() { + const element = getElementById("receive-button"); + await element.tap(); + } + async waitForPortfolioReadOnly() { await waitForElementById(this.readOnlyPortfolioId); expect(await getTextOfElement(this.graphCardBalanceId)).toBe(this.zeroBalance); @@ -49,4 +65,8 @@ export default class PortfolioPage { openMarketPage() { return tapByElement(this.marketTabButton()); } + + openMyLedger() { + return tapByElement(getElementById("TabBarManager")); + } } diff --git a/apps/ledger-live-mobile/e2e/setups/onboardingcompleted.json b/apps/ledger-live-mobile/e2e/setups/onboardingcompleted.json index 72a76e844d1e..49fa2b62f573 100644 --- a/apps/ledger-live-mobile/e2e/setups/onboardingcompleted.json +++ b/apps/ledger-live-mobile/e2e/setups/onboardingcompleted.json @@ -22,6 +22,8 @@ "loaded": true, "shareAnalytics": true, "sentryLogs": true, + "readOnlyModeEnabled": false, + "hasOrderedNano": true, "lastUsedVersion": "99.99.99", "dismissedBanners": [], "accountsViewMode": "list", diff --git a/apps/ledger-live-mobile/e2e/specs/addAccounts/bitcoin.spec.ts b/apps/ledger-live-mobile/e2e/specs/addAccounts/bitcoin.spec.ts new file mode 100644 index 000000000000..4616a11352a3 --- /dev/null +++ b/apps/ledger-live-mobile/e2e/specs/addAccounts/bitcoin.spec.ts @@ -0,0 +1,55 @@ +import { expect, device } from "detox"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { loadBleState, loadConfig } from "../../bridge/server"; +import PortfolioPage from "../../models/wallet/portfolioPage"; +import DeviceAction from "../../models/DeviceAction"; +import AccountsPage from "../../models/accounts/accountsPage"; +import AddAccountDrawer from "../../models/accounts/addAccountDrawer"; +import { getElementByText } from "../../helpers"; + +let portfolioPage: PortfolioPage; +let deviceAction: DeviceAction; +let accountsPage: AccountsPage; +let addAccountDrawer: AddAccountDrawer; + +const knownDevice = { + name: "Nano X de test", + id: "mock_1", + modelId: DeviceModelId.nanoX, +}; + +describe("Add Bitcoin Accounts", () => { + beforeAll(async () => { + loadConfig("onboardingcompleted", true); + loadBleState({ knownDevices: [knownDevice] }); + + portfolioPage = new PortfolioPage(); + deviceAction = new DeviceAction(knownDevice); + accountsPage = new AccountsPage(); + addAccountDrawer = new AddAccountDrawer(); + + await portfolioPage.waitForPortfolioPageToLoad(); + }); + + it("open add accounts from portfolio", async () => { + await addAccountDrawer.openViaDeeplink(); + }); + + it("add Bitcoin accounts", async () => { + await addAccountDrawer.selectCurrency("bitcoin"); + // device actions and add accounts modal have animations that requires to disable synchronization default detox behavior + await device.disableSynchronization(); + await deviceAction.selectMockDevice(); + await deviceAction.openApp(); + await addAccountDrawer.startAccountsDiscovery(); + await expect(getElementByText("Bitcoin 2")).toBeVisible(); + await addAccountDrawer.finishAccountsDiscovery(); + await addAccountDrawer.tapSuccessCta(); + }); + + it("displays accounts page summary", async () => { + await device.disableSynchronization(); + await accountsPage.waitForAccountsCoinPageToLoad("Bitcoin"); + await expect(getElementByText("1.19576 BTC")).toBeVisible(); + }); +}); diff --git a/apps/ledger-live-mobile/e2e/specs/manager.spec.ts b/apps/ledger-live-mobile/e2e/specs/manager.spec.ts new file mode 100644 index 000000000000..860b80111520 --- /dev/null +++ b/apps/ledger-live-mobile/e2e/specs/manager.spec.ts @@ -0,0 +1,44 @@ +import { expect, device } from "detox"; +import { loadBleState, loadConfig } from "../bridge/server"; +import PortfolioPage from "../models/wallet/portfolioPage"; +import DeviceAction from "../models/DeviceAction"; +import ManagerPage from "../models/manager/managerPage"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { getElementByText, waitForElementByText } from "../helpers"; + +let portfolioPage: PortfolioPage; +let deviceAction: DeviceAction; +let managerPage: ManagerPage; + +const knownDevice = { + name: "Nano X de test", + id: "mock_1", + modelId: DeviceModelId.nanoX, +}; + +describe("Bitcoin Account", () => { + beforeAll(async () => { + loadConfig("onboardingcompleted", true); + loadBleState({ knownDevices: [knownDevice] }); + + portfolioPage = new PortfolioPage(); + deviceAction = new DeviceAction(knownDevice); + managerPage = new ManagerPage(); + + await portfolioPage.waitForPortfolioPageToLoad(); + }); + + it("open manager", async () => { + await portfolioPage.openMyLedger(); + // device actions have animations that requires to disable synchronization default detox behavior + await device.disableSynchronization(); + await deviceAction.selectMockDevice(); + await deviceAction.accessManager(); + await managerPage.waitForManagerPageToLoad(); + }); + + it("displays device name", async () => { + await waitForElementByText(knownDevice.name); + await expect(getElementByText(knownDevice.name)).toExist(); + }); +}); diff --git a/apps/ledger-live-mobile/e2e/specs/receive/bitcoin.spec.ts b/apps/ledger-live-mobile/e2e/specs/receive/bitcoin.spec.ts new file mode 100644 index 000000000000..6be1ca86da2a --- /dev/null +++ b/apps/ledger-live-mobile/e2e/specs/receive/bitcoin.spec.ts @@ -0,0 +1,56 @@ +import { device } from "detox"; +import { loadBleState, loadConfig } from "../../bridge/server"; +import PortfolioPage from "../../models/wallet/portfolioPage"; +import ReceivePage from "../../models/receive"; +import DeviceAction from "../../models/DeviceAction"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { tapByText, waitForElementById, waitForElementByText } from "../../helpers"; + +let portfolioPage: PortfolioPage; +let receivePage: ReceivePage; +let deviceAction: DeviceAction; + +const knownDevice = { + name: "Nano X de test", + id: "mock_1", + modelId: DeviceModelId.nanoX, +}; + +describe("Bitcoin 2 account", () => { + beforeAll(async () => { + loadConfig("onboardingcompleted", true); + loadBleState({ knownDevices: [knownDevice] }); + + portfolioPage = new PortfolioPage(); + deviceAction = new DeviceAction(knownDevice); + receivePage = new ReceivePage(); + + await portfolioPage.waitForPortfolioPageToLoad(); + }); + + it("receive from portfolio", async () => { + await receivePage.openViaDeeplink(); + }); + + it("receive on Bitcoin 2 (through scanning)", async () => { + await receivePage.selectCurrency("bitcoin"); + + // device actions and add accounts modal have animations that requires to disable synchronization default detox behavior + await device.disableSynchronization(); + await deviceAction.selectMockDevice(); + await deviceAction.openApp(); + + await waitForElementByText("Bitcoin 2"); + await tapByText("Bitcoin 2"); + await waitForElementById("receive-fresh-address"); + }); + + // TODO: TO BE CONTINUED + /* + it("verifies the address", async () => { + await waitForElementByText("Verify my address"); + await tapByText("Verify my address"); + await waitForElementByText("Address verified"); + }); + */ +}); diff --git a/apps/ledger-live-mobile/e2e/specs/send/bitcoin.spec.ts b/apps/ledger-live-mobile/e2e/specs/send/bitcoin.spec.ts new file mode 100644 index 000000000000..ab21c94fc41b --- /dev/null +++ b/apps/ledger-live-mobile/e2e/specs/send/bitcoin.spec.ts @@ -0,0 +1,89 @@ +import { device, expect } from "detox"; +import { genAccount } from "@ledgerhq/live-common/mock/account"; +import { + formatCurrencyUnit, + getCryptoCurrencyById, + setSupportedCurrencies, +} from "@ledgerhq/live-common/currencies/index"; +import { loadAccounts, loadBleState, loadConfig } from "../../bridge/server"; +import PortfolioPage from "../../models/wallet/portfolioPage"; +import SendPage from "../../models/send"; +import DeviceAction from "../../models/DeviceAction"; +import { DeviceModelId } from "@ledgerhq/devices"; +import { getElementByText } from "../../helpers"; + +let portfolioPage: PortfolioPage; +let sendPage: SendPage; +let deviceAction: DeviceAction; + +setSupportedCurrencies(["bitcoin"]); + +const knownDevice = { + name: "Nano X de test", + id: "mock_1", + modelId: DeviceModelId.nanoX, +}; + +const bitcoinAccount = genAccount("mock1", { + currency: getCryptoCurrencyById("bitcoin"), +}); + +describe("Bitcoin send flow", () => { + beforeAll(async () => { + loadConfig("onboardingcompleted", true); + loadBleState({ knownDevices: [knownDevice] }); + loadAccounts([bitcoinAccount]); + + portfolioPage = new PortfolioPage(); + deviceAction = new DeviceAction(knownDevice); + sendPage = new SendPage(); + + await portfolioPage.waitForPortfolioPageToLoad(); + }); + + it("open account send flow", async () => { + await portfolioPage.openTransferMenu(); + await portfolioPage.navigateToSendFromTransferMenu(); + }); + + const halfBalance = bitcoinAccount.balance.div(2); + const amount = + // half of the balance, formatted with the same unit as what the input should use + formatCurrencyUnit(bitcoinAccount.unit, halfBalance); + + const amountWithCode = formatCurrencyUnit(bitcoinAccount.unit, halfBalance, { + showCode: true, + }); + + it("traverse through the send flow", async () => { + await sendPage.selectAccount(bitcoinAccount.id); + await sendPage.setRecipient(bitcoinAccount.freshAddress); + await sendPage.recipientContinue(); + + await sendPage.setAmount(amount); + await sendPage.amountContinue(); + + await expect(getElementByText(amountWithCode)).toBeVisible(); + await sendPage.summaryContinue(); + + // device actions and add accounts modal have animations that requires to disable synchronization default detox behavior + await device.disableSynchronization(); + await deviceAction.selectMockDevice(); + await deviceAction.openApp(); + await device.enableSynchronization(); + + await sendPage.successContinue(); + }); + + /* + // FIXME unclear why it doesn't pass + it("displays our new operation in the operation list", async () => { + const roundedAmountWithCode = formatCurrencyUnit(bitcoinAccount.unit, halfBalance, { + showCode: true, + disableRounding: false, + }); + await waitForElementByText(roundedAmountWithCode); + await expect(getElementByText(roundedAmountWithCode)).toBeVisible(); + }); + */ +}); diff --git a/apps/ledger-live-mobile/src/components/AccountCard.tsx b/apps/ledger-live-mobile/src/components/AccountCard.tsx index e9b5d32c5e12..b4b2eea02bc1 100644 --- a/apps/ledger-live-mobile/src/components/AccountCard.tsx +++ b/apps/ledger-live-mobile/src/components/AccountCard.tsx @@ -43,7 +43,7 @@ const AccountCard = ({ getTagDerivationMode(currency, account.derivationMode as DerivationMode); return ( - + void; }; -const action = createAction(installLanguage); const ChangeDeviceLanguageAction: React.FC = ({ device, language, @@ -27,6 +25,7 @@ const ChangeDeviceLanguageAction: React.FC = ({ onResult, onError, }) => { + const action = useInstallLanguageDeviceAction(); const showAlert = !device?.wired; const { t } = useTranslation(); const request = useMemo(() => ({ language }), [language]); diff --git a/apps/ledger-live-mobile/src/components/ChangeDeviceLanguageActionModal.tsx b/apps/ledger-live-mobile/src/components/ChangeDeviceLanguageActionModal.tsx index f2cf6bb34a78..7624d8defd35 100644 --- a/apps/ledger-live-mobile/src/components/ChangeDeviceLanguageActionModal.tsx +++ b/apps/ledger-live-mobile/src/components/ChangeDeviceLanguageActionModal.tsx @@ -2,10 +2,9 @@ import React, { useMemo } from "react"; import { Device } from "@ledgerhq/live-common/hw/actions/types"; import { Language } from "@ledgerhq/types-live"; -import { createAction } from "@ledgerhq/live-common/hw/actions/installLanguage"; -import installLanguage from "@ledgerhq/live-common/hw/installLanguage"; import DeviceActionModal from "./DeviceActionModal"; import DeviceLanguageInstalled from "./DeviceLanguageInstalled"; +import { useInstallLanguageDeviceAction } from "../hooks/deviceActions"; type Props = { device: Device | null; @@ -15,7 +14,6 @@ type Props = { onResult?: () => void; }; -const action = createAction(installLanguage); const ChangeDeviceLanguageActionModal: React.FC = ({ device, language, @@ -23,6 +21,7 @@ const ChangeDeviceLanguageActionModal: React.FC = ({ onError, onResult, }) => { + const action = useInstallLanguageDeviceAction(); const request = useMemo(() => ({ language }), [language]); return ( diff --git a/apps/ledger-live-mobile/src/components/CurrencyRow.tsx b/apps/ledger-live-mobile/src/components/CurrencyRow.tsx index 0a1ceb4d33a2..e2fc1226c423 100644 --- a/apps/ledger-live-mobile/src/components/CurrencyRow.tsx +++ b/apps/ledger-live-mobile/src/components/CurrencyRow.tsx @@ -30,7 +30,11 @@ const CurrencyRow = ({ currency, style, isOK = true, iconSize = 32, onPress }: P const { colors } = useTheme(); return ( - + = props => { [setDeviceHasImage], ); + const action = useStaxRemoveImageDeviceAction(); + return ( void }> = ({ device, @@ -47,6 +45,7 @@ const CustomImageDeviceAction: React.FC void }> = ({ source, remountMe, }) => { + const action = useStaxLoadImageDeviceAction(); const commandRequest = useMemo(() => ({ hexImage }), [hexImage]); const { t } = useTranslation(); diff --git a/apps/ledger-live-mobile/src/components/DeviceAction/InstallSetOfApps/index.tsx b/apps/ledger-live-mobile/src/components/DeviceAction/InstallSetOfApps/index.tsx index 91b210425c9a..7c63c52483ab 100644 --- a/apps/ledger-live-mobile/src/components/DeviceAction/InstallSetOfApps/index.tsx +++ b/apps/ledger-live-mobile/src/components/DeviceAction/InstallSetOfApps/index.tsx @@ -1,11 +1,9 @@ import React, { useCallback, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { useSelector } from "react-redux"; -import { createAction } from "@ledgerhq/live-common/hw/actions/app"; import type { Device } from "@ledgerhq/live-common/hw/actions/types"; import { SkipReason } from "@ledgerhq/live-common/apps/types"; import withRemountableWrapper from "@ledgerhq/live-common/hoc/withRemountableWrapper"; -import connectApp from "@ledgerhq/live-common/hw/connectApp"; import { Alert, Flex, ProgressLoader, VerticalTimeline } from "@ledgerhq/native-ui"; import { getDeviceModel } from "@ledgerhq/devices"; import { DeviceModelInfo } from "@ledgerhq/types-live"; @@ -19,6 +17,7 @@ import Item, { ItemState } from "./Item"; import Confirmation from "./Confirmation"; import Restore from "./Restore"; import { lastSeenDeviceSelector } from "../../../reducers/settings"; +import { useAppDeviceAction } from "../../../hooks/deviceActions"; type Props = { restore?: boolean; @@ -29,8 +28,6 @@ type Props = { debugLastSeenDeviceModelId?: DeviceModelId; }; -const action = createAction(connectApp); - /** * This component overrides the default rendering for device actions in some * cases, falling back to the default one for the rest. Actions such as user blocking @@ -47,6 +44,7 @@ const InstallSetOfApps = ({ remountMe, debugLastSeenDeviceModelId, }: Props & { remountMe: () => void }) => { + const action = useAppDeviceAction(); const { t } = useTranslation(); const [userConfirmed, setUserConfirmed] = useState(false); const productName = getDeviceModel(selectedDevice.modelId).productName; diff --git a/apps/ledger-live-mobile/src/components/DeviceAction/rendering.tsx b/apps/ledger-live-mobile/src/components/DeviceAction/rendering.tsx index a65fc3f52424..745574d8850d 100644 --- a/apps/ledger-live-mobile/src/components/DeviceAction/rendering.tsx +++ b/apps/ledger-live-mobile/src/components/DeviceAction/rendering.tsx @@ -881,7 +881,9 @@ export function renderLoading({ - {description ?? t("DeviceAction.loading")} + + {description ?? t("DeviceAction.loading")} + {lockModal ? : null} ); diff --git a/apps/ledger-live-mobile/src/components/NavigationHeaderCloseButton.tsx b/apps/ledger-live-mobile/src/components/NavigationHeaderCloseButton.tsx index 7b22b20887cf..2ca57a067ab9 100644 --- a/apps/ledger-live-mobile/src/components/NavigationHeaderCloseButton.tsx +++ b/apps/ledger-live-mobile/src/components/NavigationHeaderCloseButton.tsx @@ -32,6 +32,7 @@ export const NavigationHeaderCloseButton: React.FC = React.memo(({ onPres const navigation = useNavigation>(); return ( (onPress ? onPress() : navigation.popToTop())} > diff --git a/apps/ledger-live-mobile/src/components/RecipientInput.tsx b/apps/ledger-live-mobile/src/components/RecipientInput.tsx index def61be8e20f..8113d34353f0 100644 --- a/apps/ledger-live-mobile/src/components/RecipientInput.tsx +++ b/apps/ledger-live-mobile/src/components/RecipientInput.tsx @@ -37,6 +37,7 @@ const RecipientInput = ({ ref, onPaste, placeholderTranslationKey, ...props }: P return ( diff --git a/apps/ledger-live-mobile/src/components/SelectDevice/DeviceItem.tsx b/apps/ledger-live-mobile/src/components/SelectDevice/DeviceItem.tsx index 995e1ef39baa..e1c0c9a896c6 100644 --- a/apps/ledger-live-mobile/src/components/SelectDevice/DeviceItem.tsx +++ b/apps/ledger-live-mobile/src/components/SelectDevice/DeviceItem.tsx @@ -54,6 +54,7 @@ function DeviceItem({ Icon={CustomIcon || NanoFoldedMedium} renderRight={onMore ? renderOnMore : undefined} disabled={disabled} + testID={"device-item-" + deviceMeta.deviceId} > {deviceMeta.deviceName} {description && ( diff --git a/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx b/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx index 15427cbfe00a..f2eddf4bc089 100644 --- a/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx +++ b/apps/ledger-live-mobile/src/components/SelectDevice2/Item.tsx @@ -40,7 +40,7 @@ const Item = ({ device, onPress }: Props) => { }, [device.modelId]); return ( - onPress(device)}> + onPress(device)} touchableTestID={"device-item-" + device.deviceId}> ( - + 0 && !readOnlyModeEnabled && !areAccountsEmpty ? onSendFunds : null, Icon: IconsLegacy.ArrowTopMedium, disabled: !accountsCount || readOnlyModeEnabled || areAccountsEmpty, + testID: "transfer-send-button", }, { eventProperties: { @@ -144,6 +145,7 @@ export default function TransferDrawer({ onClose }: Omit appCreateAction(mock ? connectAppExecMock : connectApp), [mock]); +} + +export function useTransactionDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo(() => transactionCreateAction(mock ? connectAppExecMock : connectApp), [mock]); +} + +export function useInitSwapDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => + mock + ? initSwapCreateAction(connectAppExecMock, initSwapExecMock) + : initSwapCreateAction(connectApp, initSwap), + [mock], + ); +} + +export function useManagerDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo(() => managerCreateAction(mock ? connectManagerExecMock : connectManager), [mock]); +} + +export function useSignMessageDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo(() => signMessageCreateAction(mock ? connectAppExecMock : connectApp), [mock]); +} + +export function useInstallLanguageDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => installLanguageCreateAction(mock ? installLanguageExecMock : installLanguage), + [mock], + ); +} + +export function useStaxLoadImageDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => staxLoadImageCreateAction(mock ? staxLoadImageExecMock : staxLoadImage), + [mock], + ); +} + +export function useStaxFetchImageDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => staxFetchImageCreateAction(mock ? staxFetchImageExecMock : staxFetchImage), + [mock], + ); +} + +export function useStaxRemoveImageDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => staxRemoveImageCreateAction(mock ? staxRemoveImageExecMock : staxRemoveImage), + [mock], + ); +} + +export function useStartExchangeDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => + mock + ? startExchangeCreateAction(connectAppExecMock, startExchangeExecMock) + : startExchangeCreateAction(connectApp, startExchange), + [mock], + ); +} + +export function useCompleteExchangeDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => completeExchangeCreateAction(mock ? completeExchangeExecMock : completeExchange), + [mock], + ); +} + +export function useRenameDeviceAction() { + const mock = useEnv("MOCK"); + return useMemo( + () => renameDeviceCreateAction(mock ? renameDeviceExecMock : renameDevice), + [mock], + ); +} diff --git a/apps/ledger-live-mobile/src/screens/AddAccounts/02-SelectDevice.tsx b/apps/ledger-live-mobile/src/screens/AddAccounts/02-SelectDevice.tsx index cb36022102dc..0d5936fc6e65 100644 --- a/apps/ledger-live-mobile/src/screens/AddAccounts/02-SelectDevice.tsx +++ b/apps/ledger-live-mobile/src/screens/AddAccounts/02-SelectDevice.tsx @@ -2,9 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { StyleSheet, SafeAreaView } from "react-native"; import { useDispatch } from "react-redux"; import type { Device } from "@ledgerhq/live-common/hw/actions/types"; -import { createAction } from "@ledgerhq/live-common/hw/actions/app"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import connectApp from "@ledgerhq/live-common/hw/connectApp"; import { useIsFocused, useTheme } from "@react-navigation/native"; import { isTokenCurrency } from "@ledgerhq/live-common/currencies/index"; import { Flex } from "@ledgerhq/native-ui"; @@ -24,6 +22,7 @@ import SkipSelectDevice from "../SkipSelectDevice"; import { setLastConnectedDevice, setReadOnlyMode } from "../../actions/settings"; import AddAccountsHeaderRightClose from "./AddAccountsHeaderRightClose"; import { NavigationHeaderBackButton } from "../../components/NavigationHeaderBackButton"; +import { useAppDeviceAction } from "../../hooks/deviceActions"; type Props = StackNavigatorProps; @@ -35,9 +34,8 @@ export const addAccountsSelectDeviceHeaderOptions: ReactNavigationHeaderOptions headerLeft: () => , }; -const action = createAction(connectApp); - export default function AddAccountsSelectDevice({ navigation, route }: Props) { + const action = useAppDeviceAction(); const { currency, analyticsPropertyFlow } = route.params; const { colors } = useTheme(); const [device, setDevice] = useState(null); diff --git a/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx b/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx index 0c01cde9a1e5..45aa20888501 100644 --- a/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx +++ b/apps/ledger-live-mobile/src/screens/AddAccounts/03-Accounts.tsx @@ -546,6 +546,7 @@ class Footer extends PureComponent<{ /> ) : ( @@ -79,6 +80,7 @@ const PortfolioEmptyState = ({ openAddAccountModal }: { openAddAccountModal: () width={"100%"} mt={7} mb={11} + testID="add-account-button" > {t("account.emptyState.addAccountCta")} diff --git a/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccountSelectDevice.tsx b/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccountSelectDevice.tsx index efc1f1ee57c5..9f7c3684ac98 100644 --- a/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccountSelectDevice.tsx +++ b/apps/ledger-live-mobile/src/screens/ReceiveFunds/02-AddAccountSelectDevice.tsx @@ -3,9 +3,7 @@ import { StyleSheet, SafeAreaView } from "react-native"; import { useDispatch } from "react-redux"; import { Flex } from "@ledgerhq/native-ui"; import type { Device } from "@ledgerhq/live-common/hw/actions/types"; -import { createAction } from "@ledgerhq/live-common/hw/actions/app"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import connectApp from "@ledgerhq/live-common/hw/connectApp"; import { useIsFocused, useTheme } from "@react-navigation/native"; import { prepareCurrency } from "../../bridge/cache"; import { ScreenName } from "../../const"; @@ -23,6 +21,7 @@ import { } from "../../components/RootNavigator/types/helpers"; import { NavigationHeaderCloseButtonAdvanced } from "../../components/NavigationHeaderCloseButton"; import { NavigationHeaderBackButton } from "../../components/NavigationHeaderBackButton"; +import { useAppDeviceAction } from "../../hooks/deviceActions"; // Defines some of the header options for this screen to be able to reset back to them. export const addAccountsSelectDeviceHeaderOptions = ( @@ -32,8 +31,6 @@ export const addAccountsSelectDeviceHeaderOptions = ( headerLeft: () => , }); -const action = createAction(connectApp); - export default function AddAccountsSelectDevice({ navigation, route, @@ -43,6 +40,8 @@ export default function AddAccountsSelectDevice({ const [device, setDevice] = useState(null); const dispatch = useDispatch(); + const action = useAppDeviceAction(); + const isFocused = useIsFocused(); const newDeviceSelectionFeatureFlag = useFeature("llmNewDeviceSelection"); diff --git a/apps/ledger-live-mobile/src/screens/ReceiveFunds/03-Confirmation.tsx b/apps/ledger-live-mobile/src/screens/ReceiveFunds/03-Confirmation.tsx index df7eee68e4d1..14654285243e 100644 --- a/apps/ledger-live-mobile/src/screens/ReceiveFunds/03-Confirmation.tsx +++ b/apps/ledger-live-mobile/src/screens/ReceiveFunds/03-Confirmation.tsx @@ -280,7 +280,7 @@ function ReceiveConfirmationInner({ navigation, route, account, parentAccount }: width="100%" justifyContent={"space-between"} > - + {mainAccount.freshAddress} , }); -const action = createAction(connectApp); - export default function ConnectDevice({ navigation, route, @@ -50,8 +47,8 @@ export default function ConnectDevice({ const { account, parentAccount } = useSelector(accountScreenSelector(route)); const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); const [device, setDevice] = useState(); - const newDeviceSelectionFeatureFlag = useFeature("llmNewDeviceSelection"); + const action = useAppDeviceAction(); useEffect(() => { const readOnlyTitle = "transfer.receive.titleReadOnly"; diff --git a/apps/ledger-live-mobile/src/screens/SendFunds/02-SelectRecipient.tsx b/apps/ledger-live-mobile/src/screens/SendFunds/02-SelectRecipient.tsx index b746185d5c05..a06b3f6d90e5 100644 --- a/apps/ledger-live-mobile/src/screens/SendFunds/02-SelectRecipient.tsx +++ b/apps/ledger-live-mobile/src/screens/SendFunds/02-SelectRecipient.tsx @@ -267,6 +267,7 @@ export default function SendSelectRecipient({ navigation, route }: Props) { ) : null}