diff --git a/.env b/.env
index 6f65384..8dff4dd 100644
--- a/.env
+++ b/.env
@@ -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
diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 4c076b9..98be759 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -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:
diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index 03b3c18..041a2c0 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -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
diff --git a/README.md b/README.md
index cc8b628..7f06202 100644
--- a/README.md
+++ b/README.md
@@ -2,9 +2,11 @@

-**Demo**
+## Demo
[Python Front](https://europanite.github.io/python_front/))
+A browser based Python playground.
+
---
## 🚀 Getting Started
diff --git a/frontend/app/__tests__/auth.context.test.tsx b/frontend/app/__tests__/auth.context.test.tsx
index 9ed2ed3..457404a 100644
--- a/frontend/app/__tests__/auth.context.test.tsx
+++ b/frontend/app/__tests__/auth.context.test.tsx
@@ -22,39 +22,11 @@ beforeEach(() => {
exposed = null;
});
-test('signIn sets user/token', async () => {
- fetchMock.mockResolvedValueOnce({
- ok: true,
- json: async () => ({ access_token: 'tok' }),
- });
-
- render();
- expect(exposed).toBeTruthy();
-
- await act(async () => {
- await exposed!.signIn('u@example.com', 'pw');
- });
-
- await waitFor(() => {
- expect(screen.getByTestId('user').props.children).toBe('u@example.com');
- });
-});
-
-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();
- await act(async () => {
- await exposed!.signUp('u2@example.com', '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 {user ? 'yes' : 'no'};
+ }
+ render();
+ expect(screen.getByTestId('user').props.children).toBe('no');
+});
\ No newline at end of file
diff --git a/frontend/app/__tests__/home.screen.test.tsx b/frontend/app/__tests__/home.screen.test.tsx
index 9e26fc1..d79daf5 100644
--- a/frontend/app/__tests__/home.screen.test.tsx
+++ b/frontend/app/__tests__/home.screen.test.tsx
@@ -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();
- 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();
- 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();
- 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();
+});
\ No newline at end of file
diff --git a/frontend/app/__tests__/settingsbar.test.tsx b/frontend/app/__tests__/settingsbar.test.tsx
index c32d623..70909ae 100644
--- a/frontend/app/__tests__/settingsbar.test.tsx
+++ b/frontend/app/__tests__/settingsbar.test.tsx
@@ -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() }));
@@ -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();
-});
-
-test('Login: Email and Sign out', () => {
- const signOut = jest.fn();
- useAuth.mockReturnValue({ user: { email: 'me@x' }, signOut });
- const Comp = require('../components/SettingsBar').default;
-
- render();
- 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();
+ // No strict text to assert (bar is mostly layout), just verify render
+ expect(true).toBe(true);
+});
\ No newline at end of file
diff --git a/frontend/app/__tests__/signin.screen.test.tsx b/frontend/app/__tests__/signin.screen.test.tsx
deleted file mode 100644
index 4304ef4..0000000
--- a/frontend/app/__tests__/signin.screen.test.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
-import SignInScreen from '../screens/SignInScreen';
-
-// Mock useAuth
-const mockSignIn = jest.fn().mockResolvedValue(undefined);
-jest.mock('../context/Auth', () => ({ useAuth: () => ({ signIn: mockSignIn }) }));
-
-// Mock useNavigation
-const mockNavigate = jest.fn();
-jest.mock('@react-navigation/native', () => {
- const actual = jest.requireActual('@react-navigation/native');
- return { ...actual, useNavigation: () => ({ navigate: mockNavigate }) };
-});
-
-beforeEach(() => {
- mockSignIn.mockClear();
- mockNavigate.mockClear();
-});
-
-test('入力→Sign In→Home 遷移', async () => {
- render();
- fireEvent.changeText(screen.getByTestId('email'), ' user@example.com ');
- fireEvent.changeText(screen.getByTestId('password'), 'pw');
- fireEvent.press(screen.getByTestId('submit'));
-
- await waitFor(() => {
- expect(mockSignIn).toHaveBeenCalledWith('user@example.com', 'pw'); // trim
- expect(mockNavigate).toHaveBeenCalledWith('Home');
- });
-});
diff --git a/frontend/app/__tests__/signup.screen.test.tsx b/frontend/app/__tests__/signup.screen.test.tsx
deleted file mode 100644
index a84d31f..0000000
--- a/frontend/app/__tests__/signup.screen.test.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import React from 'react';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react-native';
-import SignUpScreen from '../screens/SignUpScreen';
-
-const mockSignUp = jest.fn().mockResolvedValue(undefined);
-jest.mock('../context/Auth', () => ({ useAuth: () => ({ signUp: mockSignUp }) }));
-
-const mockNavigate = jest.fn();
-jest.mock('@react-navigation/native', () => {
- const actual = jest.requireActual('@react-navigation/native');
- return { ...actual, useNavigation: () => ({ navigate: mockNavigate }) };
-});
-
-beforeEach(() => { mockSignUp.mockClear(); mockNavigate.mockClear(); });
-
-test('Sign Up to Home ', async () => {
- render();
- fireEvent.changeText(screen.getByTestId('email'), 'a@b.c');
- fireEvent.changeText(screen.getByTestId('password'), 'pw');
- fireEvent.press(screen.getByTestId('submit'));
-
- await waitFor(() => {
- expect(mockSignUp).toHaveBeenCalledWith('a@b.c', 'pw');
- expect(mockNavigate).toHaveBeenCalledWith('Home');
- });
-});
diff --git a/frontend/app/components/SettingsBar.tsx b/frontend/app/components/SettingsBar.tsx
index 95b47e8..7aca264 100644
--- a/frontend/app/components/SettingsBar.tsx
+++ b/frontend/app/components/SettingsBar.tsx
@@ -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();
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 }) => (
Promise;
- signUp: (email: string, password: string) => Promise;
- signOut: () => void;
- authHeader: () => Record;
+ authHeader: () => Partial>;
};
const AuthContext = createContext(null);
@@ -21,44 +18,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState(null);
const [user, setUser] = useState(null);
- const authHeader = () => (token ? { Authorization: `Bearer ${token}` } : {});
-
- async function signIn(email: string, password: string) {
- const res = await fetch(`${API_BASE}/auth/signin`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, password })
- });
- if (!res.ok) {
- const msg = (await res.json().catch(() => ({} as any)))?.detail || "Sign in failed";
- throw new Error(String(msg));
- }
- const data = await res.json();
- setToken(data.access_token);
- setUser({ email });
- }
-
- async function signUp(email: string, password: string) {
- const res = await fetch(`${API_BASE}/auth/signup`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ email, password })
- });
- if (!res.ok) {
- const msg = (await res.json().catch(() => ({} as any)))?.detail || "Sign up failed";
- throw new Error(String(msg));
- }
- // auto sign-in after sign-up
- await signIn(email, password);
- }
-
- function signOut() {
- setToken(null);
- setUser(null);
- }
+ const authHeader = () =>
+ token ? { Authorization: `Bearer ${token}` } : {};
return (
-
+
{children}
);
diff --git a/frontend/app/jest.config.ts b/frontend/app/jest.config.ts
index 22fc52b..8bb1191 100644
--- a/frontend/app/jest.config.ts
+++ b/frontend/app/jest.config.ts
@@ -1,6 +1,6 @@
module.exports = {
preset: 'jest-expo',
- testEnvironment: 'node',
+ testEnvironment: 'jsdom',
setupFilesAfterEnv: ['/jest.setup.ts'],
moduleNameMapper: {
'\\.(png|jpe?g|gif|svg)$': '/__mocks__/fileMock.js',
diff --git a/frontend/app/jest.setup.ts b/frontend/app/jest.setup.ts
index 9f28ad2..068f334 100644
--- a/frontend/app/jest.setup.ts
+++ b/frontend/app/jest.setup.ts
@@ -1 +1,2 @@
import '@testing-library/jest-native/extend-expect';
+import '@testing-library/jest-dom';
\ No newline at end of file
diff --git a/frontend/app/package-lock.json b/frontend/app/package-lock.json
index a6201e2..02438bb 100644
--- a/frontend/app/package-lock.json
+++ b/frontend/app/package-lock.json
@@ -39,7 +39,11 @@
"react-native-reanimated": "^4.1.0",
"react-test-renderer": "^19.1.0",
"ts-node": "^10.9.2",
- "typescript": "^5.9.2"
+ "typescript": "^5.9.2",
+ "@testing-library/react": "^16.0.0",
+ "react-dom": "^18.3.1",
+ "@testing-library/jest-dom": "^6.6.3",
+ "@types/jest": "29.5.14"
}
},
"node_modules/@0no-co/graphql.web": {
diff --git a/frontend/app/tsconfig.json b/frontend/app/tsconfig.json
index b9567f6..67d7fb0 100644
--- a/frontend/app/tsconfig.json
+++ b/frontend/app/tsconfig.json
@@ -1,6 +1,13 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
- "strict": true
+ "strict": true,
+ "moduleResolution": "bundler",
+ "lib": ["esnext", "dom"],
+ "types": ["jest", "@testing-library/jest-dom"],
+ "baseUrl": ".",
+ "paths": {
+ "pyodide": ["node_modules/pyodide/pyodide"]
+ }
}
}