Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .github/workflows/frontend-e2e.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Frontend E2E

on:
pull_request:
paths:
- 'frontend/**'
- '.github/workflows/frontend-e2e.yml'
push:
branches:
- main
- master
paths:
- 'frontend/**'
- '.github/workflows/frontend-e2e.yml'

jobs:
playwright:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Install dependencies
run: npm ci

- name: Install Playwright browsers
run: npx playwright install --with-deps

- name: Run Playwright tests
run: npm run test:e2e

- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 7
176 changes: 176 additions & 0 deletions frontend/e2e/fixtures/web3.fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { test as base, expect, type BrowserContext } from '@playwright/test';

const STELLAR_TESTNET_PASSPHRASE = 'Test SDF Network ; September 2015';
const MOCK_STELLAR_ADDRESS = 'GCMOCKWALLETADDRESS000000000000000000000000000000000000000000000';
const MOCK_ETHEREUM_ADDRESS = '0x1111111111111111111111111111111111111111';

type Web3Mocks = {
stellarAddress: string;
ethereumAddress: string;
signedTransactionXdr: string;
installWalletMocks: () => Promise<void>;
installWebSocketMock: () => Promise<void>;
};

export const test = base.extend<Web3Mocks>({
stellarAddress: async ({}, use) => {
await use(MOCK_STELLAR_ADDRESS);
},
ethereumAddress: async ({}, use) => {
await use(MOCK_ETHEREUM_ADDRESS);
},
signedTransactionXdr: async ({}, use) => {
await use('AAAAAgAAAAA-web3-student-lab-signed-xdr');
},
installWalletMocks: async ({ context, stellarAddress, ethereumAddress, signedTransactionXdr }, use) => {
await installWalletMocks(context, {
stellarAddress,
ethereumAddress,
signedTransactionXdr,
});
await use(async () => undefined);
},
installWebSocketMock: async ({ context }, use) => {
await installWebSocketMock(context);
await use(async () => undefined);
},
});

export { expect };

async function installWalletMocks(
context: BrowserContext,
options: {
stellarAddress: string;
ethereumAddress: string;
signedTransactionXdr: string;
}
) {
await context.addInitScript(
({ stellarAddress, ethereumAddress, signedTransactionXdr, networkPassphrase }) => {
const freighterApi = {
isConnected: async () => ({ isConnected: true }),
requestAccess: async () => ({ address: stellarAddress }),
getAddress: async () => ({ address: stellarAddress }),
getNetwork: async () => ({ network: 'TESTNET', networkPassphrase }),
getNetworkDetails: async () => ({
network: 'TESTNET',
networkUrl: 'https://horizon-testnet.stellar.org',
networkPassphrase,
sorobanRpcUrl: 'https://soroban-testnet.stellar.org',
}),
signTransaction: async (xdr: string) => ({
signedTxXdr: `${signedTransactionXdr}:${xdr.length}`,
signerAddress: stellarAddress,
}),
};

Object.defineProperty(window, 'freighterApi', {
configurable: true,
value: freighterApi,
});
Object.defineProperty(window, 'freighter', {
configurable: true,
value: freighterApi,
});
Object.defineProperty(window, 'stellar', {
configurable: true,
value: { freighter: freighterApi },
});

Object.defineProperty(window, 'ethereum', {
configurable: true,
value: {
isMetaMask: true,
selectedAddress: ethereumAddress,
request: async ({ method }: { method: string; params?: unknown[] }) => {
if (method === 'eth_requestAccounts' || method === 'eth_accounts') {
return [ethereumAddress];
}
if (method === 'personal_sign') {
return '0xmocked_personal_signature';
}
if (method === 'eth_chainId') {
return '0xaa36a7';
}
return null;
},
on: () => undefined,
removeListener: () => undefined,
},
});

Object.defineProperty(window, 'albedo', {
configurable: true,
value: {
publicKey: async () => ({ pubkey: stellarAddress }),
tx: async ({ xdr }: { xdr: string; network: string }) => ({
signed_envelope_xdr: `${signedTransactionXdr}:albedo:${xdr.length}`,
}),
},
});
},
{
stellarAddress: options.stellarAddress,
ethereumAddress: options.ethereumAddress,
signedTransactionXdr: options.signedTransactionXdr,
networkPassphrase: STELLAR_TESTNET_PASSPHRASE,
}
);
}

async function installWebSocketMock(context: BrowserContext) {
await context.addInitScript(() => {
class MockWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;

readonly url: string;
readonly protocol = '';
readonly extensions = '';
binaryType: BinaryType = 'blob';
bufferedAmount = 0;
readyState = MockWebSocket.CONNECTING;
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;

constructor(url: string) {
super();
this.url = url;
window.setTimeout(() => {
this.readyState = MockWebSocket.OPEN;
const event = new Event('open');
this.dispatchEvent(event);
this.onopen?.(event);
}, 0);
}

send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
const payload = typeof data === 'string' ? data : '[binary]';
window.setTimeout(() => {
const event = new MessageEvent('message', {
data: JSON.stringify({ type: 'ack', payload }),
});
this.dispatchEvent(event);
this.onmessage?.(event);
}, 0);
}

close(code = 1000, reason = 'mock closed') {
this.readyState = MockWebSocket.CLOSED;
const event = new CloseEvent('close', { code, reason, wasClean: true });
this.dispatchEvent(event);
this.onclose?.(event);
}
}

Object.defineProperty(window, 'WebSocket', {
configurable: true,
value: MockWebSocket,
});
});
}
17 changes: 17 additions & 0 deletions frontend/e2e/tests/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { expect, test } from '../fixtures/web3.fixture';

test.describe('critical learning journeys', () => {
test('opens the simulator and playground with mocked realtime transport', async ({
page,
installWebSocketMock,
}) => {
await installWebSocketMock();

await page.goto('/simulator', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: /Network Simulator/i })).toBeVisible();

await page.goto('/playground', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: /Soroban Playground/i })).toBeVisible();
await expect(page.getByText(/Experimental Smart Contract Runtime/i)).toBeVisible();
});
});
56 changes: 56 additions & 0 deletions frontend/e2e/tests/wallet.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { expect, test } from '../fixtures/web3.fixture';

test.describe('wallet onboarding', () => {
test('installs mocked Stellar and Ethereum wallet providers', async ({
page,
installWalletMocks,
stellarAddress,
ethereumAddress,
}) => {
await installWalletMocks();
await page.goto('/auth/login', { waitUntil: 'domcontentloaded' });

const walletState = await page.evaluate(async () => {
const freighter = (window as any).freighterApi;
const ethereum = (window as any).ethereum;

return {
stellarAddress: freighter ? (await freighter.requestAccess?.())?.address : null,
ethereumAddress: ethereum ? (await ethereum.request({ method: 'eth_requestAccounts' }))?.[0] : null,
};
});

expect(walletState.stellarAddress).toBe(stellarAddress);
expect(walletState.ethereumAddress).toBe(ethereumAddress);
await expect(page.getByRole('button', { name: /Freighter/ })).toContainText('Ready to connect');
});

test('restores wallet state into the transaction state chart', async ({
page,
stellarAddress,
}) => {
await page.addInitScript(
({ address }) => {
window.localStorage.setItem(
'stellar_wallet',
JSON.stringify({ wallet: 'Freighter', pk: address })
);
},
{ address: stellarAddress }
);

await page.goto('/devtools/wallet', { waitUntil: 'domcontentloaded' });

await expect(page.getByText(`CONNECTED — Freighter`)).toBeVisible();
await expect(page.getByText(stellarAddress).first()).toBeVisible();
await expect(page.getByLabel('Web3 transaction lifecycle state chart')).toContainText('connected');
});

test('keeps unavailable Freighter detection controlled', async ({ page }) => {
await page.goto('/auth/login', { waitUntil: 'domcontentloaded' });

await expect(page.getByRole('button', { name: /Freighter/ })).toContainText(
'Click to detect extension'
);
});
});
20 changes: 4 additions & 16 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"zustand": "^5.0.12"
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
Expand Down
Loading
Loading