diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 436e11d..e4038dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,17 @@ jobs: - name: Run Tests run: make GOBIN=$HOME/gopath/bin test + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Create Python Virtual Environment + run: python -m venv .pyvenv + + - name: Run API Unit Tests + run: make api-test + - name: Run Coverage Tests env: COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} diff --git a/.gitignore b/.gitignore index f4b30e6..492854c 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ roveralls* .specstory .pyvenv gogen-api/__pycache__ +__pycache__/ gogen-api/build gogen-api/env.json ui/node_modules/* @@ -29,4 +30,4 @@ ui/build/* ui/coverage/* ui/public/gogen.wasm ui/.vite -*.idea \ No newline at end of file +*.idea diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..791a3d8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,116 @@ +# AGENTS.md + +This file gives coding agents the repo-specific context needed to work effectively in `gogen`. + +## Project Overview + +Gogen is a data generator for demo and test data, especially time-series logs and metrics. The repo contains: + +- a Go CLI core +- a Python AWS Lambda API backend in `gogen-api/` +- a React/TypeScript UI in `ui/` + +## Common Commands + +### Go + +```bash +make install # Preferred install path; injects ldflags from Makefile +make build # Cross-compiles linux, darwin, windows, wasm +make test # go test -v ./... +go test -v ./internal +go test -v -run TestName ./internal +``` + +Notes: + +- Use `make install` instead of bare `go install`; version/build metadata and OAuth settings are injected through `-ldflags`. +- Dependencies are vendored. After dependency changes, run `go mod vendor`. + +### Python API + +```bash +cd gogen-api +./start_dev.sh +./setup_local_db.sh +./deploy_lambdas.sh +``` + +Repo-standard Python environment: + +```bash +source /home/clint/local/src/gogen/.pyvenv/bin/activate +``` + +Focused API unit tests: + +```bash +make api-test +``` + +### UI + +```bash +cd ui +npm run dev +npm run build +npm test +``` + +## Architecture + +### Go Package Layout + +- `main.go`: CLI entry point using `urfave/cli.v1`; maps flags to `GOGEN_*` env vars +- `internal/`: core config, sample, token, API/share logic +- `generator/`: generation workers +- `outputter/`: output workers and destinations +- `run/`: pipeline orchestration +- `timer/`: one timer goroutine per sample +- `rater/`: event-rate control +- `template/`: output formatting +- `logger/`: log wrapper + +### Data Flow + +```text +YAML/JSON config -> internal.Config singleton + -> timer goroutines + -> generator worker pool + -> outputter worker pool + -> output destination +``` + +### Config System + +- Config is a singleton guarded by `sync.Once` +- Remote configs default to `https://api.gogen.io` and can be overridden by `GOGEN_APIURL` +- In Go tests, reset config state with `config.ResetConfig()` before `config.NewConfig()` +- Tests often use `config.SetupFromString(...)` for inline YAML + +### Python API + +- Lambda handlers live as separate files in `gogen-api/api/` +- Backed by DynamoDB + S3 +- Local development uses Docker Compose plus SAM +- Use `.pyvenv` rather than system Python when running repo Python commands + +### UI + +- Vite + React 18 + TypeScript + Tailwind +- Components live in `ui/src/components/` +- Pages live in `ui/src/pages/` +- Tests are colocated as `.test.tsx` + +## CI/CD + +- `.github/workflows/ci.yml` runs Go tests on pushes to `master`/`dev` and on PRs +- CI also runs `make api-test` +- Branch builds/deploys happen on `master` and `dev` +- Release workflow is handled separately in `.github/workflows/release.yml` + +## Practical Notes + +- Prefer minimal, targeted edits; this repo spans Go, Python, and frontend code in one tree +- For Python work, prefer adding tests that avoid external AWS dependencies unless the task explicitly needs integration coverage +- For UI tests, keep them aligned with the current design system rather than hardcoding old color classes diff --git a/Makefile b/Makefile index 97ffbc4..7aaf2df 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ SUMMARY = $(shell git describe --tags --always --dirty) DATE = $(shell date --rfc-3339=date) -.PHONY: all build deps install test docker splunkapp embed +.PHONY: all build deps install test api-test docker splunkapp embed ifeq ($(OS),Windows_NT) dockercmd := docker run -e TERM -e HOME=/go/src/github.com/coccyx/gogen --rm -it -v $(CURDIR):/go/src/github.com/coccyx/gogen -v $(HOME)/.ssh:/root/.ssh clintsharp/gogen bash @@ -33,6 +33,8 @@ install: test: go test -v ./... +api-test: + ./.pyvenv/bin/python -m unittest gogen-api/test_auth_utils.py gogen-api/test_upsert_auth.py + docker: $(dockercmd) - diff --git a/gogen-api/README.md b/gogen-api/README.md index b253108..fe9e0f1 100644 --- a/gogen-api/README.md +++ b/gogen-api/README.md @@ -149,6 +149,24 @@ This script will: 4. List objects in the bucket 5. Download and verify the test file +### Testing API Unit Logic + +Focused unit tests are available for request authentication helpers and config ownership enforcement. + +From the repo root: + +```bash +make api-test +``` + +Or directly with the project virtual environment: + +```bash +/home/clint/local/src/gogen/.pyvenv/bin/python -m unittest \ + gogen-api/test_auth_utils.py \ + gogen-api/test_upsert_auth.py +``` + ## Accessing Services ### API Endpoints @@ -307,4 +325,4 @@ docker-compose logs createbuckets - Document code with clear comments - Update SUMMARY.md after completing significant features - Each AWS Lambda function should be a separate .py file in the `api` directory -- Remember that the codebase is being updated from Python 2.7 to Python 3.13 \ No newline at end of file +- Remember that the codebase is being updated from Python 2.7 to Python 3.13 diff --git a/gogen-api/api/auth_utils.py b/gogen-api/api/auth_utils.py new file mode 100644 index 0000000..8525fe1 --- /dev/null +++ b/gogen-api/api/auth_utils.py @@ -0,0 +1,42 @@ +from cors_utils import cors_response +from github_utils import get_github_user +from logger import setup_logger + +logger = setup_logger(__name__) + + +def get_header(event, name): + """Return an HTTP header value using case-insensitive lookup.""" + headers = event.get('headers') or {} + target = name.lower() + + for key, value in headers.items(): + if key.lower() == target: + return value + + return None + + +def get_authenticated_username(event): + """ + Authenticate the GitHub token from the request and return the username. + + Returns: + tuple[str | None, dict | None]: (username, error_response) + """ + auth_header = get_header(event, 'Authorization') + if not auth_header: + logger.error("Authorization header not present") + return None, cors_response(401, {'error': 'Authorization header not present'}) + + user_info, error = get_github_user(auth_header) + if error: + logger.error(f"Failed to authenticate user: {error}") + return None, cors_response(401, {'error': error}) + + username = user_info.get('login') + if not username: + logger.error("Could not get username from GitHub") + return None, cors_response(401, {'error': 'Could not get username from GitHub'}) + + return username, None diff --git a/gogen-api/api/delete.py b/gogen-api/api/delete.py index 1216116..0f75f2f 100644 --- a/gogen-api/api/delete.py +++ b/gogen-api/api/delete.py @@ -2,7 +2,7 @@ from db_utils import get_dynamodb_client, get_table_name from s3_utils import delete_config from cors_utils import cors_response -from github_utils import get_github_user +from auth_utils import get_authenticated_username from logger import setup_logger logger = setup_logger(__name__) @@ -28,22 +28,9 @@ def lambda_handler(event, context): try: logger.debug(f"Received event: {json.dumps(event, indent=2)}") - # Validate GitHub authorization - if 'headers' not in event or 'Authorization' not in event['headers']: - logger.error("Authorization header not present") - return respond("Authorization header not present", status_code=401) - - # Get user information from GitHub token - auth_header = event['headers']['Authorization'] - user_info, error = get_github_user(auth_header) - if error: - logger.error(f"Failed to authenticate user: {error}") - return respond(error, status_code=401) - - username = user_info.get('login') - if not username: - logger.error("Could not get username from GitHub") - return respond("Could not get username from GitHub", status_code=401) + username, auth_error = get_authenticated_username(event) + if auth_error: + return auth_error # Extract config name from path path_params = event.get('pathParameters', {}) diff --git a/gogen-api/api/my_configs.py b/gogen-api/api/my_configs.py index a99a708..945a476 100644 --- a/gogen-api/api/my_configs.py +++ b/gogen-api/api/my_configs.py @@ -2,7 +2,7 @@ from boto3.dynamodb.conditions import Attr from db_utils import get_dynamodb_client, get_table_name from cors_utils import cors_response -from github_utils import get_github_user +from auth_utils import get_authenticated_username from logger import setup_logger logger = setup_logger(__name__) @@ -28,22 +28,9 @@ def lambda_handler(event, context): try: logger.debug(f"Received event: {json.dumps(event, indent=2)}") - # Validate GitHub authorization - if 'headers' not in event or 'Authorization' not in event['headers']: - logger.error("Authorization header not present") - return respond("Authorization header not present", status_code=401) - - # Get user information from GitHub token - auth_header = event['headers']['Authorization'] - user_info, error = get_github_user(auth_header) - if error: - logger.error(f"Failed to authenticate user: {error}") - return respond(error, status_code=401) - - username = user_info.get('login') - if not username: - logger.error("Could not get username from GitHub") - return respond("Could not get username from GitHub", status_code=401) + username, auth_error = get_authenticated_username(event) + if auth_error: + return auth_error logger.info(f"Fetching configurations for user: {username}") diff --git a/gogen-api/api/upsert.py b/gogen-api/api/upsert.py index 61afe24..92a0519 100644 --- a/gogen-api/api/upsert.py +++ b/gogen-api/api/upsert.py @@ -2,7 +2,7 @@ from db_utils import get_dynamodb_client, get_table_name from s3_utils import upload_config from cors_utils import cors_response -from github_utils import validate_github_token +from auth_utils import get_authenticated_username from logger import setup_logger logger = setup_logger(__name__) @@ -34,15 +34,9 @@ def lambda_handler(event, context): logger.error(f"Invalid JSON in request body: {str(e)}") return respond("Invalid JSON in request body") - # Validate GitHub authorization - if 'headers' not in event or 'Authorization' not in event['headers']: - logger.error("Authorization header not present") - return respond("Authorization header not present") - - # Validate GitHub token - is_valid, error_msg = validate_github_token(event['headers']['Authorization']) - if not is_valid: - return respond(error_msg) + username, auth_error = get_authenticated_username(event) + if auth_error: + return auth_error # Validate and clean request body validated_body = {} @@ -58,9 +52,9 @@ def lambda_handler(event, context): if 'config' in validated_body: config_content = validated_body['config'] - # Create S3 path in the format username/sample.yml - if 'owner' in validated_body and 'name' in validated_body: - s3_path = f"{validated_body['owner']}/{validated_body['name']}.yml" + if 'name' in validated_body: + validated_body['owner'] = username + s3_path = f"{username}/{validated_body['name']}.yml" # Upload config to S3 logger.info(f"Uploading config to S3 at path: {s3_path}") @@ -78,13 +72,13 @@ def lambda_handler(event, context): validated_body['s3Path'] = s3_path # Set the primary key (gogen = owner/name) - validated_body['gogen'] = f"{validated_body['owner']}/{validated_body['name']}" + validated_body['gogen'] = f"{username}/{validated_body['name']}" # Remove gistID if present (for migration) validated_body.pop('gistID', None) else: - logger.error("Owner or name missing in request body") - return respond("Owner and name are required fields") + logger.error("Name missing in request body") + return respond("Name is a required field") else: logger.warning("No config found in request body") @@ -103,4 +97,4 @@ def lambda_handler(event, context): except Exception as e: logger.error(f"Error in lambda_handler: {str(e)}", exc_info=True) - return respond(e) \ No newline at end of file + return respond(e) diff --git a/gogen-api/test_auth_utils.py b/gogen-api/test_auth_utils.py new file mode 100644 index 0000000..2371a12 --- /dev/null +++ b/gogen-api/test_auth_utils.py @@ -0,0 +1,43 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent / 'api')) + +import auth_utils # noqa: E402 + + +class AuthUtilsTest(unittest.TestCase): + def test_get_header_is_case_insensitive(self): + event = { + 'headers': { + 'authorization': 'token abc123', + } + } + + self.assertEqual(auth_utils.get_header(event, 'Authorization'), 'token abc123') + + @patch('auth_utils.get_github_user') + def test_get_authenticated_username_returns_username(self, mock_get_github_user): + mock_get_github_user.return_value = ({'login': 'clint'}, None) + event = {'headers': {'Authorization': 'token abc123'}} + + username, error = auth_utils.get_authenticated_username(event) + + self.assertEqual(username, 'clint') + self.assertIsNone(error) + mock_get_github_user.assert_called_once_with('token abc123') + + @patch('auth_utils.get_github_user') + def test_get_authenticated_username_returns_401_without_authorization_header(self, mock_get_github_user): + username, error = auth_utils.get_authenticated_username({'headers': {}}) + + self.assertIsNone(username) + self.assertEqual(error['statusCode'], '401') + self.assertIn('Authorization header not present', error['body']) + mock_get_github_user.assert_not_called() + + +if __name__ == '__main__': + unittest.main() diff --git a/gogen-api/test_upsert_auth.py b/gogen-api/test_upsert_auth.py new file mode 100644 index 0000000..beef1f6 --- /dev/null +++ b/gogen-api/test_upsert_auth.py @@ -0,0 +1,83 @@ +import json +import sys +import unittest +from pathlib import Path +from types import ModuleType +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parent / 'api')) + +boto3_module = ModuleType('boto3') +boto3_module.resource = Mock() +sys.modules.setdefault('boto3', boto3_module) + +botocore_module = ModuleType('botocore') +botocore_config_module = ModuleType('botocore.config') + + +class Config: + def __init__(self, *args, **kwargs): + self.args = args + self.kwargs = kwargs + + +botocore_config_module.Config = Config +sys.modules.setdefault('botocore', botocore_module) +sys.modules.setdefault('botocore.config', botocore_config_module) + +import upsert # noqa: E402 + + +class UpsertAuthTest(unittest.TestCase): + @patch('upsert.get_table_name', return_value='gogen') + @patch('upsert.get_dynamodb_client') + @patch('upsert.upload_config', return_value=True) + @patch('upsert.get_authenticated_username', return_value=('actual-user', None)) + def test_upsert_uses_authenticated_username_for_owner( + self, + mock_get_authenticated_username, + mock_upload_config, + mock_get_dynamodb_client, + _mock_get_table_name, + ): + table = Mock() + table.put_item.return_value = {'ResponseMetadata': {'HTTPStatusCode': 200}} + mock_get_dynamodb_client.return_value.Table.return_value = table + + event = { + 'headers': {'authorization': 'token abc123'}, + 'body': json.dumps({ + 'owner': 'spoofed-user', + 'name': 'sample', + 'description': 'demo', + 'config': 'global: {}' + }) + } + + response = upsert.lambda_handler(event, None) + + self.assertEqual(response['statusCode'], '200') + mock_get_authenticated_username.assert_called_once_with(event) + mock_upload_config.assert_called_once_with('actual-user/sample.yml', 'global: {}') + table.put_item.assert_called_once() + + stored_item = table.put_item.call_args.kwargs['Item'] + self.assertEqual(stored_item['owner'], 'actual-user') + self.assertEqual(stored_item['gogen'], 'actual-user/sample') + self.assertEqual(stored_item['s3Path'], 'actual-user/sample.yml') + + @patch('upsert.get_authenticated_username') + def test_upsert_returns_auth_error_response(self, mock_get_authenticated_username): + mock_get_authenticated_username.return_value = ( + None, + {'statusCode': '401', 'body': json.dumps({'error': 'Authorization header not present'})} + ) + + response = upsert.lambda_handler({'headers': {}, 'body': '{}'}, None) + + self.assertEqual(response['statusCode'], '401') + self.assertIn('Authorization header not present', response['body']) + + +if __name__ == '__main__': + unittest.main() diff --git a/ui/jest.config.ts b/ui/jest.config.ts index a711efb..45c4340 100644 --- a/ui/jest.config.ts +++ b/ui/jest.config.ts @@ -13,7 +13,6 @@ const config: Config = { }, testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - collectCoverage: true, collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', @@ -30,4 +29,4 @@ const config: Config = { }, }; -export default config; \ No newline at end of file +export default config; diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index c66d40d..3ec213b 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -47,23 +47,23 @@ describe('App Component', () => { ); }; - it('renders the layout component', () => { + it('renders the layout component', async () => { renderWithRouter(); - expect(screen.getByTestId('mock-layout')).toBeInTheDocument(); + expect(await screen.findByTestId('mock-layout')).toBeInTheDocument(); }); - it('renders home page on root path', () => { + it('renders home page on root path', async () => { renderWithRouter(['/']); - expect(screen.getByTestId('mock-home-page')).toBeInTheDocument(); + expect(await screen.findByTestId('mock-home-page')).toBeInTheDocument(); }); - it('renders configuration detail page on configuration path', () => { + it('renders configuration detail page on configuration path', async () => { renderWithRouter(['/configurations/owner/config-name']); - expect(screen.getByTestId('mock-config-detail-page')).toBeInTheDocument(); + expect(await screen.findByTestId('mock-config-detail-page')).toBeInTheDocument(); }); - it('renders not found page for unknown routes', () => { + it('renders not found page for unknown routes', async () => { renderWithRouter(['/unknown-route']); - expect(screen.getByTestId('mock-not-found-page')).toBeInTheDocument(); + expect(await screen.findByTestId('mock-not-found-page')).toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b2eae3f..d83eb96 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,51 +1,56 @@ +import { Suspense, lazy } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { AuthProvider } from './context/AuthContext'; import Layout from './components/Layout'; import ProtectedRoute from './components/ProtectedRoute'; -import HomePage from './pages/HomePage'; -import ConfigurationDetailPage from './pages/ConfigurationDetailPage'; -import LoginPage from './pages/LoginPage'; -import AuthCallbackPage from './pages/AuthCallbackPage'; -import MyConfigurationsPage from './pages/MyConfigurationsPage'; -import EditConfigurationPage from './pages/EditConfigurationPage'; -import NotFoundPage from './pages/NotFoundPage'; +import LoadingSpinner from './components/LoadingSpinner'; + +const HomePage = lazy(() => import('./pages/HomePage')); +const ConfigurationDetailPage = lazy(() => import('./pages/ConfigurationDetailPage')); +const LoginPage = lazy(() => import('./pages/LoginPage')); +const AuthCallbackPage = lazy(() => import('./pages/AuthCallbackPage')); +const MyConfigurationsPage = lazy(() => import('./pages/MyConfigurationsPage')); +const EditConfigurationPage = lazy(() => import('./pages/EditConfigurationPage')); +const NotFoundPage = lazy(() => import('./pages/NotFoundPage')); function App() { return ( - - } /> - } /> - } /> - } /> - - - - } - /> - - - - } - /> - - - - } - /> - } /> - + }> + + } /> + } /> + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + } /> + + diff --git a/ui/src/api/gogenApi.ts b/ui/src/api/gogenApi.ts index f6d6b74..6e87aa3 100644 --- a/ui/src/api/gogenApi.ts +++ b/ui/src/api/gogenApi.ts @@ -50,7 +50,6 @@ export interface OAuthResponse { export interface UpsertRequest { name: string; - owner: string; description: string; config: string; } diff --git a/ui/src/components/ConfigurationList.test.tsx b/ui/src/components/ConfigurationList.test.tsx index f386ce2..0adbd90 100644 --- a/ui/src/components/ConfigurationList.test.tsx +++ b/ui/src/components/ConfigurationList.test.tsx @@ -62,11 +62,11 @@ describe('ConfigurationList', () => { // Check header styling const headers = screen.getAllByRole('columnheader'); headers.forEach(header => { - expect(header).toHaveClass('px-6', 'py-3', 'text-left', 'text-xs', 'font-medium', 'text-gray-500', 'uppercase', 'tracking-wider'); + expect(header).toHaveClass('px-6', 'py-2', 'text-left', 'text-xs', 'font-medium', 'text-term-text-muted', 'uppercase', 'tracking-wider'); }); // Check table container styling const tableContainer = screen.getByRole('table').closest('div'); - expect(tableContainer).toHaveClass('bg-white', 'rounded-lg', 'shadow', 'overflow-hidden'); + expect(tableContainer).toHaveClass('bg-term-bg-elevated', 'rounded', 'border', 'border-term-border', 'overflow-hidden'); }); -}); \ No newline at end of file +}); diff --git a/ui/src/components/Footer.test.tsx b/ui/src/components/Footer.test.tsx index b9dd372..b3f2133 100644 --- a/ui/src/components/Footer.test.tsx +++ b/ui/src/components/Footer.test.tsx @@ -48,8 +48,8 @@ describe('Footer', () => { const container = footer.firstElementChild; const flexContainer = container?.firstElementChild; - expect(footer).toHaveClass('bg-cribl-primary', 'text-white', 'py-6'); + expect(footer).toHaveClass('bg-term-bg-elevated', 'text-term-text', 'py-3', 'border-t', 'border-term-border'); expect(container).toHaveClass('container-custom', 'mx-auto', 'px-4'); expect(flexContainer).toHaveClass('flex', 'flex-col', 'md:flex-row', 'justify-between', 'items-center'); }); -}); \ No newline at end of file +}); diff --git a/ui/src/components/Header.test.tsx b/ui/src/components/Header.test.tsx index 86841d5..8e7b46f 100644 --- a/ui/src/components/Header.test.tsx +++ b/ui/src/components/Header.test.tsx @@ -2,6 +2,15 @@ import { render, screen } from '../utils/test-utils'; import { MemoryRouter } from 'react-router-dom'; import Header from './Header'; +jest.mock('../context/AuthContext', () => ({ + useAuth: () => ({ + user: null, + isAuthenticated: false, + logout: jest.fn(), + isLoading: false, + }), +})); + describe('Header', () => { const renderWithRouter = () => { render( @@ -14,10 +23,10 @@ describe('Header', () => { it('renders logo text', () => { renderWithRouter(); - const logo = screen.getByText('Gogen UI'); + const logo = screen.getByText('gogen'); expect(logo).toBeInTheDocument(); expect(logo).toHaveAttribute('href', '/'); - expect(logo).toHaveClass('text-2xl', 'font-bold', 'no-underline', 'text-white'); + expect(logo).toHaveClass('font-mono', 'text-xl', 'font-semibold', 'no-underline', 'text-term-text'); }); it('renders navigation links', () => { @@ -26,7 +35,8 @@ describe('Header', () => { const homeLink = screen.getByText('Home'); expect(homeLink).toBeInTheDocument(); expect(homeLink).toHaveAttribute('href', '/'); - expect(homeLink).toHaveClass('hover:text-cyan-400', 'transition-colors'); + expect(homeLink).toHaveClass('text-sm', 'hover:text-term-green', 'transition-colors'); + expect(screen.getByText('Login')).toHaveAttribute('href', '/login'); }); it('has correct layout structure and styling', () => { @@ -34,7 +44,7 @@ describe('Header', () => { // Check header styling const header = screen.getByRole('banner'); - expect(header).toHaveClass('bg-blue-900', 'text-white', 'p-4', 'shadow-md'); + expect(header).toHaveClass('bg-term-bg-elevated', 'text-term-text', 'px-4', 'py-2', 'border-b', 'border-term-border'); // Check container styling const container = header.firstElementChild; @@ -43,6 +53,6 @@ describe('Header', () => { // Check navigation styling const nav = screen.getByRole('navigation'); expect(nav).toBeInTheDocument(); - expect(nav.querySelector('ul')).toHaveClass('flex', 'space-x-4'); + expect(nav.querySelector('ul')).toHaveClass('flex', 'items-center', 'space-x-4'); }); -}); \ No newline at end of file +}); diff --git a/ui/src/components/Hero.test.tsx b/ui/src/components/Hero.test.tsx index a96dbbf..3fa1956 100644 --- a/ui/src/components/Hero.test.tsx +++ b/ui/src/components/Hero.test.tsx @@ -8,7 +8,7 @@ describe('Hero', () => { // Check for main heading expect(screen.getByRole('heading', { level: 2, - name: /gogen helps generate telemetry data, quickly and easily\./i + name: /generate telemetry data, quickly and easily\./i })).toBeInTheDocument(); // Check for description text @@ -29,7 +29,7 @@ describe('Hero', () => { // Check section styling const heroSection = screen.getByRole('region', { name: /hero/i }); - expect(heroSection).toHaveClass('bg-blue-800', 'text-white', 'py-12'); + expect(heroSection).toHaveClass('bg-term-bg-elevated', 'text-term-text', 'py-6', 'border-b', 'border-term-border'); // Check container styling const container = heroSection.firstElementChild; @@ -41,10 +41,10 @@ describe('Hero', () => { // Check heading styling const heading = screen.getByRole('heading', { level: 2 }); - expect(heading).toHaveClass('text-4xl', 'font-bold', 'mb-4'); + expect(heading).toHaveClass('text-2xl', 'font-bold', 'mb-2'); // Check paragraph styling const paragraph = screen.getByText(/view and manage/i); - expect(paragraph).toHaveClass('text-xl'); + expect(paragraph).toHaveClass('text-term-text-muted'); }); -}); \ No newline at end of file +}); diff --git a/ui/src/components/Layout.test.tsx b/ui/src/components/Layout.test.tsx index 078ff31..860e7b0 100644 --- a/ui/src/components/Layout.test.tsx +++ b/ui/src/components/Layout.test.tsx @@ -36,6 +36,6 @@ describe('Layout', () => { ); const container = screen.getByTestId('layout-container'); - expect(container).toHaveClass('min-h-screen', 'bg-gray-100', 'flex', 'flex-col'); + expect(container).toHaveClass('min-h-screen', 'bg-term-bg', 'flex', 'flex-col'); }); -}); \ No newline at end of file +}); diff --git a/ui/src/config.ts b/ui/src/config.ts index 33e0986..5c1d7b0 100644 --- a/ui/src/config.ts +++ b/ui/src/config.ts @@ -5,8 +5,13 @@ interface Config { githubRedirectUri: string; } +function getEnvValue(name: string): string { + const value = process.env[name]; + return value ?? ''; +} + export const config: Config = { - apiBaseUrl: import.meta.env.VITE_API_URL, - githubClientId: import.meta.env.VITE_GITHUB_CLIENT_ID, - githubRedirectUri: import.meta.env.VITE_GITHUB_REDIRECT_URI, -}; \ No newline at end of file + apiBaseUrl: getEnvValue('VITE_API_URL'), + githubClientId: getEnvValue('VITE_GITHUB_CLIENT_ID'), + githubRedirectUri: getEnvValue('VITE_GITHUB_REDIRECT_URI'), +}; diff --git a/ui/src/pages/ConfigurationDetailPage.test.tsx b/ui/src/pages/ConfigurationDetailPage.test.tsx index 910db8c..35f04e3 100644 --- a/ui/src/pages/ConfigurationDetailPage.test.tsx +++ b/ui/src/pages/ConfigurationDetailPage.test.tsx @@ -4,6 +4,13 @@ import ConfigurationDetailPage from './ConfigurationDetailPage'; import gogenApi from '../api/gogenApi'; import type { Configuration } from '../api/gogenApi'; +jest.mock('../context/AuthContext', () => ({ + useAuth: () => ({ + user: null, + isAuthenticated: false, + }), +})); + // Mock the Monaco Editor jest.mock('@monaco-editor/react', () => { return function MockEditor({ value }: { value: string }) { @@ -81,15 +88,15 @@ describe('ConfigurationDetailPage', () => { await waitFor(() => { // Check main container const mainContainer = screen.getByRole('main'); - expect(mainContainer).toHaveClass('container', 'mx-auto', 'px-4', 'py-8'); + expect(mainContainer).toHaveClass('container', 'mx-auto', 'px-4', 'py-6'); // Check title const title = screen.getByRole('heading', { level: 1 }); - expect(title).toHaveClass('text-3xl', 'font-bold', 'text-gray-800'); + expect(title).toHaveClass('text-2xl', 'font-bold', 'text-term-text', 'font-mono'); // Check back link const backLink = screen.getByRole('link', { name: 'Back to List' }); - expect(backLink).toHaveClass('btn-primary'); + expect(backLink).toHaveClass('btn-secondary', 'text-sm'); }); }); -}); \ No newline at end of file +}); diff --git a/ui/src/pages/EditConfigurationPage.tsx b/ui/src/pages/EditConfigurationPage.tsx index b0a9673..7d697cb 100644 --- a/ui/src/pages/EditConfigurationPage.tsx +++ b/ui/src/pages/EditConfigurationPage.tsx @@ -117,7 +117,6 @@ const EditConfigurationPage = () => { await gogenApi.upsertConfiguration({ name: name.trim(), - owner: user.login, description: description.trim(), config: config, }); diff --git a/ui/src/pages/HomePage.test.tsx b/ui/src/pages/HomePage.test.tsx index d2c010d..b881dba 100644 --- a/ui/src/pages/HomePage.test.tsx +++ b/ui/src/pages/HomePage.test.tsx @@ -54,7 +54,9 @@ describe('HomePage', () => { jest.clearAllMocks(); }); - it('renders Hero and ConfigurationList components', () => { + it('renders Hero and ConfigurationList components', async () => { + (gogenApi.listConfigurations as jest.Mock).mockImplementation(() => new Promise(() => {})); + renderWithRouter(); expect(screen.getByTestId('mock-hero')).toBeInTheDocument(); @@ -118,4 +120,4 @@ describe('HomePage', () => { // Verify empty state is handled expect(screen.queryByText(/test\d/)).not.toBeInTheDocument(); }); -}); \ No newline at end of file +}); diff --git a/ui/src/pages/NotFoundPage.test.tsx b/ui/src/pages/NotFoundPage.test.tsx index b22fe83..081055d 100644 --- a/ui/src/pages/NotFoundPage.test.tsx +++ b/ui/src/pages/NotFoundPage.test.tsx @@ -33,10 +33,10 @@ describe('NotFoundPage', () => { // Check heading styling const heading = screen.getByRole('heading', { level: 1 }); - expect(heading).toHaveClass('text-6xl', 'font-bold', 'text-cribl-primary', 'mb-4'); + expect(heading).toHaveClass('text-6xl', 'font-bold', 'text-term-text', 'mb-4'); // Check link styling const link = screen.getByRole('link', { name: 'Go to Homepage' }); expect(link).toHaveClass('btn-primary'); }); -}); \ No newline at end of file +}); diff --git a/ui/src/setupTests.ts b/ui/src/setupTests.ts index 8b53fea..e13e5a9 100644 --- a/ui/src/setupTests.ts +++ b/ui/src/setupTests.ts @@ -25,4 +25,39 @@ global.ResizeObserver = class ResizeObserver { // Mock TextEncoder/TextDecoder global.TextEncoder = TextEncoder; -global.TextDecoder = TextDecoder as typeof global.TextDecoder; \ No newline at end of file +global.TextDecoder = TextDecoder as typeof global.TextDecoder; + +const originalConsoleWarn = console.warn; +const originalConsoleError = console.error; + +beforeAll(() => { + jest.spyOn(console, 'warn').mockImplementation((...args: unknown[]) => { + const message = String(args[0] ?? ''); + if ( + message.includes('React Router Future Flag Warning') + ) { + return; + } + + originalConsoleWarn(...args); + }); + + jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + const message = String(args[0] ?? ''); + if ( + message.includes('not wrapped in act') || + message.startsWith('Error fetching configuration') || + message.startsWith('Error fetching configurations:') || + message.startsWith('Error searching configurations:') || + message.startsWith('Error executing WASM:') + ) { + return; + } + + originalConsoleError(...args); + }); +}); + +afterAll(() => { + jest.restoreAllMocks(); +}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index f6e19df..841f864 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,48 +1,62 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ -export default defineConfig(({ mode }) => ({ - plugins: [ - react(), - // Add plugin to set Cross-Origin Isolation headers - { - name: 'configure-server', - configureServer(server) { - server.middlewares.use((req, res, next) => { - // Add Cross-Origin Isolation headers - res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); - res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); - next(); - }); +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + + return { + plugins: [ + react(), + { + name: 'configure-server', + configureServer(server) { + server.middlewares.use((req, res, next) => { + res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp'); + res.setHeader('Cross-Origin-Opener-Policy', 'same-origin'); + next(); + }); + } } - } - ], - server: { - port: 3000, - open: true, - proxy: { - '/api': { - target: mode === 'staging' - ? 'https://staging-api.gogen.io' - : mode === 'production' - ? 'https://api.gogen.io' - : 'http://localhost:4000', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api/, '/v1'), - secure: mode !== 'development', + ], + define: { + 'process.env.VITE_API_URL': JSON.stringify(env.VITE_API_URL ?? ''), + 'process.env.VITE_GITHUB_CLIENT_ID': JSON.stringify(env.VITE_GITHUB_CLIENT_ID ?? ''), + 'process.env.VITE_GITHUB_REDIRECT_URI': JSON.stringify(env.VITE_GITHUB_REDIRECT_URI ?? ''), + }, + server: { + port: 3000, + open: true, + proxy: { + '/api': { + target: mode === 'staging' + ? 'https://staging-api.gogen.io' + : mode === 'production' + ? 'https://api.gogen.io' + : 'http://localhost:4000', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, '/v1'), + secure: mode !== 'development', + } + }, + fs: { + allow: ['..'] } }, - fs: { - allow: ['..'] - } - }, - build: { - outDir: 'dist', - sourcemap: true, - }, - optimizeDeps: { - exclude: ['@wasmer/sdk'] - }, - assetsInclude: ['**/*.wasm'], -})); \ No newline at end of file + build: { + outDir: 'dist', + sourcemap: true, + rollupOptions: { + output: { + manualChunks: { + monaco: ['@monaco-editor/react'], + }, + }, + }, + }, + optimizeDeps: { + exclude: ['@wasmer/sdk'] + }, + assetsInclude: ['**/*.wasm'], + }; +});