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
3 changes: 3 additions & 0 deletions .github/workflows/e2e-bdd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Type check
run: npm run typecheck

- name: Install Playwright browser
run: npx playwright install --with-deps chromium

Expand Down
55 changes: 36 additions & 19 deletions package-lock.json

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:e2e:report": "cucumber-js --config cucumber.js --format progress-bar --format html:reports/cucumber-report.html",
"test:e2e:full": "cross-env EXPO_PUBLIC_E2E_MODE=true BASE_URL=http://localhost:3000 cucumber-js --config cucumber.js",
"test:e2e:ci": "cross-env EXPO_PUBLIC_E2E_MODE=true BASE_URL=http://localhost:3000 cucumber-js --config cucumber.js --format progress-bar --format html:reports/cucumber-report.html --format json:reports/cucumber-report.json",
"typecheck": "tsc --noEmit",
"test:unit": "node --import tsx --test tests/**/*.test.ts",
"admin:support": "node scripts/admin-support.mjs"
},
Expand All @@ -39,17 +40,16 @@
"react-native-web": "~0.19.13"
},
"devDependencies": {
"@types/react": "~18.3.12",
"typescript": "~5.3.3",
"@cucumber/cucumber": "^12.7.0",
"@types/react": "~18.3.12",
"cross-env": "^7.0.3",
"firebase-admin": "^12.6.0",
"playwright": "^1.52.0",
"tsx": "^4.20.6"
"tsx": "^4.20.6",
"typescript": "~5.3.3"
},
"overrides": {
"tar": ">=7.5.10",
"@xmldom/xmldom": ">=0.8.0"
}
}

2 changes: 1 addition & 1 deletion src/components/MilestoneAnimation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ export default function MilestoneAnimation({
<Text style={styles.progressText}>{Math.round(safeProgress * 100)}% visible</Text>
<Text style={styles.stageText}>Stage {progressStage + 1} of 11</Text>
{theme === 'house' && (
<Text style={styles.houseStepText}>Land -> Foundation -> Walls -> Roof -> Landscaping</Text>
<Text style={styles.houseStepText}>{'Land -> Foundation -> Walls -> Roof -> Landscaping'}</Text>
)}
</View>
)}
Expand Down
9 changes: 5 additions & 4 deletions src/screens/auth/LoginScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { login, resetPassword } from '../../services/authService';
import { getErrorMessage } from '../../utils/errorUtils';

type Props = { navigation: NativeStackNavigationProp<RootStackParamList, 'Login'> };

Expand All @@ -29,8 +30,8 @@ export default function LoginScreen({ navigation }: Props) {
setLoading(true);
try {
await login(email.trim(), password);
} catch (e: any) {
Alert.alert('Login failed', e.message);
} catch (error: unknown) {
Alert.alert('Login failed', getErrorMessage(error, 'Login failed. Please try again.'));
} finally {
setLoading(false);
}
Expand All @@ -45,8 +46,8 @@ export default function LoginScreen({ navigation }: Props) {
try {
await resetPassword(emailToReset);
Alert.alert('Reset link sent', `If an account exists for ${emailToReset}, a reset email has been sent.`);
} catch (e: any) {
Alert.alert('Reset failed', e.message);
} catch (error: unknown) {
Alert.alert('Reset failed', getErrorMessage(error, 'Reset failed. Please try again.'));
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/screens/auth/RegisterScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { register } from '../../services/authService';
import { getErrorMessage } from '../../utils/errorUtils';

type Props = { navigation: NativeStackNavigationProp<RootStackParamList, 'Register'> };

Expand All @@ -33,8 +34,8 @@ export default function RegisterScreen({ navigation }: Props) {
setLoading(true);
try {
await register(email.trim(), password);
} catch (e: any) {
Alert.alert('Registration failed', e.message);
} catch (error: unknown) {
Alert.alert('Registration failed', getErrorMessage(error, 'Registration failed. Please try again.'));
} finally {
setLoading(false);
}
Expand Down
7 changes: 4 additions & 3 deletions src/screens/goals/CreateGoalScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import SidebarNav from '../../components/SidebarNav';
import { formatCurrency, parseNumberInput } from '../../utils/format';
import { captureError, trackEvent } from '../../services/telemetryService';
import { showToast } from '../../services/toastService';
import { getErrorMessage } from '../../utils/errorUtils';

type Nav = NativeStackNavigationProp<RootStackParamList>;

Expand Down Expand Up @@ -113,9 +114,9 @@ export default function CreateGoalScreen() {
didCompleteRef.current = true;
showToast('Goal created successfully.', 'success');
navigation.replace('GoalDetail', { goalId });
} catch (e: any) {
captureError('create_goal_submit', e);
showToast(e?.message ?? 'Failed to create goal.', 'error');
} catch (error: unknown) {
captureError('create_goal_submit', error);
showToast(getErrorMessage(error, 'Failed to create goal.'), 'error');
} finally {
setLoading(false);
}
Expand Down
7 changes: 4 additions & 3 deletions src/screens/goals/DepositScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { isE2EMode } from '../../config/runtime';
import { formatCurrency, parseNumberInput } from '../../utils/format';
import { captureError, trackEvent } from '../../services/telemetryService';
import { showToast } from '../../services/toastService';
import { getErrorMessage } from '../../utils/errorUtils';

type Nav = NativeStackNavigationProp<RootStackParamList>;
type Route = RouteProp<RootStackParamList, 'Deposit'>;
Expand Down Expand Up @@ -153,9 +154,9 @@ export default function DepositScreen() {
showToast('Deposit recorded.', 'success');
navigation.goBack();
}
} catch (e: any) {
captureError('deposit_submit', e);
showToast(e?.message ?? 'Failed to record deposit.', 'error');
} catch (error: unknown) {
captureError('deposit_submit', error);
showToast(getErrorMessage(error, 'Failed to record deposit.'), 'error');
} finally {
setLoading(false);
}
Expand Down
13 changes: 7 additions & 6 deletions src/screens/goals/EditGoalScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import SidebarNav from '../../components/SidebarNav';
import { formatCurrency } from '../../utils/format';
import { captureError, trackEvent } from '../../services/telemetryService';
import { showToast } from '../../services/toastService';
import { getErrorMessage } from '../../utils/errorUtils';

type Props = NativeStackScreenProps<RootStackParamList, 'EditGoal'>;

Expand Down Expand Up @@ -124,9 +125,9 @@ export default function EditGoalScreen({ navigation, route }: Props) {
setWasSaved(true);
showToast('Goal changes saved.', 'success');
navigation.goBack();
} catch (e: any) {
captureError('edit_goal_save', e);
showToast(e?.message ?? 'Failed to update goal.', 'error');
} catch (error: unknown) {
captureError('edit_goal_save', error);
showToast(getErrorMessage(error, 'Failed to update goal.'), 'error');
} finally {
setLoading(false);
}
Expand All @@ -150,9 +151,9 @@ export default function EditGoalScreen({ navigation, route }: Props) {
setWasSaved(true);
showToast('Goal deleted.', 'success');
navigation.navigate('AppTabs');
} catch (e: any) {
captureError('edit_goal_delete', e);
showToast(e?.message ?? 'Failed to delete goal.', 'error');
} catch (error: unknown) {
captureError('edit_goal_delete', error);
showToast(getErrorMessage(error, 'Failed to delete goal.'), 'error');
} finally {
setLoading(false);
}
Expand Down
18 changes: 16 additions & 2 deletions src/services/goalService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,22 @@ export interface Goal {
currentBalance: number;
}

/** Typed shape of a goal document as stored in Firestore. */
interface GoalDocData {
userId: string;
name: string;
targetAmount: number;
monthlyContribution: number;
annualInterestRate: number;
timelineMonths?: number;
visualTheme?: ThemeType;
createdAt: Timestamp;
completedAt?: Timestamp;
currentBalance?: number;
}

function mapGoal(snap: QueryDocumentSnapshot): Goal {
const d = snap.data();
const d = snap.data() as GoalDocData;
return {
id: snap.id,
userId: d.userId,
Expand Down Expand Up @@ -82,7 +96,7 @@ export async function updateGoalBalance(goalId: string, newBalance: number): Pro

export async function markGoalCompleted(goalId: string): Promise<void> {
if (isE2EMode) {
e2eUpdateGoal(goalId, { completedAt: serverTimestamp() as unknown as Timestamp });
e2eUpdateGoal(goalId, { completedAt: Timestamp.now() });
return;
}
await updateDoc(doc(db, 'goals', goalId), { completedAt: serverTimestamp() });
Expand Down
7 changes: 7 additions & 0 deletions src/utils/errorUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Safely extracts a human-readable message from an unknown caught value.
* Use this in catch blocks instead of `(e: any).message`.
*/
export function getErrorMessage(error: unknown, fallback = 'An unexpected error occurred.'): string {
return error instanceof Error ? error.message : fallback;
}
30 changes: 30 additions & 0 deletions tests/errorUtils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getErrorMessage } from '../src/utils/errorUtils';

describe('getErrorMessage', () => {
it('returns the error message when given an Error instance', () => {
const err = new Error('something went wrong');
assert.equal(getErrorMessage(err), 'something went wrong');
});

it('returns the fallback when given a non-Error value (string)', () => {
assert.equal(getErrorMessage('raw string'), 'An unexpected error occurred.');
});

it('returns the fallback when given a non-Error value (object)', () => {
assert.equal(getErrorMessage({ code: 42 }), 'An unexpected error occurred.');
});

it('returns the fallback when given null', () => {
assert.equal(getErrorMessage(null), 'An unexpected error occurred.');
});

it('returns the fallback when given undefined', () => {
assert.equal(getErrorMessage(undefined), 'An unexpected error occurred.');
});

it('uses the custom fallback when provided', () => {
assert.equal(getErrorMessage('not an Error', 'custom fallback'), 'custom fallback');
});
Comment on lines +1 to +29

Copilot AI Mar 7, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unit tests in this repo currently use the test() API from node:test (see tests/compoundInterest.test.ts). For consistency (and to keep the suite uniform), consider using import test from 'node:test' here as well rather than mixing describe/it with test in other files.

Suggested change
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { getErrorMessage } from '../src/utils/errorUtils';
describe('getErrorMessage', () => {
it('returns the error message when given an Error instance', () => {
const err = new Error('something went wrong');
assert.equal(getErrorMessage(err), 'something went wrong');
});
it('returns the fallback when given a non-Error value (string)', () => {
assert.equal(getErrorMessage('raw string'), 'An unexpected error occurred.');
});
it('returns the fallback when given a non-Error value (object)', () => {
assert.equal(getErrorMessage({ code: 42 }), 'An unexpected error occurred.');
});
it('returns the fallback when given null', () => {
assert.equal(getErrorMessage(null), 'An unexpected error occurred.');
});
it('returns the fallback when given undefined', () => {
assert.equal(getErrorMessage(undefined), 'An unexpected error occurred.');
});
it('uses the custom fallback when provided', () => {
assert.equal(getErrorMessage('not an Error', 'custom fallback'), 'custom fallback');
});
import test from 'node:test';
import assert from 'node:assert/strict';
import { getErrorMessage } from '../src/utils/errorUtils';
test('getErrorMessage returns the error message when given an Error instance', () => {
const err = new Error('something went wrong');
assert.equal(getErrorMessage(err), 'something went wrong');
});
test('getErrorMessage returns the fallback when given a non-Error value (string)', () => {
assert.equal(getErrorMessage('raw string'), 'An unexpected error occurred.');
});
test('getErrorMessage returns the fallback when given a non-Error value (object)', () => {
assert.equal(getErrorMessage({ code: 42 }), 'An unexpected error occurred.');
});
test('getErrorMessage returns the fallback when given null', () => {
assert.equal(getErrorMessage(null), 'An unexpected error occurred.');
});
test('getErrorMessage returns the fallback when given undefined', () => {
assert.equal(getErrorMessage(undefined), 'An unexpected error occurred.');
});
test('getErrorMessage uses the custom fallback when provided', () => {
assert.equal(getErrorMessage('not an Error', 'custom fallback'), 'custom fallback');

Copilot uses AI. Check for mistakes.
});