Files
archy/neode-ui/src/components/__tests__/ReceiveBitcoinModal.i18n.test.ts
T
6fe9c5f81b fix(ui): escape the literal @ in the Minibits address label
Root cause of "click Receive, click Ecash, the modal disappears" (in
both the browser and the Android companion's WebView, since both host
the same neode-ui bundle): vue-i18n treats a bare @ as the start of
"linked message" syntax. receiveBitcoin.lnAddressLabel ("Your
@minibits.cash address:") isn't valid linked-message syntax, so
*compiling* that message throws a SyntaxError the instant it's first
rendered — i.e. the moment wallet.ecash-lnaddress resolves and the
address section becomes visible. The uncaught render-function error
blanks the whole teleported modal, which is indistinguishable from it
just closing.

Confirmed with a real (non-mocked) Vue app + real vue-i18n compiler in
a headless Chromium — a Vitest run with `t` mocked to a no-op, which is
how the existing component test suite covers this file, cannot catch a
bad message string at all. Fixed by escaping the @ as {'@'} — the same
pattern the codebase already uses for settings.domainNamePlaceholder
("user{'@'}example.com"). Added a regression test using the real
vue-i18n instance instead of the mocked one; verified it fails on the
old string and passes on the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EawZPP9iidXj6Tvg3EpG3a
2026-09-08 21:16:57 -04:00

67 lines
2.8 KiB
TypeScript

// Real vue-i18n instance (unlike ReceiveBitcoinModal.test.ts, which mocks
// `t` to a no-op and so cannot catch a bad message string). Operator report
// (2026-09-08): clicking the Ecash tab closed the whole Receive modal, in
// both the browser and the Android companion's WebView. Root cause: vue-i18n
// treats a bare `@` as the start of "linked message" syntax — `en.json`'s
// `receiveBitcoin.lnAddressLabel` ("Your @minibits.cash address:") isn't
// valid linked-message syntax, so *compiling* that message throws a
// SyntaxError the instant it's first rendered (i.e. the moment the address
// loads), and the uncaught render-function error blanks the whole teleported
// modal. Fixed by escaping it as `{'@'}` (the same pattern already used for
// `settings.domainNamePlaceholder`). This test uses the real compiler so a
// future bad interpolation string in this component fails fast in `npm test`
// instead of only in a live browser.
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client'
import i18n from '@/i18n'
vi.mock('@/api/rpc-client', () => ({
rpcClient: { call: vi.fn() },
}))
vi.mock('@/composables/useLightningRequired', () => ({
useLightningRequired: () => ({
requireLightningReady: vi.fn().mockResolvedValue(true),
handleLightningFailure: vi.fn().mockReturnValue(false),
}),
}))
describe('ReceiveBitcoinModal — ecash tab with the real vue-i18n compiler', () => {
it('renders the Minibits address label without an uncaught render error', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }: { method: string }) => {
if (method === 'wallet.ecash-lnaddress') {
return { address: 'someone@minibits.cash' } as never
}
return { claimed_count: 0, received_sats: 0, failed_count: 0 } as never
})
const wrapper = mount(ReceiveBitcoinModal, {
props: { show: true },
attachTo: document.body,
global: { plugins: [i18n] },
})
let captured: unknown = null
wrapper.vm.$.appContext.app.config.errorHandler = (err) => { captured = err }
await flushPromises()
const ecashTab = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.toLowerCase().includes('ecash'),
)
expect(ecashTab).toBeTruthy()
ecashTab!.dispatchEvent(new Event('click', { bubbles: true }))
await flushPromises()
await flushPromises()
expect(captured).toBeNull()
expect(wrapper.emitted('close')).toBeFalsy()
const dialog = document.body.querySelector('[role="dialog"]')
expect(dialog).toBeTruthy()
expect(dialog?.textContent).toContain('minibits.cash')
expect(dialog?.textContent).toContain('someone@minibits.cash')
wrapper.unmount()
})
})