|
| 1 | +import { expect } from '@playwright/test' |
| 2 | +import type { Page } from '@playwright/test' |
| 3 | + |
| 4 | +import { setPrimaryName } from '@ensdomains/ensjs/wallet' |
| 5 | + |
| 6 | +import { test } from '../../../playwright' |
| 7 | +import { createAccounts } from '../../../playwright/fixtures/accounts' |
| 8 | +import { |
| 9 | + waitForTransaction, |
| 10 | + walletClient, |
| 11 | +} from '../../../playwright/fixtures/contracts/utils/addTestContracts' |
| 12 | +import type { Login } from '../../../playwright/fixtures/login' |
| 13 | + |
| 14 | +// Constants |
| 15 | +const INVALID_RESOLVER = '0x0000000000000000000000000000000000000001' |
| 16 | +const UNIVERSAL_RESOLVER_RESOLVE_SELECTOR = '0x82ad56cb' // resolve(bytes,bytes) |
| 17 | +const NAMES_LIST_TIMEOUT = 15000 |
| 18 | +const RETRY_TIMEOUT = 45000 |
| 19 | + |
| 20 | +// Helper to get user address consistently |
| 21 | +const getUserAddress = () => createAccounts().getAddress('user') as `0x${string}` |
| 22 | + |
| 23 | +// Helper to set primary name and wait for transaction |
| 24 | +async function setPrimaryNameAndWait(name: string, userAddress: `0x${string}`) { |
| 25 | + const tx = await setPrimaryName(walletClient, { |
| 26 | + name, |
| 27 | + account: userAddress, |
| 28 | + }) |
| 29 | + await waitForTransaction(tx) |
| 30 | +} |
| 31 | + |
| 32 | +// Helper to create a name owned by user |
| 33 | +async function createUserName(makeName: any, label: string) { |
| 34 | + return makeName({ |
| 35 | + label, |
| 36 | + type: 'legacy', |
| 37 | + owner: 'user', |
| 38 | + manager: 'user', |
| 39 | + addr: 'user', |
| 40 | + }) |
| 41 | +} |
| 42 | + |
| 43 | +// Helper to set an invalid resolver that triggers ContractFunctionExecutionError |
| 44 | +async function setInvalidResolver( |
| 45 | + page: Page, |
| 46 | + name: string, |
| 47 | + login: InstanceType<typeof Login>, |
| 48 | + makePageObject: any, |
| 49 | +) { |
| 50 | + const morePage = makePageObject('MorePage') |
| 51 | + const transactionModal = makePageObject('TransactionModal') |
| 52 | + |
| 53 | + await morePage.goto(name) |
| 54 | + await login.connect() |
| 55 | + |
| 56 | + await morePage.editResolverButton.click() |
| 57 | + await page.getByTestId('custom-resolver-radio').check() |
| 58 | + await page.getByTestId('dogfood').fill(INVALID_RESOLVER) |
| 59 | + await page.getByTestId('update-button').click() |
| 60 | + await transactionModal.autoComplete() |
| 61 | +} |
| 62 | + |
| 63 | +// Helper to assert address page loaded gracefully (doesn't crash) |
| 64 | +async function assertAddressPageLoaded(page: Page) { |
| 65 | + await page.waitForLoadState('networkidle') |
| 66 | + |
| 67 | + const hasProfile = await page |
| 68 | + .getByTestId('profile-snippet') |
| 69 | + .isVisible() |
| 70 | + .catch(() => false) |
| 71 | + const hasNoProfile = await page |
| 72 | + .getByTestId('no-profile-snippet') |
| 73 | + .isVisible() |
| 74 | + .catch(() => false) |
| 75 | + |
| 76 | + expect(hasProfile || hasNoProfile).toBeTruthy() |
| 77 | +} |
| 78 | + |
| 79 | +// Helper to intercept and fail RPC calls to test retry logic |
| 80 | +async function setupRPCInterception(page: Page, maxFailures: number): Promise<() => number> { |
| 81 | + let requestCount = 0 |
| 82 | + |
| 83 | + await page.route('**/*', async (route) => { |
| 84 | + const request = route.request() |
| 85 | + const url = request.url() |
| 86 | + |
| 87 | + if (url.includes(':8545') || url.includes('localhost:8545')) { |
| 88 | + try { |
| 89 | + const postData = request.postDataJSON() |
| 90 | + |
| 91 | + if (postData?.method === 'eth_call') { |
| 92 | + const data = postData?.params?.[0]?.data |
| 93 | + |
| 94 | + if (data && data.startsWith(UNIVERSAL_RESOLVER_RESOLVE_SELECTOR)) { |
| 95 | + requestCount += 1 |
| 96 | + |
| 97 | + if (requestCount <= maxFailures) { |
| 98 | + // Simulate network error (not ContractFunctionExecutionError) |
| 99 | + await route.abort('failed') |
| 100 | + return |
| 101 | + } |
| 102 | + } |
| 103 | + } |
| 104 | + } catch (e) { |
| 105 | + // If we can't parse the request, continue normally |
| 106 | + } |
| 107 | + } |
| 108 | + |
| 109 | + await route.continue() |
| 110 | + }) |
| 111 | + |
| 112 | + return () => requestCount |
| 113 | +} |
| 114 | + |
| 115 | +test.describe('Address page error handling', () => { |
| 116 | + // Tests for usePrimaryName.ts fix that catches ContractFunctionExecutionError |
| 117 | + // and returns null (graceful failure) while still throwing other errors for retry |
| 118 | + |
| 119 | + test('should load address page without crashing when getName throws ContractFunctionExecutionError', async ({ |
| 120 | + page, |
| 121 | + login, |
| 122 | + makeName, |
| 123 | + makePageObject, |
| 124 | + }) => { |
| 125 | + test.slow() |
| 126 | + |
| 127 | + // SETUP: Create name with invalid resolver + set as primary name |
| 128 | + const name = await createUserName(makeName, 'error-test-name') |
| 129 | + await setInvalidResolver(page, name, login, makePageObject) |
| 130 | + |
| 131 | + const profilePage = makePageObject('ProfilePage') |
| 132 | + const transactionModal = makePageObject('TransactionModal') |
| 133 | + await profilePage.goto(name) |
| 134 | + await page.getByText('Set as primary name').click() |
| 135 | + await transactionModal.autoComplete() |
| 136 | + |
| 137 | + // ACTION: Navigate to address page (triggers getName with invalid resolver) |
| 138 | + const userAddress = getUserAddress() |
| 139 | + await page.goto(`/${userAddress}`) |
| 140 | + |
| 141 | + // ASSERT: Page loads gracefully without crashing |
| 142 | + await assertAddressPageLoaded(page) |
| 143 | + |
| 144 | + // ASSERT: Other functionality still works (names list loads) |
| 145 | + await expect(page.getByTestId('names-list')).toBeVisible({ timeout: NAMES_LIST_TIMEOUT }) |
| 146 | + }) |
| 147 | + |
| 148 | + test('should retry on non-ContractFunctionExecutionError and not return null early', async ({ |
| 149 | + page, |
| 150 | + login, |
| 151 | + makeName, |
| 152 | + }) => { |
| 153 | + test.slow() |
| 154 | + |
| 155 | + // SETUP: Create valid name and set as primary name |
| 156 | + const name = await createUserName(makeName, 'retry-test-name') |
| 157 | + const userAddress = getUserAddress() |
| 158 | + await setPrimaryNameAndWait(name, userAddress) |
| 159 | + |
| 160 | + // SETUP: Intercept RPC calls to simulate network failures |
| 161 | + const maxFailures = 2 |
| 162 | + const getRequestCount = await setupRPCInterception(page, maxFailures) |
| 163 | + |
| 164 | + // ACTION: Navigate to address page (triggers getName with simulated failures) |
| 165 | + await page.goto(`/${userAddress}`) |
| 166 | + await login.connect() |
| 167 | + |
| 168 | + // ASSERT: Page eventually loads after retries |
| 169 | + await page.waitForLoadState('networkidle') |
| 170 | + await expect(page.getByTestId('profile-snippet')).toBeVisible({ timeout: RETRY_TIMEOUT }) |
| 171 | + await expect(page.getByTestId('profile-snippet-name')).toContainText(name) |
| 172 | + |
| 173 | + // ASSERT: Retries occurred (proves non-ContractFunctionExecutionError thrown, not caught) |
| 174 | + const requestCount = getRequestCount() |
| 175 | + expect(requestCount).toBeGreaterThan(maxFailures) |
| 176 | + expect(requestCount).toBeGreaterThanOrEqual(3) // At least 2 failures + 1 success |
| 177 | + }) |
| 178 | + |
| 179 | + test.afterEach(async () => { |
| 180 | + // Clean up: reset primary name after each test |
| 181 | + await setPrimaryName(walletClient, { |
| 182 | + name: '', |
| 183 | + account: getUserAddress(), |
| 184 | + }).catch(() => { |
| 185 | + // Ignore errors during cleanup |
| 186 | + }) |
| 187 | + }) |
| 188 | +}) |
0 commit comments