@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
439 lines • 19.7 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
Object.defineProperty(exports, "__esModule", { value: true });
/**
* @jest-environment jsdom
*/
require("../../__tests__/test-helpers/dom-polyfill");
const react_1 = __importStar(require("react"));
const react_2 = require("@testing-library/react");
const rxjs_1 = require("rxjs");
const cryptoassets_1 = require("@ledgerhq/cryptoassets");
const account_1 = require("../../mock/account");
const BridgeSync_1 = require("./BridgeSync");
const currencies_1 = require("../../currencies");
const Bridge = __importStar(require(".."));
const context_1 = require("./context");
jest.setTimeout(30000);
const defaultsBridgeSyncOpts = {
accounts: [],
updateAccountWithUpdater: () => { },
recoverError: e => e,
trackAnalytics: () => { },
prepareCurrency: () => Promise.resolve(),
hydrateCurrency: () => Promise.resolve(),
blacklistedTokenIds: [],
};
(0, currencies_1.setSupportedCurrencies)(["bitcoin", "ethereum"]);
const bitcoin = (0, cryptoassets_1.getCryptoCurrencyById)("bitcoin");
const ethereum = (0, cryptoassets_1.getCryptoCurrencyById)("ethereum");
const createAccount = (id, currency, options = {}) => (0, account_1.genAccount)(id, { ...options, currency });
const renderBridgeSync = (props = {}, children = null) => (0, react_2.render)(react_1.default.createElement(BridgeSync_1.BridgeSync, { ...defaultsBridgeSyncOpts, ...props }, children));
const baseGetAccountBridge = Bridge.getAccountBridge;
const withMockedAccountBridge = (account, syncFactory) => {
const originalBridge = baseGetAccountBridge(account);
const mockBridge = {
...originalBridge,
sync: syncFactory,
};
return jest.spyOn(Bridge, "getAccountBridge").mockImplementation(acc => {
if (acc.id === account.id) {
return mockBridge;
}
return baseGetAccountBridge(acc);
});
};
const mockBridgeSync = (account, producer) => withMockedAccountBridge(account, () => new rxjs_1.Observable(observer => {
const cleanup = producer(observer);
return typeof cleanup === "function" ? cleanup : undefined;
}));
describe("BridgeSync", () => {
afterEach(() => {
jest.restoreAllMocks();
(0, BridgeSync_1.resetStates)();
});
test("initialize without an error", async () => {
renderBridgeSync({}, "LOADED");
expect(react_2.screen.getByText("LOADED")).not.toBeNull();
});
test("executes a sync at start tracked as reason=initial", done => {
const account = createAccount("btc1", bitcoin);
const futureOpLength = account.operations.length;
// we remove the first operation to feed it back as a broadcasted one, the mock impl will make it go back to operations
const lastOp = account.operations.splice(0, 1)[0];
Bridge.getAccountBridge(account).broadcast({
account,
signedOperation: {
operation: lastOp,
signature: "",
},
});
const accounts = [account];
expect(accounts[0].operations.length).toBe(futureOpLength - 1);
function track(type, opts) {
if (type === "SyncSuccess") {
expect(opts).toMatchObject({
reason: "initial",
currencyName: "Bitcoin",
operationsLength: futureOpLength,
});
done();
}
}
renderBridgeSync({ accounts, trackAnalytics: track });
});
test("sync all accounts in parallel at start tracked as reason=initial", done => {
const accounts = [
createAccount("2btc1", bitcoin),
createAccount("2btc2", bitcoin),
createAccount("2eth1", ethereum),
];
const synced = [];
let resolveFirst;
function prepareCurrency() {
if (!resolveFirst) {
return new Promise((resolve, reject) => {
resolveFirst = resolve;
setTimeout(reject, 5000, new Error("prepareCurrency doesn't seem to be called in parallel"));
});
}
// if we reach here, it means, we managed to have
// a SECOND sync that need to prepare currency
// so it's a proof that sync correctly runs in parallel
// otherwise it would timeout
resolveFirst();
return Promise.resolve();
}
function track(type, opts) {
expect(type).not.toEqual("SyncError");
if (type === "SyncSuccess") {
synced.push(opts);
expect(opts).toMatchObject({
reason: "initial",
});
if (synced.length === accounts.length)
done();
}
}
renderBridgeSync({
accounts,
prepareCurrency,
trackAnalytics: track,
});
});
test("provides context values correctly", () => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
let syncFunction;
let syncState;
function TestComponent() {
syncFunction = (0, context_1.useBridgeSync)();
syncState = (0, context_1.useBridgeSyncState)();
return react_1.default.createElement("div", { "data-testid": "test-component" }, "Test");
}
renderBridgeSync({ accounts }, react_1.default.createElement(TestComponent, null));
expect(syncFunction).toBeDefined();
expect(typeof syncFunction).toBe("function");
expect(syncState).toBeDefined();
expect(typeof syncState).toBe("object");
});
test("handles sync errors with recoverError function", done => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
const mockError = new Error("Sync failed");
const recoverError = jest.fn((error) => {
expect(error.message).toBe("Sync failed");
return error; // Return error to treat as actual error
});
// Mock the account bridge to return an Observable that emits an error
mockBridgeSync(account, observer => {
const timeout = setTimeout(() => observer.error(mockError), 100);
return () => clearTimeout(timeout);
});
let syncStateChecked = false;
let syncStateRef;
function TestComponent() {
const syncState = (0, context_1.useBridgeSyncState)();
syncStateRef = syncState;
// After the error is silenced, the sync state should show no error
setTimeout(() => {
if (!syncStateChecked && syncStateRef[account.id]) {
syncStateChecked = true;
expect(syncStateRef[account.id].error).toBe(mockError);
expect(recoverError).toHaveBeenCalledWith(mockError);
done();
}
}, 200);
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts, recoverError }, react_1.default.createElement(TestComponent, null));
});
test("silences errors when recoverError returns null", done => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
const mockError = new Error("Sync failed but should be silenced");
const recoverError = jest.fn(() => null); // Return null to silence the error
// Mock the account bridge to return an Observable that emits an error
mockBridgeSync(account, observer => {
const timeout = setTimeout(() => observer.error(mockError), 100);
return () => clearTimeout(timeout);
});
let syncStateChecked = false;
let syncStateRef;
function TestComponent() {
const syncState = (0, context_1.useBridgeSyncState)();
syncStateRef = syncState;
// After the error is silenced, the sync state should show no error
setTimeout(() => {
if (!syncStateChecked && syncStateRef[account.id]) {
syncStateChecked = true;
expect(syncStateRef[account.id].error).toBeNull();
expect(recoverError).toHaveBeenCalledWith(mockError);
done();
}
}, 200);
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts, recoverError }, react_1.default.createElement(TestComponent, null));
});
test("handles blacklisted token IDs in sync config", () => {
const account = createAccount("btc1", bitcoin);
const blacklistedTokenIds = ["token1", "token2"];
renderBridgeSync({ accounts: [account], blacklistedTokenIds });
// Test passes if component renders without errors with blacklisted tokens
expect(blacklistedTokenIds).toHaveLength(2);
});
test("handles sync actions correctly", () => {
const account1 = createAccount("btc1", bitcoin);
const account2 = createAccount("eth1", ethereum);
const accounts = [account1, account2];
let sync;
function TestComponent() {
sync = (0, context_1.useBridgeSync)();
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts }, react_1.default.createElement(TestComponent, null));
expect(sync).toBeDefined();
// Test different sync actions
expect(() => {
sync?.({ type: "SYNC_ALL_ACCOUNTS", priority: 1, reason: "manual" });
}).not.toThrow();
expect(() => {
sync?.({ type: "SYNC_ONE_ACCOUNT", accountId: account1.id, priority: 1, reason: "manual" });
}).not.toThrow();
expect(() => {
sync?.({
type: "SYNC_SOME_ACCOUNTS",
accountIds: [account1.id],
priority: 1,
reason: "manual",
});
}).not.toThrow();
expect(() => {
sync?.({ type: "SET_SKIP_UNDER_PRIORITY", priority: 5 });
}).not.toThrow();
expect(() => {
sync?.({ type: "BACKGROUND_TICK", reason: "background" });
}).not.toThrow();
});
test("handles pending operations sync", () => {
const account = createAccount("btc1", bitcoin);
// Create account with pending operations
const accountWithPending = {
...account,
pendingOperations: [
{
id: "pending1",
hash: "hash1",
type: "OUT",
value: account.balance,
fee: account.balance.dividedBy(10),
blockHash: null,
blockHeight: null,
senders: [account.freshAddress],
recipients: ["recipient1"],
accountId: account.id,
date: new Date(),
extra: {},
},
],
};
const accounts = [accountWithPending];
let sync;
function TestComponent() {
sync = (0, context_1.useBridgeSync)();
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts }, react_1.default.createElement(TestComponent, null));
expect(sync).toBeDefined();
// The component should automatically sync accounts with pending operations
// This is tested by checking that the component renders without errors
// and that pending operations are handled properly
expect(accountWithPending.pendingOperations.length).toBeGreaterThan(0);
});
test("hydrates currencies only once", async () => {
const account1 = createAccount("btc1", bitcoin);
const account2 = createAccount("btc2", bitcoin); // Same currency
const account3 = createAccount("eth1", ethereum);
const accounts = [account1, account2, account3];
const hydrateCurrency = jest.fn(() => Promise.resolve());
renderBridgeSync({ accounts, hydrateCurrency });
// Wait for hydration to complete
await new Promise(resolve => setTimeout(resolve, 50));
// Should only hydrate each currency once, not once per account
expect(hydrateCurrency).toHaveBeenCalledTimes(2); // BTC and ETH
expect(hydrateCurrency).toHaveBeenCalledWith(bitcoin);
expect(hydrateCurrency).toHaveBeenCalledWith(ethereum);
});
test("handles different sync actions", () => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
let sync;
function TestComponent() {
sync = (0, context_1.useBridgeSync)();
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts }, react_1.default.createElement(TestComponent, null));
expect(sync).toBeDefined();
// Test that sync actions can be called without throwing
expect(sync).toBeDefined();
const syncFn = sync; // Assert non-null since we just checked
expect(() => syncFn({ type: "SYNC_ALL_ACCOUNTS", priority: 1, reason: "manual" })).not.toThrow();
expect(() => syncFn({ type: "SYNC_ONE_ACCOUNT", accountId: account.id, priority: 1, reason: "manual" })).not.toThrow();
expect(() => syncFn({ type: "SET_SKIP_UNDER_PRIORITY", priority: 5 })).not.toThrow();
expect(() => syncFn({ type: "BACKGROUND_TICK", reason: "background" })).not.toThrow();
});
test("tracks session analytics when all accounts complete", async () => {
const account1 = createAccount("btc1", bitcoin, { operationsSize: 3 });
const account2 = createAccount("eth1", ethereum, { operationsSize: 5 });
const accounts = [account1, account2];
const trackAnalytics = jest.fn();
renderBridgeSync({ accounts, trackAnalytics });
// Wait for potential analytics calls
await new Promise(resolve => setTimeout(resolve, 100));
// The component should not throw when tracking analytics
expect(accounts).toHaveLength(2);
});
test("handles non-existent account sync gracefully", () => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
let sync;
function TestComponent() {
sync = (0, context_1.useBridgeSync)();
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts }, react_1.default.createElement(TestComponent, null));
// Try to sync an account that doesn't exist - should not throw
expect(sync).toBeDefined();
const syncFn = sync;
expect(() => {
syncFn({
type: "SYNC_ONE_ACCOUNT",
accountId: "non-existent-account",
priority: 1,
reason: "manual",
});
}).not.toThrow();
});
test("does not send analytics for background sync reason", done => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
const trackAnalytics = jest.fn();
// Mock the account bridge to complete successfully
mockBridgeSync(account, observer => {
observer.next((acc) => acc);
observer.complete();
});
function TestComponent() {
const sync = (0, context_1.useBridgeSync)();
(0, react_1.useEffect)(() => {
// Trigger a background sync
sync({
type: "SYNC_ONE_ACCOUNT",
accountId: account.id,
priority: 1,
reason: "background",
});
}, [sync]);
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts, trackAnalytics }, react_1.default.createElement(TestComponent, null));
// Wait for sync to complete and verify no analytics were sent
setTimeout(() => {
// Should not have called trackAnalytics with SyncSuccess for background syncs
const syncSuccessCalls = trackAnalytics.mock.calls.filter(call => call[0] === "SyncSuccess");
expect(syncSuccessCalls).toHaveLength(0);
// Verify trackAnalytics was not called at all with SyncSuccess
expect(trackAnalytics).not.toHaveBeenCalledWith("SyncSuccess", expect.anything());
done();
}, 200);
});
test("sends analytics for non-background sync reason", done => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
const trackAnalytics = jest.fn();
// Mock the account bridge to complete successfully
mockBridgeSync(account, observer => {
observer.next((acc) => acc);
observer.complete();
});
function TestComponent() {
const sync = (0, context_1.useBridgeSync)();
(0, react_1.useEffect)(() => {
// Trigger a manual (non-background) sync
sync({
type: "SYNC_ONE_ACCOUNT",
accountId: account.id,
priority: 1,
reason: "manual",
});
}, [sync]);
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts, trackAnalytics }, react_1.default.createElement(TestComponent, null));
// Wait for sync to complete and verify analytics were sent
setTimeout(() => {
// Should have called trackAnalytics with SyncSuccess for manual syncs
expect(trackAnalytics).toHaveBeenCalledWith("SyncSuccess", expect.objectContaining({
reason: "manual",
currencyName: account.currency.name,
}));
done();
}, 400);
});
test("provides sync state context", () => {
const account = createAccount("btc1", bitcoin);
const accounts = [account];
let syncState;
function TestComponent() {
syncState = (0, context_1.useBridgeSyncState)();
return react_1.default.createElement("div", null, "Test");
}
renderBridgeSync({ accounts }, react_1.default.createElement(TestComponent, null));
expect(syncState).toBeDefined();
expect(typeof syncState).toBe("object");
});
});
//# sourceMappingURL=BridgeSync.test.js.map