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
15 changes: 11 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]
branches:
- main

# Cancel in-progress runs for the same workflow and branch
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
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
Expand Down Expand Up @@ -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
echo "**Build completed in:** ${{ job.status == 'success' && '✅' || '❌' }}" >> $GITHUB_STEP_SUMMARY
6 changes: 4 additions & 2 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,12 +462,14 @@ const App = () => {
<AuthProvider>
<StatusBar style={theme === 'dark' ? 'light' : 'dark'} />
<CacheRevalidationBanner />
<AppNavigator />
<ScreenErrorBoundary screenName="AppNavigator">
<AppNavigator />
</ScreenErrorBoundary>
<NotificationPermissionExplanationSheet />
{showPreferencesResetToast ? <PreferencesResetToast /> : null}
</AuthProvider>
</ErrorBoundary>
);
};

export default SHOW_STORYBOOK ? StorybookUI : App;
export default SHOW_STORYBOOK ? StorybookUI : App;
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions docs/adr/ADR-001-state-management.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/adr/ADR-002-api-caching-strategy.md
Original file line number Diff line number Diff line change
@@ -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.
20 changes: 20 additions & 0 deletions docs/adr/ADR-003-authentication-token-storage.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/adr/ADR-004-streaming-protocol.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions docs/adr/ADR-005-logging-infrastructure.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions maestro/01-login.yaml
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 7 additions & 0 deletions maestro/02-course-enroll.yaml
Original file line number Diff line number Diff line change
@@ -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"
8 changes: 8 additions & 0 deletions maestro/03-lesson-complete.yaml
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 10 additions & 0 deletions maestro/04-quiz-submit.yaml
Original file line number Diff line number Diff line change
@@ -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"
53 changes: 53 additions & 0 deletions src/__tests__/components/ScreenErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <Text>You should not see this</Text>;
};


describe('ScreenErrorBoundary', () => {
it('should render children when there is no error', () => {
const { getByText } = render(
<ScreenErrorBoundary screenName="TestScreen">
<Text>Hello World</Text>
</ScreenErrorBoundary>
);

expect(getByText('Hello World')).toBeTruthy();
});

it('should render an error message when a child component throws an error', () => {
const { getByText } = render(
<ScreenErrorBoundary screenName="TestScreen">
<ProblemChild />
</ScreenErrorBoundary>
);

expect(getByText('This screen encountered an error.')).toBeTruthy();
});

it('should allow the user to retry rendering the child component', () => {
const { getByText, queryByText } = render(
<ScreenErrorBoundary screenName="TestScreen">
<ProblemChild />
</ScreenErrorBoundary>
);

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();
});
});
22 changes: 22 additions & 0 deletions src/__tests__/services/api/axios.config.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
55 changes: 55 additions & 0 deletions src/components/common/ScreenErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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<Props, State> {
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 (
<View>
<Text>This screen encountered an error.</Text>
<Button title="Retry" onPress={this.handleRetry} />
<Button title="Go Home" onPress={this.handleGoHome} />
</View>
);
}

return this.props.children;
}
}

export default ScreenErrorBoundary;
Loading