From 18d3d4ee1763df077adaf8cb80a8e091d0133df0 Mon Sep 17 00:00:00 2001 From: Chimdiogo Date: Wed, 29 Jul 2026 16:44:30 +0100 Subject: [PATCH] feat: Add deterministic Freighter browser mock for E2E testing --- src/lib/wallet/freighter.js | 37 ++++++++ tests/e2e/fixtures/freighter-mock.js | 104 ++++++++++++++++++++++ tests/e2e/freighter.spec.js | 123 +++++++++++++++++++++++++++ 3 files changed, 264 insertions(+) create mode 100644 tests/e2e/fixtures/freighter-mock.js create mode 100644 tests/e2e/freighter.spec.js diff --git a/src/lib/wallet/freighter.js b/src/lib/wallet/freighter.js index 5f716ef6..ca3b6e7e 100644 --- a/src/lib/wallet/freighter.js +++ b/src/lib/wallet/freighter.js @@ -86,3 +86,40 @@ export async function getFreighterNetwork() { return null } } + +/** + * Listen for account changes from Freighter. + * Not all versions of Freighter natively broadcast this, but we handle it if fired. + */ +export function onFreighterAccountChange(callback) { + if (typeof window !== 'undefined') { + const handler = (e) => callback(e.detail); + window.addEventListener('freighterAccountChange', handler); + return () => window.removeEventListener('freighterAccountChange', handler); + } + return () => {}; +} + +/** + * Listen for network changes from Freighter. + */ +export function onFreighterNetworkChange(callback) { + if (typeof window !== 'undefined') { + const handler = (e) => callback(e.detail); + window.addEventListener('freighterNetworkChange', handler); + return () => window.removeEventListener('freighterNetworkChange', handler); + } + return () => {}; +} + +/** + * Listen for Freighter lock events. + */ +export function onFreighterLock(callback) { + if (typeof window !== 'undefined') { + const handler = () => callback(); + window.addEventListener('freighterLock', handler); + return () => window.removeEventListener('freighterLock', handler); + } + return () => {}; +} diff --git a/tests/e2e/fixtures/freighter-mock.js b/tests/e2e/fixtures/freighter-mock.js new file mode 100644 index 00000000..3ae938a2 --- /dev/null +++ b/tests/e2e/fixtures/freighter-mock.js @@ -0,0 +1,104 @@ +/** + * Deterministic Freighter browser mock for E2E tests. + * This script is injected into the page via Playwright's addInitScript. + */ + +window.__MOCK_FREIGHTER_STATE__ = { + isConnected: true, + isLocked: false, + publicKey: 'GA1234567890MOCKFREIGHTERPUBLICKEY1234567890', + network: 'TESTNET', + networkUrl: 'https://horizon-testnet.stellar.org', + rejectNextConnect: false, + rejectNextSign: false, +}; + +window.mockFreighter = { + setState(newState) { + window.__MOCK_FREIGHTER_STATE__ = { + ...window.__MOCK_FREIGHTER_STATE__, + ...newState, + }; + }, + simulateAccountChange(newPublicKey) { + this.setState({ publicKey: newPublicKey }); + window.dispatchEvent(new CustomEvent('freighterAccountChange', { detail: newPublicKey })); + }, + simulateNetworkChange(newNetwork, newNetworkUrl = 'https://horizon-mock.stellar.org') { + this.setState({ network: newNetwork, networkUrl: newNetworkUrl }); + window.dispatchEvent(new CustomEvent('freighterNetworkChange', { detail: newNetwork })); + }, + simulateLock() { + this.setState({ isLocked: true }); + window.dispatchEvent(new CustomEvent('freighterLock')); + }, + rejectNextConnect() { + this.setState({ rejectNextConnect: true }); + }, + rejectNextSign() { + this.setState({ rejectNextSign: true }); + } +}; + +window.freighterApi = { + isConnected: async () => { + return { isConnected: window.__MOCK_FREIGHTER_STATE__.isConnected }; + }, + + isAllowed: async () => { + return { isAllowed: !window.__MOCK_FREIGHTER_STATE__.isLocked }; + }, + + setAllowed: async () => { + return { isAllowed: true }; + }, + + requestAccess: async () => { + const state = window.__MOCK_FREIGHTER_STATE__; + if (state.isLocked) { + return { error: 'Freighter is locked. Please unlock it.' }; + } + if (state.rejectNextConnect) { + state.rejectNextConnect = false; + return { error: 'User declined access.' }; + } + return { address: state.publicKey }; + }, + + getAddress: async () => { + const state = window.__MOCK_FREIGHTER_STATE__; + if (state.isLocked) { + return { error: 'Freighter is locked. Please unlock it.' }; + } + return { address: state.publicKey }; + }, + + getNetwork: async () => { + const state = window.__MOCK_FREIGHTER_STATE__; + return { + network: state.network, + networkUrl: state.networkUrl + }; + }, + + signTransaction: async (tx, opts) => { + const state = window.__MOCK_FREIGHTER_STATE__; + if (state.isLocked) { + return { error: 'Freighter is locked. Please unlock it.' }; + } + if (state.rejectNextSign) { + state.rejectNextSign = false; + return { error: 'User declined transaction signing.' }; + } + return { + signedTxXdr: tx + '_mock_signed_by_freighter' + }; + }, + + getUserInfo: async () => { + const state = window.__MOCK_FREIGHTER_STATE__; + return { + publicKey: state.publicKey, + }; + } +}; diff --git a/tests/e2e/freighter.spec.js b/tests/e2e/freighter.spec.js new file mode 100644 index 00000000..a06883b7 --- /dev/null +++ b/tests/e2e/freighter.spec.js @@ -0,0 +1,123 @@ +import { test, expect } from '@playwright/test'; +import * as path from 'path'; + +test.describe('Freighter Wallet Flows', () => { + const freighterMockPath = path.resolve(__dirname, 'fixtures', 'freighter-mock.js'); + + test.beforeEach(async ({ page }) => { + // Inject the mock script before any page loads + await page.addInitScript({ path: freighterMockPath }); + + // Mock Horizon API calls to avoid hitting real network + await page.route('**/accounts/*', async route => { + await route.fulfill({ + status: 200, + json: { + account_id: 'GA1234567890MOCKFREIGHTERPUBLICKEY1234567890', + balances: [{ asset_type: 'native', balance: '10000.0000000' }], + sequence: '1' + } + }); + }); + + await page.route('**/transactions*', async route => { + await route.fulfill({ status: 200, json: { _embedded: { records: [] } } }); + }); + + await page.route('**/operations*', async route => { + await route.fulfill({ status: 200, json: { _embedded: { records: [] } } }); + }); + + // Mock sign result submission if needed + await page.route('**/transactions', async route => { + if (route.request().method() === 'POST') { + await route.fulfill({ + status: 200, + json: { hash: 'mock-tx-hash-123', ledger: 123456 } + }); + } else { + await route.continue(); + } + }); + }); + + test('primary flow: connect successfully', async ({ page }) => { + await page.goto('/'); + + // Connect Wallet button usually appears or we go to a specific view + // The previous tests indicate clicking 'Connect' or selecting Freighter + + // In WalletConnect, it lists wallets. Click Freighter + const connectFreighterBtn = page.getByRole('button', { name: /Freighter/i }); + await expect(connectFreighterBtn).toBeVisible(); + await connectFreighterBtn.click(); + + // Wait for the success state showing the mock public key + await expect(page.getByText('GA1234567890MOCKFREIGHTERPUBLICKEY1234567890')).toBeVisible({ timeout: 10000 }); + await expect(page.getByText(/Freighter Connected/i)).toBeVisible(); + }); + + test('failure case: user rejects connection', async ({ page }) => { + await page.goto('/'); + + // Set the mock to reject next connection + await page.evaluate(() => { + window.mockFreighter.rejectNextConnect(); + }); + + const connectFreighterBtn = page.getByRole('button', { name: /Freighter/i }); + await connectFreighterBtn.click(); + + // The error should be displayed on screen + await expect(page.getByText('User declined access.')).toBeVisible(); + }); + + test('failure case: freighter is locked', async ({ page }) => { + await page.goto('/'); + + // Lock the mock + await page.evaluate(() => { + window.mockFreighter.simulateLock(); + }); + + const connectFreighterBtn = page.getByRole('button', { name: /Freighter/i }); + await connectFreighterBtn.click(); + + await expect(page.getByText(/Freighter is locked/i)).toBeVisible(); + }); + + test('boundary case: network change', async ({ page }) => { + await page.goto('/'); + + const connectFreighterBtn = page.getByRole('button', { name: /Freighter/i }); + await connectFreighterBtn.click(); + await expect(page.getByText('GA1234567890MOCKFREIGHTERPUBLICKEY1234567890')).toBeVisible(); + + // Trigger network change + await page.evaluate(() => { + window.mockFreighter.simulateNetworkChange('PUBLIC'); + }); + + // In a real app we might expect the UI to show PUBLIC. + // Since we aren't enforcing full app reaction without modifying more code, + // we just ensure the mock can trigger it without breaking. + const network = await page.evaluate(() => window.freighterApi.getNetwork()); + expect(network.network).toBe('PUBLIC'); + }); + + test('boundary case: account change', async ({ page }) => { + await page.goto('/'); + + const connectFreighterBtn = page.getByRole('button', { name: /Freighter/i }); + await connectFreighterBtn.click(); + + // Trigger account change + await page.evaluate(() => { + window.mockFreighter.simulateAccountChange('GBNEWACCOUNTMOCKKEY9999999999999999999999'); + }); + + const address = await page.evaluate(() => window.freighterApi.getAddress()); + expect(address.address).toBe('GBNEWACCOUNTMOCKKEY9999999999999999999999'); + }); + +});