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
20 changes: 0 additions & 20 deletions .env
Original file line number Diff line number Diff line change
@@ -1,23 +1,3 @@
# Postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=postgres

# Backend
BACKEND_PORT=8000

# Auth
AUTH_SECRET=change-this-to-a-long-random-string
AUTH_EXPIRE_MINUTES=60

# DB
DB_HOST=db
DB_PORT=5432
DB_NAME=app_db
DB_USER=app_user
DB_PASS=app_pass
DATABASE_URL=postgresql+psycopg[binary]://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}

# Frontend (Expo)
EXPO_TUNNEL=false
EXPO_DEVTOOLS_LISTEN_ADDRESS=0.0.0.0
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ jobs:
- name: Build (expo export -p web)
working-directory: frontend/app
run: npx expo export -p web
# dist/
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
Expand Down
81 changes: 74 additions & 7 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,94 @@
name: Frontend Unit Tests
name: Frontend CI

on:
push:
pull_request:

concurrency:
group: frontend-ci-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
test:
name: Run Jest (React Native / Expo)
name: Jest on Node ${{ matrix.node }}
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18, 20] # LTS + current used in repo

env:
CI: "1"
EXPO_PUBLIC_API_BASE: http://localhost:8000
EXPO_TUNNEL: "false"
EXPO_DEVTOOLS_LISTEN_ADDRESS: 0.0.0.0
EXPO_PUBLIC_API_BASE: http://localhost:8000

steps:
- name: Checkout
uses: actions/checkout@v4
- name: Use Node.js 20

- name: Use Node ${{ matrix.node }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: frontend/app/package-lock.json

- name: Install deps
working-directory: frontend/app
run: npm ci

- name: Type check (skip if no TS)
if: hashFiles('frontend/app/tsconfig.json') != ''
working-directory: frontend/app
run: |
npx --yes typescript@latest -v >/dev/null 2>&1 || true
npx tsc --noEmit || (echo "::warning::Type check failed"; exit 1)

- name: Run Jest with coverage (CI mode)
working-directory: frontend/app
run: npm test -- --ci --runInBand --coverage --verbose

- name: Upload coverage
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-node${{ matrix.node }}
path: frontend/app/coverage
if-no-files-found: warn

web-export-smoke:
name: Expo web export (smoke)
needs: test
runs-on: ubuntu-latest
env:
CI: "1"
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Use Node 20
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: |
frontend/app/package-lock.json
- name: Install dependencies
cache-dependency-path: frontend/app/package-lock.json

- name: Install deps
working-directory: frontend/app
run: npm ci

- name: Build (expo export -p web)
working-directory: frontend/app
run: npx expo export -p web

- name: Upload dist (preview artifact)
if: always()
uses: actions/upload-artifact@v4
with:
name: expo-web-dist
path: frontend/app/dist
if-no-files-found: error
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@

!["web_ui"](./assets/images/web_ui.png)

**Demo**
## Demo
[Python Front](https://europanite.github.io/python_front/))

A browser based Python playground.

---

## 🚀 Getting Started
Expand Down
44 changes: 8 additions & 36 deletions frontend/app/__tests__/auth.context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,39 +22,11 @@ beforeEach(() => {
exposed = null;
});

test('signIn sets user/token', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ access_token: 'tok' }),
});

render(<AuthProvider><Expose /><ShowUser /></AuthProvider>);
expect(exposed).toBeTruthy();

await act(async () => {
await exposed!.signIn('[email protected]', 'pw');
});

await waitFor(() => {
expect(screen.getByTestId('user').props.children).toBe('[email protected]');
});
});

test('signUp calls signIn internally', async () => {
// /auth/signup → 200
fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
// /auth/signin → 200
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ access_token: 'tok' }),
});

render(<AuthProvider><Expose /><ShowUser /></AuthProvider>);
await act(async () => {
await exposed!.signUp('[email protected]', 'pw');
});

expect(fetchMock).toHaveBeenCalledTimes(2); // signup -> signin
expect(fetchMock.mock.calls[0][0]).toContain('/auth/signup');
expect(fetchMock.mock.calls[1][0]).toContain('/auth/signin');
});
test('AuthProvider provides default null user', () => {
function ShowUser() {
const { user } = useAuth();
return <Text testID="user">{user ? 'yes' : 'no'}</Text>;
}
render(<AuthProvider><ShowUser /></AuthProvider>);
expect(screen.getByTestId('user').props.children).toBe('no');
});
75 changes: 26 additions & 49 deletions frontend/app/__tests__/home.screen.test.tsx
Original file line number Diff line number Diff line change
@@ -1,60 +1,37 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { render, screen } from '@testing-library/react';

// Mock Fetch
const fetchMock = jest.fn();
(global as any).fetch = fetchMock;
// Mock pyodide to avoid network and heavy init
jest.mock('pyodide', () => ({
loadPyodide: jest.fn().mockResolvedValue({
setStdout: jest.fn(),
setStderr: jest.fn(),
runPythonAsync: jest.fn().mockResolvedValue(undefined),
}),
}));

// Mock useNavigation
const mockNavigate = jest.fn();
// Mock clipboard used by Copy Output button
Object.assign(navigator, { clipboard: { writeText: jest.fn().mockResolvedValue(undefined) } });

// Mock react-navigation (only what HomeScreen touches indirectly)
jest.mock('@react-navigation/native', () => {
const actual = jest.requireActual('@react-navigation/native');
return { ...actual, useNavigation: () => ({ navigate: mockNavigate }) };
return { ...actual, useNavigation: () => ({ navigate: jest.fn() }) };
});

// Mock useAuth
jest.mock('../context/Auth', () => ({ useAuth: jest.fn() }));
const { useAuth } = require('../context/Auth') as { useAuth: jest.Mock };
// Mock useAuth to a simple anonymous state
jest.mock('../context/Auth', () => ({ useAuth: jest.fn(() => ({ user: null })) }));

const HomeScreen = require('../screens/HomeScreen').default;

beforeEach(() => {
fetchMock.mockReset();
mockNavigate.mockReset();
useAuth.mockReset();
});

test('Get List While First Load', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ([{ id: 1, title: 'A' }, { id: 2, title: 'B' }]),
});
useAuth.mockReturnValue({ user: null });

test('renders title, code and console areas', async () => {
render(<HomeScreen />);
expect(await screen.findByText(/Records \(2\)/)).toBeTruthy();
});

test('Unsigned in: DELETE to SignIn ', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ([{ id: 1, title: 'A' }]),
});
useAuth.mockReturnValue({ user: null });

render(<HomeScreen />);
fireEvent.press(await screen.findByText('DELETE'));
expect(mockNavigate).toHaveBeenCalledWith('SignIn');
});

test('Signed in: Empty Title CREATE -> "Invalid Title" ', async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ([]),
});
useAuth.mockReturnValue({ user: { email: 'me@x' } });

render(<HomeScreen />);
fireEvent.press(await screen.findByText('CREATE'));
expect(await screen.findByText(/Invalid Title/)).toBeTruthy();
});
// Title link
expect(await screen.findByText('Python Front')).toBeTruthy();
// Buttons (labels come from MUI Buttons)
expect(screen.getByRole('button', { name: /Run/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Clear/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /Load Sample/i })).toBeTruthy();
// Console label
expect(screen.getByText('Console')).toBeTruthy();
});
25 changes: 8 additions & 17 deletions frontend/app/__tests__/settingsbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react-native';
import { render, screen } from '@testing-library/react-native';

// Mock useAuth
jest.mock('../context/Auth', () => ({ useAuth: jest.fn() }));
Expand All @@ -14,19 +14,10 @@ jest.mock('@react-navigation/native', () => {

beforeEach(() => { useAuth.mockReset(); mockNavigate.mockReset(); });

test('Shows Unsigned in: Sign In/Up Button', () => {
useAuth.mockReturnValue({ user: null, signOut: jest.fn() });
const Comp = require('../components/SettingsBar').default;
render(<Comp />);
});

test('Login: Email and Sign out', () => {
const signOut = jest.fn();
useAuth.mockReturnValue({ user: { email: 'me@x' }, signOut });
const Comp = require('../components/SettingsBar').default;

render(<Comp />);
expect(screen.getByText('me@x')).toBeTruthy();
fireEvent.press(screen.getByText('Sign out'));
expect(signOut).toHaveBeenCalled();
});
test('SettingsBar renders without crashing', () => {
const SettingsBar = require('../components/SettingsBar').default;
useAuth.mockReturnValue({ user: null, token: null, authHeader: () => ({}) });
render(<SettingsBar />);
// No strict text to assert (bar is mostly layout), just verify render
expect(true).toBe(true);
});
31 changes: 0 additions & 31 deletions frontend/app/__tests__/signin.screen.test.tsx

This file was deleted.

26 changes: 0 additions & 26 deletions frontend/app/__tests__/signup.screen.test.tsx

This file was deleted.

2 changes: 0 additions & 2 deletions frontend/app/components/SettingsBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,9 @@ const BAR_BG = "#000000ff";
const CONTENT_MAX_W = 480; // ← same as forms

export default function SettingsBar() {
const { user, signOut } = useAuth();
const nav = useNavigation<any>();
const { width } = useWindowDimensions();
const isNarrow = width < 420; // stack buttons below on very small widths
const NOT_SIGNED_COLOR = BAR_BG;

const Btn = ({ title, onPress }: { title: string; onPress: () => void }) => (
<TouchableOpacity
Expand Down
Loading
Loading