diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a9427fd..06295fd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,10 +1,9 @@
name: CI
on:
- push:
- branches: [main]
pull_request:
- branches: [main]
+ branches:
+ - main
# Cancel in-progress runs for the same workflow and branch
concurrency:
@@ -12,6 +11,14 @@ concurrency:
cancel-in-progress: true
jobs:
+ test-e2e:
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v2
+ - name: Install Maestro
+ run: curl -Ls "https://get.maestro.mobile.dev" | bash
+ - name: Run E2E tests
+ run: maestro cloud --api-key ${{ secrets.MAESTRO_API_KEY }} --app-file ./app.apk maestro/
ci:
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -211,4 +218,4 @@ jobs:
echo "| Node Modules | ${{ steps.cache-node-modules.outputs.cache-hit == 'true' && '✅ Hit' || '❌ Miss' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Fonts | ${{ steps.cache-fonts.outputs.cache-hit == 'true' && '✅ Hit' || '❌ Miss' }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
- echo "**Build completed in:** ${{ job.status == 'success' && '✅' || '❌' }}" >> $GITHUB_STEP_SUMMARY
\ No newline at end of file
+ echo "**Build completed in:** ${{ job.status == 'success' && '✅' || '❌' }}" >> $GITHUB_STEP_SUMMARY
diff --git a/App.tsx b/App.tsx
index 5c04907..55a2442 100644
--- a/App.tsx
+++ b/App.tsx
@@ -462,7 +462,9 @@ const App = () => {
-
+
+
+
{showPreferencesResetToast ? : null}
@@ -470,4 +472,4 @@ const App = () => {
);
};
-export default SHOW_STORYBOOK ? StorybookUI : App;
+export default SHOW_STORYBOOK ? StorybookUI : App;
\ No newline at end of file
diff --git a/README.md b/README.md
index c3043f5..8d755bf 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,10 @@ npx expo start --android # Launch directly in Android Emulator
npx expo start --web # Run in browser (limited functionality)
```
+## Architecture
+
+We use Architecture Decision Records (ADRs) to document important architectural choices. You can find them in the [docs/adr](docs/adr) directory.
+
## Storybook
- **Start:** `npm run storybook` to start the app in Storybook mode.
diff --git a/docs/adr/ADR-001-state-management.md b/docs/adr/ADR-001-state-management.md
new file mode 100644
index 0000000..0eeed19
--- /dev/null
+++ b/docs/adr/ADR-001-state-management.md
@@ -0,0 +1,21 @@
+# ADR-001: State Management
+
+**Status**: Accepted
+
+**Context**:
+
+We need a predictable and efficient way to manage the global state in our React Native application. The state includes user authentication, profile information, and other shared data. We considered several options, including Redux, MobX, and Zustand.
+
+**Decision**:
+
+We have decided to use Zustand for state management. Zustand is a small, fast, and scalable state management library for React. It provides a simple and intuitive API that is easy to learn and use.
+
+**Consequences**:
+
+- **Positive**:
+ - Zustand is a lightweight library with a small bundle size, which is important for mobile applications.
+ - It has a simple and intuitive API that is easy to learn and use.
+ - It is highly performant and can handle frequent state updates without performance issues.
+- **Negative**:
+ - Zustand is less popular than Redux, so there are fewer resources and community support available.
+ - It does not have a built-in middleware ecosystem like Redux, so we may need to implement custom solutions for logging, analytics, and other side effects.
\ No newline at end of file
diff --git a/docs/adr/ADR-002-api-caching-strategy.md b/docs/adr/ADR-002-api-caching-strategy.md
new file mode 100644
index 0000000..24208c5
--- /dev/null
+++ b/docs/adr/ADR-002-api-caching-strategy.md
@@ -0,0 +1,21 @@
+# ADR-002: API Caching Strategy
+
+**Status**: Accepted
+
+**Context**:
+
+Our application needs a robust and efficient way to fetch and cache data from our API. We considered several options, including React Query, SWR, and a custom solution using `fetch` and `AsyncStorage`.
+
+**Decision**:
+
+We have decided to use SWR for our API caching strategy. SWR is a React Hooks library for data fetching that provides a simple and powerful way to manage remote data. It offers features like caching, revalidation, and optimistic UI updates out of the box.
+
+**Consequences**:
+
+- **Positive**:
+ - SWR provides a simple and intuitive API that is easy to learn and use.
+ - It has a built-in caching mechanism that improves performance and reduces the number of API requests.
+ - It supports revalidation on focus, on interval, and on reconnect, which ensures that the data is always up-to-date.
+- **Negative**:
+ - SWR is not as feature-rich as React Query, so we may need to implement custom solutions for some advanced use cases.
+ - It does not have a built-in mutation management system, so we need to handle mutations manually.
\ No newline at end of file
diff --git a/docs/adr/ADR-003-authentication-token-storage.md b/docs/adr/ADR-003-authentication-token-storage.md
new file mode 100644
index 0000000..67a869f
--- /dev/null
+++ b/docs/adr/ADR-003-authentication-token-storage.md
@@ -0,0 +1,20 @@
+# ADR-003: Authentication Token Storage
+
+**Status**: Accepted
+
+**Context**:
+
+We need a secure way to store authentication tokens on the user's device. We considered several options, including `expo-secure-store` and `AsyncStorage`.
+
+**Decision**:
+
+We have decided to use `expo-secure-store` for storing authentication tokens. `expo-secure-store` provides a way to encrypt and securely store key-value pairs on the device. It uses the native Keychain services on iOS and the Keystore on Android.
+
+**Consequences**:
+
+- **Positive**:
+ - `expo-secure-store` provides a secure way to store sensitive data on the user's device.
+ - It is easy to use and has a simple API.
+- **Negative**:
+ - `expo-secure-store` is only available in Expo projects, so we may need to find an alternative if we eject to a bare React Native project.
+ - It has a size limit for the stored data, so we need to be mindful of the amount of data we store.
\ No newline at end of file
diff --git a/docs/adr/ADR-004-streaming-protocol.md b/docs/adr/ADR-004-streaming-protocol.md
new file mode 100644
index 0000000..8d4d9e5
--- /dev/null
+++ b/docs/adr/ADR-004-streaming-protocol.md
@@ -0,0 +1,21 @@
+# ADR-004: Streaming Protocol
+
+**Status**: Accepted
+
+**Context**:
+
+We need a way to stream data from our server to the client in real-time. We considered several options, including NDJSON, WebSocket, and Server-Sent Events (SSE).
+
+**Decision**:
+
+We have decided to use NDJSON (Newline Delimited JSON) for our streaming protocol. NDJSON is a simple and efficient way to stream JSON objects over a network connection. It is easy to parse and can be used with any HTTP library.
+
+**Consequences**:
+
+- **Positive**:
+ - NDJSON is a simple and lightweight protocol that is easy to implement and debug.
+ - It is supported by most HTTP libraries and can be used with any backend language.
+ - It is more resilient to network interruptions than WebSockets, as each JSON object is a separate message.
+- **Negative**:
+ - NDJSON does not provide a way to send messages from the client to the server, so we need to use a separate HTTP request for that.
+ - It does not have a built-in mechanism for handling backpressure, so we need to implement it ourselves.
\ No newline at end of file
diff --git a/docs/adr/ADR-005-logging-infrastructure.md b/docs/adr/ADR-005-logging-infrastructure.md
new file mode 100644
index 0000000..4cc3c1f
--- /dev/null
+++ b/docs/adr/ADR-005-logging-infrastructure.md
@@ -0,0 +1,21 @@
+# ADR-005: Logging Infrastructure
+
+**Status**: Accepted
+
+**Context**:
+
+We need a centralized and effective way to log events and errors in our application. We considered several options, including using `console.log` and a centralized `AppLogger` module.
+
+**Decision**:
+
+We have decided to implement a centralized `AppLogger` module for our logging infrastructure. The `AppLogger` module will provide a consistent way to log events and errors, and it will be easy to integrate with third-party logging services in the future.
+
+**Consequences**:
+
+- **Positive**:
+ - The `AppLogger` module will provide a centralized and consistent way to log events and errors.
+ - It will be easy to integrate with third-party logging services like Sentry or Bugsnag.
+ - It will allow us to easily filter and search for logs based on their level and context.
+- **Negative**:
+ - We will need to implement the `AppLogger` module ourselves, which will require some initial effort.
+ - We will need to ensure that all developers use the `AppLogger` module for logging, which may require some training and enforcement.
\ No newline at end of file
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 0000000..cdaadb6
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,16 @@
+# Architecture Decision Records
+
+This directory contains Architecture Decision Records (ADRs) for the project. ADRs are short documents that capture important architectural decisions. Each ADR describes the context of a decision, the decision itself, and the consequences of the decision.
+
+## Format
+
+Each ADR is a Markdown file with the following sections:
+
+- **Status**: The current status of the ADR (e.g., Proposed, Accepted, Deprecated, Superseded).
+- **Context**: The context and problem that the ADR is trying to solve.
+- **Decision**: The decision that was made.
+- **Consequences**: The consequences of the decision, both positive and negative.
+
+## Numbering
+
+ADRs are numbered sequentially, starting from 001. The format is `ADR-XXX.md`, where `XXX` is the zero-padded number of the ADR.
\ No newline at end of file
diff --git a/maestro/01-login.yaml b/maestro/01-login.yaml
new file mode 100644
index 0000000..ab7c0d8
--- /dev/null
+++ b/maestro/01-login.yaml
@@ -0,0 +1,10 @@
+appId: com.teachlink
+---
+- launchApp
+- tapOn: "Login"
+- tapOn: "Email"
+- inputText: "test@example.com"
+- tapOn: "Password"
+- inputText: "password"
+- tapOn: "Log In"
+- assertVisible: "Welcome, Test User"
\ No newline at end of file
diff --git a/maestro/02-course-enroll.yaml b/maestro/02-course-enroll.yaml
new file mode 100644
index 0000000..11d1b7f
--- /dev/null
+++ b/maestro/02-course-enroll.yaml
@@ -0,0 +1,7 @@
+appId: com.teachlink
+---
+- launchApp
+- tapOn: "Courses"
+- tapOn: "Introduction to React Native"
+- tapOn: "Enroll"
+- assertVisible: "You are now enrolled in this course"
\ No newline at end of file
diff --git a/maestro/03-lesson-complete.yaml b/maestro/03-lesson-complete.yaml
new file mode 100644
index 0000000..ffef45c
--- /dev/null
+++ b/maestro/03-lesson-complete.yaml
@@ -0,0 +1,8 @@
+appId: com.teachlink
+---
+- launchApp
+- tapOn: "My Courses"
+- tapOn: "Introduction to React Native"
+- tapOn: "Chapter 1: Getting Started"
+- tapOn: "Mark as Complete"
+- assertVisible: "Lesson Complete"
\ No newline at end of file
diff --git a/maestro/04-quiz-submit.yaml b/maestro/04-quiz-submit.yaml
new file mode 100644
index 0000000..fe34296
--- /dev/null
+++ b/maestro/04-quiz-submit.yaml
@@ -0,0 +1,10 @@
+appId: com.teachlink
+---
+- launchApp
+- tapOn: "My Courses"
+- tapOn: "Introduction to React Native"
+- tapOn: "Chapter 1 Quiz"
+- tapOn: "Answer 1"
+- tapOn: "Answer 2"
+- tapOn: "Submit"
+- assertVisible: "Quiz Submitted"
\ No newline at end of file
diff --git a/src/__tests__/components/ScreenErrorBoundary.test.tsx b/src/__tests__/components/ScreenErrorBoundary.test.tsx
new file mode 100644
index 0000000..0382e77
--- /dev/null
+++ b/src/__tests__/components/ScreenErrorBoundary.test.tsx
@@ -0,0 +1,53 @@
+import { fireEvent, render } from '@testing-library/react-native';
+import { Text } from 'react-native';
+import ScreenErrorBoundary from '../../components/common/ScreenErrorBoundary';
+
+// Mock Sentry so we don't actually send errors
+jest.mock('@sentry/react-native', () => ({
+ withScope: jest.fn((callback) => callback({ setTag: jest.fn() })),
+ captureException: jest.fn(),
+}));
+
+const ProblemChild = () => {
+ throw new Error('Test error');
+ return You should not see this;
+};
+
+
+describe('ScreenErrorBoundary', () => {
+ it('should render children when there is no error', () => {
+ const { getByText } = render(
+
+ Hello World
+
+ );
+
+ expect(getByText('Hello World')).toBeTruthy();
+ });
+
+ it('should render an error message when a child component throws an error', () => {
+ const { getByText } = render(
+
+
+
+ );
+
+ expect(getByText('This screen encountered an error.')).toBeTruthy();
+ });
+
+ it('should allow the user to retry rendering the child component', () => {
+ const { getByText, queryByText } = render(
+
+
+
+ );
+
+ expect(getByText('This screen encountered an error.')).toBeTruthy();
+
+ fireEvent.press(getByText('Retry'));
+
+ // After retrying, the error boundary should re-render its children.
+ // In this test case, ProblemChild will throw an error again.
+ expect(getByText('This screen encountered an error.')).toBeTruthy();
+ });
+});
\ No newline at end of file
diff --git a/src/__tests__/services/api/axios.config.test.ts b/src/__tests__/services/api/axios.config.test.ts
new file mode 100644
index 0000000..f395f83
--- /dev/null
+++ b/src/__tests__/services/api/axios.config.test.ts
@@ -0,0 +1,22 @@
+import MockAdapter from 'axios-mock-adapter';
+import { apiClient } from '../../../services/api/axios.config';
+
+describe('axios.config.ts', () => {
+ let mock: MockAdapter;
+
+ beforeEach(() => {
+ mock = new MockAdapter(apiClient);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ it('should add an X-Request-ID header to every request', async () => {
+ mock.onGet('/test').reply(200);
+
+ await apiClient.get('/test');
+
+ expect(mock.history.get[0].headers['X-Request-ID']).toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/src/components/common/ScreenErrorBoundary.tsx b/src/components/common/ScreenErrorBoundary.tsx
new file mode 100644
index 0000000..10e3cd4
--- /dev/null
+++ b/src/components/common/ScreenErrorBoundary.tsx
@@ -0,0 +1,55 @@
+import * as Sentry from '@sentry/react-native';
+import React, { Component, ErrorInfo, ReactNode } from 'react';
+import { Button, Text, View } from 'react-native';
+
+interface Props {
+ children: ReactNode;
+ screenName: string;
+}
+
+interface State {
+ hasError: boolean;
+}
+
+class ScreenErrorBoundary extends Component {
+ state: State = {
+ hasError: false,
+ };
+
+ static getDerivedStateFromError(_: Error): State {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error: Error, errorInfo: ErrorInfo) {
+ Sentry.withScope((scope) => {
+ scope.setTag('screen', this.props.screenName);
+ Sentry.captureException(error, { extra: errorInfo });
+ });
+ }
+
+ handleRetry = () => {
+ this.setState({ hasError: false });
+ };
+
+ handleGoHome = () => {
+ // This assumes you are using a navigation library that can navigate to a "Home" route.
+ // You may need to adjust this depending on your navigation setup.
+ // For expo-router, you might use router.replace('/');
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+ This screen encountered an error.
+
+
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+export default ScreenErrorBoundary;
\ No newline at end of file
diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts
index bc4a1d9..d29ead5 100644
--- a/src/services/api/axios.config.ts
+++ b/src/services/api/axios.config.ts
@@ -12,22 +12,22 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
-import {
- invalidateCacheForBatchRequests,
- invalidateCacheForMutation,
- invalidateByPattern,
-} from './cache';
-import { requestQueue } from './requestQueue';
import { getEnv } from '../../config';
import { MUTATION_INVALIDATION_MAP } from '../../config/apiCacheConfig';
import { SSL_PINNING } from '../../config/security';
import { useAppStore } from '../../store';
import { useConflictStore, type ConflictData } from '../../store/conflictStore';
import { appLogger } from '../../utils/logger';
-import { startTiming, notifyEntry } from '../../utils/performanceTiming';
+import { notifyEntry, startTiming } from '../../utils/performanceTiming';
import { healthMetricsService } from '../healthMetrics';
import { getAccessToken, getRefreshToken, saveTokens } from '../secureStorage';
import { sentryContextService } from '../sentryContext';
+import {
+ invalidateByPattern,
+ invalidateCacheForBatchRequests,
+ invalidateCacheForMutation,
+} from './cache';
+import { requestQueue } from './requestQueue';
// ─── Helpers ────────────────────────────────────────────────────────────────
@@ -147,6 +147,10 @@ function processRefreshQueue(token: string | null, error: unknown) {
apiClient.interceptors.request.use(
async (config: InternalAxiosRequestConfig & { _requestStartMs?: number }) => {
+ const requestId = uuidv4();
+ config.headers['X-Request-ID'] = requestId;
+ pushLogContext({ requestId });
+
// Stamp request start time for latency tracking
config._requestStartMs = Date.now();
@@ -210,6 +214,15 @@ apiClient.interceptors.request.use(
apiClient.interceptors.response.use(
response => {
+ const sentRequestId = response.config.headers['X-Request-ID'];
+ const receivedRequestId = response.headers['x-request-id'];
+
+ if (sentRequestId && receivedRequestId && sentRequestId !== receivedRequestId) {
+ appLogger.warnSync('Request ID mismatch', { sent: sentRequestId, received: receivedRequestId });
+ }
+
+ popLogContext();
+
// Record successful API call for health metrics
const cfg = response.config as InternalAxiosRequestConfig & { _requestStartMs?: number };
const durationMs = cfg._requestStartMs ? Date.now() - cfg._requestStartMs : 0;
@@ -229,6 +242,8 @@ apiClient.interceptors.response.use(
return response;
},
async (error: AxiosError) => {
+ popLogContext();
+
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
_retryCount?: number;
@@ -561,4 +576,4 @@ apiClient.interceptors.response.use(
}
);
-export default apiClient;
+export default apiClient;
\ No newline at end of file