|
| 1 | +import { expect } from 'chai' |
| 2 | +import { afterEach, beforeEach, describe, it } from 'mocha' |
| 3 | + |
| 4 | +import { engineSchedulerConfig } from '../../../../src/core/currency/wallet/engine-scheduler' |
| 5 | +import { walletCacheSaverConfig } from '../../../../src/core/currency/wallet/wallet-cache-file' |
| 6 | +import { EdgeContext, makeFakeEdgeWorld } from '../../../../src/index' |
| 7 | +import { snooze } from '../../../../src/util/snooze' |
| 8 | +import { |
| 9 | + createEngineGate, |
| 10 | + fakePluginTestConfig |
| 11 | +} from '../../../fake/fake-currency-plugin' |
| 12 | +import { fakeUser } from '../../../fake/fake-user' |
| 13 | + |
| 14 | +const contextOptions = { apiKey: '', appId: '', deviceDescription: 'iphone12' } |
| 15 | +const quiet = { onLog() {} } |
| 16 | + |
| 17 | +// Generous wait for the throttled cache saver (50ms in tests) to write: |
| 18 | +const SAVE_WAIT_MS = 300 |
| 19 | + |
| 20 | +// Short wait to prove something has *not* happened: |
| 21 | +const RACE_WAIT_MS = 150 |
| 22 | + |
| 23 | +interface MultiWalletWorld { |
| 24 | + context: EdgeContext |
| 25 | + walletIds: string[] |
| 26 | +} |
| 27 | + |
| 28 | +/** |
| 29 | + * Logs in once, pads the account out to at least `count` fakecoin |
| 30 | + * wallets, waits for their cache files to persist, and logs out. |
| 31 | + * The next login on the returned context is a warm start where every |
| 32 | + * wallet's engine startup enters the scheduler queue. The returned |
| 33 | + * ids cover EVERY wallet in the account (the fake user starts with |
| 34 | + * two), so length comparisons against engine-creation counts hold. |
| 35 | + */ |
| 36 | +async function makeMultiWalletWorld(count: number): Promise<MultiWalletWorld> { |
| 37 | + const world = await makeFakeEdgeWorld([fakeUser], quiet) |
| 38 | + const context = await world.makeEdgeContext({ |
| 39 | + ...contextOptions, |
| 40 | + plugins: { fakecoin: true } |
| 41 | + }) |
| 42 | + |
| 43 | + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) |
| 44 | + const walletIds = account.activeWalletIds.filter( |
| 45 | + walletId => account.getWalletInfo(walletId)?.type === 'wallet:fakecoin' |
| 46 | + ) |
| 47 | + if (walletIds.length !== account.activeWalletIds.length) { |
| 48 | + throw new Error('Broken test account: unexpected non-fakecoin wallets') |
| 49 | + } |
| 50 | + while (walletIds.length < count) { |
| 51 | + const wallet = await account.createCurrencyWallet('wallet:fakecoin', { |
| 52 | + fiatCurrencyCode: 'iso:USD', |
| 53 | + name: `Wallet ${walletIds.length}` |
| 54 | + }) |
| 55 | + walletIds.push(wallet.id) |
| 56 | + } |
| 57 | + |
| 58 | + // Let the throttled saver persist each wallet's cache file: |
| 59 | + await snooze(SAVE_WAIT_MS) |
| 60 | + await account.logout() |
| 61 | + |
| 62 | + return { context, walletIds } |
| 63 | +} |
| 64 | + |
| 65 | +/** Returns a sorted copy, so order-insensitive comparisons read cleanly. */ |
| 66 | +function sorted(list: string[]): string[] { |
| 67 | + return [...list].sort((a, b) => a.localeCompare(b)) |
| 68 | +} |
| 69 | + |
| 70 | +/** Polls until `condition` holds, or fails the test after ~5s. */ |
| 71 | +async function pollUntil(condition: () => boolean): Promise<void> { |
| 72 | + for (let i = 0; i < 100; ++i) { |
| 73 | + if (condition()) return |
| 74 | + await snooze(50) |
| 75 | + } |
| 76 | + throw new Error('Timed out waiting for a test condition') |
| 77 | +} |
| 78 | + |
| 79 | +describe('engine scheduler', function () { |
| 80 | + beforeEach(function () { |
| 81 | + fakePluginTestConfig.engineGate = undefined |
| 82 | + fakePluginTestConfig.onEngineCreate = undefined |
| 83 | + walletCacheSaverConfig.throttleMs = 50 |
| 84 | + }) |
| 85 | + |
| 86 | + afterEach(function () { |
| 87 | + fakePluginTestConfig.engineGate = undefined |
| 88 | + fakePluginTestConfig.onEngineCreate = undefined |
| 89 | + walletCacheSaverConfig.throttleMs = 5000 |
| 90 | + engineSchedulerConfig.concurrency = 8 |
| 91 | + }) |
| 92 | + |
| 93 | + it('runs cached startup work at the configured concurrency', async function () { |
| 94 | + this.timeout(15000) |
| 95 | + const { context, walletIds } = await makeMultiWalletWorld(3) |
| 96 | + |
| 97 | + engineSchedulerConfig.concurrency = 1 |
| 98 | + const created: string[] = [] |
| 99 | + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) |
| 100 | + const { gate, release } = createEngineGate() |
| 101 | + fakePluginTestConfig.engineGate = gate |
| 102 | + |
| 103 | + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) |
| 104 | + |
| 105 | + // Every wallet emits from its cache while the queue is stuck: |
| 106 | + await pollUntil(() => |
| 107 | + walletIds.every(walletId => account.currencyWallets[walletId] != null) |
| 108 | + ) |
| 109 | + |
| 110 | + // The gate blocks the slot holder inside `makeCurrencyEngine`, |
| 111 | + // so with one slot, exactly one creation can have begun: |
| 112 | + await snooze(RACE_WAIT_MS) |
| 113 | + expect(created.length).equals(1) |
| 114 | + |
| 115 | + // Releasing the gate drains the queue one wallet at a time, |
| 116 | + // and every engine still starts: |
| 117 | + release() |
| 118 | + await pollUntil(() => created.length === walletIds.length) |
| 119 | + expect(sorted(created)).deep.equals(sorted(walletIds)) |
| 120 | + await account.logout() |
| 121 | + }) |
| 122 | + |
| 123 | + it('waitForCurrencyWallet moves a queued wallet to the front', async function () { |
| 124 | + this.timeout(15000) |
| 125 | + const { context, walletIds } = await makeMultiWalletWorld(4) |
| 126 | + |
| 127 | + engineSchedulerConfig.concurrency = 1 |
| 128 | + const created: string[] = [] |
| 129 | + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) |
| 130 | + const { gate, release } = createEngineGate() |
| 131 | + fakePluginTestConfig.engineGate = gate |
| 132 | + |
| 133 | + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) |
| 134 | + await pollUntil(() => |
| 135 | + walletIds.every(walletId => account.currencyWallets[walletId] != null) |
| 136 | + ) |
| 137 | + await pollUntil(() => created.length === 1) |
| 138 | + |
| 139 | + // Pick the wallet at the back of the line and ask for it: |
| 140 | + const queued = walletIds.filter(walletId => !created.includes(walletId)) |
| 141 | + const target = queued[queued.length - 1] |
| 142 | + await account.waitForCurrencyWallet(target) |
| 143 | + |
| 144 | + // Once the slot frees up, the bumped wallet goes next: |
| 145 | + release() |
| 146 | + await pollUntil(() => created.length === walletIds.length) |
| 147 | + expect(created[1]).equals(target) |
| 148 | + await account.logout() |
| 149 | + }) |
| 150 | + |
| 151 | + it('cold wallets skip the queue entirely', async function () { |
| 152 | + this.timeout(15000) |
| 153 | + const { context, walletIds } = await makeMultiWalletWorld(3) |
| 154 | + |
| 155 | + // Erase the cache files, making the next login a cold start. |
| 156 | + // Let the login's own cache save settle first, so nothing |
| 157 | + // rewrites the files after the deletes: |
| 158 | + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) |
| 159 | + await account.waitForAllWallets() |
| 160 | + await snooze(SAVE_WAIT_MS) |
| 161 | + for (const walletId of walletIds) { |
| 162 | + const wallet = await account.waitForCurrencyWallet(walletId) |
| 163 | + await wallet.localDisklet.delete('walletCache.json') |
| 164 | + } |
| 165 | + await account.logout() |
| 166 | + |
| 167 | + engineSchedulerConfig.concurrency = 1 |
| 168 | + const created: string[] = [] |
| 169 | + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) |
| 170 | + const { gate, release } = createEngineGate() |
| 171 | + fakePluginTestConfig.engineGate = gate |
| 172 | + |
| 173 | + // Cold wallets cannot emit until their startup work runs, |
| 174 | + // so they bypass the queue: every creation begins even though |
| 175 | + // the single queue slot never opens: |
| 176 | + const account2 = await context.loginWithPIN(fakeUser.username, fakeUser.pin) |
| 177 | + await pollUntil(() => created.length === walletIds.length) |
| 178 | + |
| 179 | + release() |
| 180 | + await account2.waitForAllWallets() |
| 181 | + await account2.logout() |
| 182 | + }) |
| 183 | + |
| 184 | + it('a wallet deleted while queued gives up its place in line', async function () { |
| 185 | + this.timeout(15000) |
| 186 | + const { context, walletIds } = await makeMultiWalletWorld(3) |
| 187 | + |
| 188 | + engineSchedulerConfig.concurrency = 1 |
| 189 | + const created: string[] = [] |
| 190 | + fakePluginTestConfig.onEngineCreate = walletId => created.push(walletId) |
| 191 | + const { gate, release } = createEngineGate() |
| 192 | + fakePluginTestConfig.engineGate = gate |
| 193 | + |
| 194 | + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) |
| 195 | + await pollUntil(() => |
| 196 | + walletIds.every(walletId => account.currencyWallets[walletId] != null) |
| 197 | + ) |
| 198 | + await pollUntil(() => created.length === 1) |
| 199 | + |
| 200 | + // Delete a wallet that is still waiting in the queue: |
| 201 | + const queued = walletIds.filter(walletId => !created.includes(walletId)) |
| 202 | + const deleted = queued[0] |
| 203 | + await account.changeWalletStates({ [deleted]: { deleted: true } }) |
| 204 | + |
| 205 | + // The queue keeps moving: the deleted wallet never creates an |
| 206 | + // engine, and the remaining wallets still get theirs: |
| 207 | + release() |
| 208 | + const survivors = walletIds.filter(walletId => walletId !== deleted) |
| 209 | + await pollUntil(() => created.length === survivors.length) |
| 210 | + expect(sorted(created)).deep.equals(sorted(survivors)) |
| 211 | + await snooze(RACE_WAIT_MS) |
| 212 | + expect(created.includes(deleted)).equals(false) |
| 213 | + await account.logout() |
| 214 | + }) |
| 215 | + |
| 216 | + it('unchanged balances keep the same balanceMap object', async function () { |
| 217 | + this.timeout(15000) |
| 218 | + const { context, walletIds } = await makeMultiWalletWorld(1) |
| 219 | + |
| 220 | + const account = await context.loginWithPIN(fakeUser.username, fakeUser.pin) |
| 221 | + const wallet = await account.waitForCurrencyWallet(walletIds[0]) |
| 222 | + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) |
| 223 | + await pollUntil(() => wallet.balanceMap.get(null) === '20') |
| 224 | + |
| 225 | + // Re-reporting the same balance must not produce a new map: |
| 226 | + const before = wallet.balanceMap |
| 227 | + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 20 }) |
| 228 | + await snooze(RACE_WAIT_MS) |
| 229 | + expect(wallet.balanceMap).equals(before) |
| 230 | + |
| 231 | + // A real change still lands: |
| 232 | + await account.currencyConfig.fakecoin.changeUserSettings({ balance: 21 }) |
| 233 | + await pollUntil(() => wallet.balanceMap.get(null) === '21') |
| 234 | + expect(wallet.balanceMap).not.equals(before) |
| 235 | + await account.logout() |
| 236 | + }) |
| 237 | +}) |
0 commit comments