Skip to content

Commit a8fde87

Browse files
authored
Merge pull request #1315 from lycantho/dx/coverage-comment-context-tests
chore(dx): add coverage PR comment + unit tests for wallet/theme/pref…
2 parents 3355704 + 0c2af4b commit a8fde87

12 files changed

Lines changed: 616 additions & 39 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
name: Frontend Coverage Comment
2+
3+
# Runs in the base repo's context (not the PR head's), so it gets a
4+
# write-capable GITHUB_TOKEN even for PRs from forks. It never checks out
5+
# or executes any code from the PR — it only downloads the small JSON
6+
# artifact produced by the `build` job in frontend.yml and posts/updates a
7+
# PR comment from it.
8+
on:
9+
workflow_run:
10+
workflows: ["Frontend CI"]
11+
types: [completed]
12+
13+
permissions:
14+
contents: read
15+
pull-requests: write
16+
17+
jobs:
18+
comment:
19+
name: Post coverage comment
20+
runs-on: ubuntu-latest
21+
if: github.event.workflow_run.event == 'pull_request'
22+
23+
steps:
24+
- name: Download coverage report artifact
25+
id: download
26+
continue-on-error: true
27+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
28+
with:
29+
name: frontend-coverage-report
30+
run-id: ${{ github.event.workflow_run.id }}
31+
github-token: ${{ secrets.GITHUB_TOKEN }}
32+
33+
- name: Post or update comment
34+
if: steps.download.outcome == 'success'
35+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
36+
with:
37+
script: |
38+
const fs = require('fs');
39+
const report = JSON.parse(fs.readFileSync('frontend-coverage-report.json', 'utf8'));
40+
const marker = '<!-- frontend-coverage-report -->';
41+
42+
const row = (label, pct, target) => {
43+
const status = pct >= target ? '✅' : '⚠️';
44+
return `| ${label} | ${pct.toFixed(2)}% | ${target}% | ${status} |`;
45+
};
46+
47+
const body = [
48+
marker,
49+
'### Frontend unit test coverage',
50+
'',
51+
`Commit \`${report.head_sha.slice(0, 7)}\``,
52+
'',
53+
'| Metric | Coverage | Target | |',
54+
'|---|---|---|---|',
55+
row('Statements', report.statements, report.targets.statements),
56+
row('Branches', report.branches, report.targets.branches),
57+
row('Functions', report.functions, report.targets.functions),
58+
row('Lines', report.lines, report.targets.lines),
59+
'',
60+
'_Targets are informational — not currently enforced as a CI gate._',
61+
].join('\n');
62+
63+
const { owner, repo } = context.repo;
64+
const issue_number = report.pr_number;
65+
66+
const comments = await github.rest.issues.listComments({ owner, repo, issue_number });
67+
const existing = comments.data.find((c) => c.body.includes(marker));
68+
69+
if (existing) {
70+
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
71+
} else {
72+
await github.rest.issues.createComment({ owner, repo, issue_number, body });
73+
}

.github/workflows/frontend.yml

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,49 @@ jobs:
110110
run: pnpm test:coverage:merge
111111
env:
112112
NODE_OPTIONS: --max-old-space-size=4096
113-
# Fails the pipeline if coverage thresholds (stmt 70%, branch 65%, fn 70%) are not met
113+
# NOTE: coverage.thresholds are not currently configured in vitest.config.ts,
114+
# so this step does not fail the pipeline on low coverage. The target numbers
115+
# below (stmt 70%, branch 65%, fn 70%, lines 70%) are informational only until
116+
# a threshold gate is deliberately reintroduced.
117+
118+
- name: Generate coverage report artifact
119+
id: coverage_report
120+
if: always() && github.event_name == 'pull_request'
121+
run: |
122+
if [ ! -f coverage/coverage-summary.json ]; then
123+
echo "No coverage summary found, skipping report generation."
124+
echo "generated=false" >> "$GITHUB_OUTPUT"
125+
exit 0
126+
fi
127+
node -e "
128+
const fs = require('fs');
129+
const summary = JSON.parse(fs.readFileSync('coverage/coverage-summary.json', 'utf8'));
130+
const total = summary.total;
131+
const report = {
132+
pr_number: ${{ github.event.pull_request.number }},
133+
head_sha: '${{ github.event.pull_request.head.sha }}',
134+
statements: total.statements.pct,
135+
branches: total.branches.pct,
136+
functions: total.functions.pct,
137+
lines: total.lines.pct,
138+
targets: { statements: 70, branches: 65, functions: 70, lines: 70 },
139+
};
140+
fs.writeFileSync('frontend-coverage-report.json', JSON.stringify(report));
141+
"
142+
echo "generated=true" >> "$GITHUB_OUTPUT"
143+
144+
# PRs from forks get a read-only GITHUB_TOKEN under the `pull_request` event, so
145+
# this job can't post a comment directly. The report is handed off as an artifact
146+
# to coverage-comment.yml, which runs via `workflow_run` in the base repo's
147+
# context (write-capable token) and only ever reads this JSON — it never checks
148+
# out or executes any code from the PR.
149+
- name: Upload coverage report artifact
150+
if: always() && steps.coverage_report.outputs.generated == 'true'
151+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
152+
with:
153+
name: frontend-coverage-report
154+
path: Dechat/dex_with_fiat_frontend/frontend-coverage-report.json
155+
retention-days: 7
114156

115157
e2e:
116158
name: Playwright E2E Tests

Dechat/dex_with_fiat_frontend/src/app/api/initiate-transfer/route.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,13 @@ export async function POST(request: NextRequest) {
6464

6565
const { source, reason, amount, recipient, reference } =
6666
validationResult.data;
67+
// `clientSessionId` isn't part of initiateTransferSchema, so it's read
68+
// from the raw (already-validated-as-an-object) body instead of
69+
// validationResult.data.
70+
const bodyRecord = body as Record<string, unknown>;
6771
const clientSessionId =
68-
typeof body.clientSessionId === 'string'
69-
? body.clientSessionId
72+
typeof bodyRecord.clientSessionId === 'string'
73+
? bodyRecord.clientSessionId
7074
: undefined;
7175

7276
telemetry.addLog(span.spanId, 'info', 'Request validated', {

Dechat/dex_with_fiat_frontend/src/components/Message.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,6 @@ export default function Message({ message, onActionClick, onRetry, shouldAnimate
6363
// made since it mounted.
6464
const totalRetryAttempts = (message.error?.retryAttempts ?? 0) + retry.attempts;
6565

66-
const handleMessageKeyDown = (e: React.KeyboardEvent) => {
67-
if (e.key === 'r' && hasError && onRetry) {
68-
retry.retryNow();
69-
}
70-
};
71-
7266
// Currency conversion hook for transaction amounts
7367
const amountForConversion = message.metadata?.transactionData?.amountIn
7468
? parseFloat(String(message.metadata.transactionData.amountIn))

Dechat/dex_with_fiat_frontend/src/components/StellarChatInterface.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import SkeletonChat from '@/components/ui/skeleton/SkeletonChat';
44
import SkeletonSidebar from '@/components/ui/skeleton/SkeletonSidebar';
55
import { useState, useCallback, useEffect, useRef } from 'react';
6-
import { useReducedMotion } from 'framer-motion';
76
import {
87
Wallet,
98
LogOut,
@@ -68,7 +67,6 @@ const HEALTH_POLL_INTERVAL_MS = 60_000;
6867

6968
function StellarChatInterfaceContent() {
7069
const { t } = useTranslation();
71-
const prefersReducedMotion = useReducedMotion();
7270
const {
7371
connection,
7472
connect,
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import React from 'react';
2+
import { renderHook, act, waitFor } from '@testing-library/react';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { Networks } from '@stellar/stellar-sdk';
5+
import { StellarWalletProvider, useStellarWallet } from './StellarWalletContext';
6+
7+
const freighter = vi.hoisted(() => ({
8+
isConnected: vi.fn(),
9+
getAddress: vi.fn(),
10+
getNetwork: vi.fn(),
11+
requestAccess: vi.fn(),
12+
signTransaction: vi.fn(),
13+
setAllowed: vi.fn(),
14+
}));
15+
16+
vi.mock('@stellar/freighter-api', () => freighter);
17+
18+
const fetchXlmBalance = vi.hoisted(() => vi.fn());
19+
vi.mock('@/lib/stellarContract', () => ({ fetchXlmBalance }));
20+
21+
function wrapper({ children }: { children: React.ReactNode }) {
22+
return <StellarWalletProvider>{children}</StellarWalletProvider>;
23+
}
24+
25+
const ADDRESS = 'GABCDEF1234567890TESTADDRESS';
26+
const SECOND_ADDRESS = 'GSECONDACCOUNTADDRESS1234567';
27+
28+
describe('StellarWalletContext', () => {
29+
beforeEach(() => {
30+
localStorage.clear();
31+
delete (window as { freighter?: unknown }).freighter;
32+
vi.clearAllMocks();
33+
freighter.isConnected.mockResolvedValue({ isConnected: false });
34+
freighter.getAddress.mockResolvedValue({ address: ADDRESS });
35+
freighter.getNetwork.mockResolvedValue({
36+
network: 'TESTNET',
37+
networkPassphrase: Networks.TESTNET,
38+
});
39+
freighter.requestAccess.mockResolvedValue({ address: ADDRESS });
40+
freighter.signTransaction.mockResolvedValue({ signedTxXdr: 'SIGNED_XDR' });
41+
freighter.setAllowed.mockResolvedValue({ isAllowed: true });
42+
fetchXlmBalance.mockResolvedValue('100.0000000');
43+
});
44+
45+
it('defaults to a disconnected state when Freighter is not installed', async () => {
46+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
47+
48+
await waitFor(() => expect(result.current.isFreighterInstalled).toBe(false));
49+
50+
expect(result.current.connection.isConnected).toBe(false);
51+
expect(result.current.accounts).toEqual([]);
52+
expect(result.current.xlmBalance).toBe('');
53+
expect(result.current.isNetworkMismatch).toBe(false);
54+
});
55+
56+
describe('connect', () => {
57+
it('sets connection, balance, and persists to localStorage on success', async () => {
58+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
59+
60+
await act(async () => {
61+
await result.current.connect();
62+
});
63+
64+
expect(result.current.connection.isConnected).toBe(true);
65+
expect(result.current.connection.address).toBe(ADDRESS);
66+
expect(result.current.xlmBalance).toBe('100.0000000');
67+
expect(localStorage.getItem('stellar_address')).toBe(ADDRESS);
68+
expect(result.current.error).toBeNull();
69+
});
70+
71+
it('sets an error and leaves the connection unset on a network mismatch', async () => {
72+
freighter.getNetwork.mockResolvedValue({
73+
network: 'PUBLIC',
74+
networkPassphrase: Networks.PUBLIC,
75+
});
76+
77+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
78+
79+
await act(async () => {
80+
await result.current.connect();
81+
});
82+
83+
expect(result.current.error).toBe('Please switch Freighter to Testnet');
84+
expect(result.current.connection.isConnected).toBe(false);
85+
});
86+
87+
it('sets an error when Freighter reports a failure', async () => {
88+
freighter.requestAccess.mockResolvedValue({
89+
error: 'User declined access',
90+
});
91+
92+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
93+
94+
await act(async () => {
95+
await result.current.connect();
96+
});
97+
98+
expect(result.current.error).toBe('User declined access');
99+
expect(result.current.isLoading).toBe(false);
100+
});
101+
});
102+
103+
it('disconnect resets connection, accounts, balance, and storage', async () => {
104+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
105+
106+
await act(async () => {
107+
await result.current.connect();
108+
});
109+
expect(result.current.connection.isConnected).toBe(true);
110+
111+
act(() => {
112+
result.current.disconnect();
113+
});
114+
115+
expect(result.current.connection.isConnected).toBe(false);
116+
expect(result.current.accounts).toEqual([]);
117+
expect(result.current.xlmBalance).toBe('');
118+
expect(localStorage.getItem('stellar_address')).toBeNull();
119+
});
120+
121+
describe('selectAccount', () => {
122+
it('switches the active account and persists the new selection', async () => {
123+
window.freighter = {
124+
getAccounts: vi
125+
.fn()
126+
.mockResolvedValue({ accounts: [ADDRESS, SECOND_ADDRESS] }),
127+
};
128+
129+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
130+
131+
await act(async () => {
132+
await result.current.connect();
133+
});
134+
expect(result.current.accounts).toHaveLength(2);
135+
136+
await act(async () => {
137+
await result.current.selectAccount(1);
138+
});
139+
140+
expect(result.current.selectedAccountIndex).toBe(1);
141+
expect(result.current.connection.address).toBe(SECOND_ADDRESS);
142+
expect(localStorage.getItem('stellar_selected_account_index')).toBe('1');
143+
});
144+
145+
it('is a no-op for an out-of-range index', async () => {
146+
window.freighter = {
147+
getAccounts: vi.fn().mockResolvedValue({ accounts: [ADDRESS] }),
148+
};
149+
150+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
151+
152+
await act(async () => {
153+
await result.current.connect();
154+
});
155+
expect(result.current.accounts).toHaveLength(1);
156+
157+
await act(async () => {
158+
await result.current.selectAccount(5);
159+
});
160+
161+
expect(result.current.selectedAccountIndex).toBe(0);
162+
});
163+
});
164+
165+
describe('signTx', () => {
166+
it('returns the signed XDR on success', async () => {
167+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
168+
169+
await act(async () => {
170+
await result.current.connect();
171+
});
172+
173+
const signed = await result.current.signTx('UNSIGNED_XDR');
174+
175+
expect(signed).toBe('SIGNED_XDR');
176+
});
177+
178+
it('throws when Freighter returns an error', async () => {
179+
freighter.signTransaction.mockResolvedValue({ error: 'User rejected' });
180+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
181+
182+
await act(async () => {
183+
await result.current.connect();
184+
});
185+
186+
await expect(result.current.signTx('UNSIGNED_XDR')).rejects.toThrow(
187+
'User rejected',
188+
);
189+
});
190+
});
191+
192+
it('mockConnect sets a TESTNET connection directly', () => {
193+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
194+
195+
act(() => {
196+
result.current.mockConnect(ADDRESS);
197+
});
198+
199+
expect(result.current.connection.isConnected).toBe(true);
200+
expect(result.current.connection.address).toBe(ADDRESS);
201+
expect(result.current.connection.network).toBe('TESTNET');
202+
});
203+
204+
it('clearSessionExpired resets the sessionExpired flag after an expired session is detected', async () => {
205+
localStorage.setItem('stellar_address', ADDRESS);
206+
localStorage.setItem(
207+
'stellar_connection_timestamp',
208+
String(Date.now() - 25 * 60 * 60 * 1000),
209+
);
210+
freighter.isConnected.mockResolvedValue({ isConnected: true });
211+
212+
const { result } = renderHook(() => useStellarWallet(), { wrapper });
213+
214+
await waitFor(() => expect(result.current.sessionExpired).toBe(true));
215+
216+
act(() => {
217+
result.current.clearSessionExpired();
218+
});
219+
220+
expect(result.current.sessionExpired).toBe(false);
221+
});
222+
223+
it('throws a clear error when consumed outside the provider', () => {
224+
expect(() => renderHook(() => useStellarWallet())).toThrow(
225+
'useStellarWallet must be used inside StellarWalletProvider',
226+
);
227+
});
228+
});

0 commit comments

Comments
 (0)