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 @@ !["web_ui"](./assets/images/web_ui.png) -**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"] + } } }