Spaces:
Sleeping
Sleeping
| """ | |
| Comprehensive unit tests for the AutoApp Builder Hugging Face Space. | |
| All tests run offline -- no external API calls are made. | |
| """ | |
| import ast | |
| import sys | |
| from pathlib import Path | |
| from unittest.mock import patch, MagicMock | |
| import pytest | |
| # --------------------------------------------------------------------------- | |
| # Path setup -- ensure ``app`` package is importable from the hf/ directory. | |
| # --------------------------------------------------------------------------- | |
| HF_ROOT = Path(__file__).resolve().parent.parent | |
| if str(HF_ROOT) not in sys.path: | |
| sys.path.insert(0, str(HF_ROOT)) | |
| from app.engine.app_planner import AppPlanner, APP_TEMPLATES | |
| from app.engine.model_recommender import ModelRecommender, MODEL_CATALOG | |
| from app.codegen.repo_generator import RepoGenerator | |
| from app.codegen.gradio_generator import GradioGenerator, GRADIO_TEMPLATES | |
| from app.codegen.docker_generator import DockerGenerator, DOCKER_TEMPLATES | |
| from app.codegen.readme_generator import ReadmeGenerator | |
| from app.validators.code_checker import CodeChecker | |
| def _can_run_fastapi_tests(): | |
| """Check if FastAPI integration tests can run in this environment.""" | |
| try: | |
| from starlette.testclient import TestClient | |
| from app.main import app | |
| with TestClient(app, raise_server_exceptions=False) as c: | |
| resp = c.get("/") | |
| return resp.status_code != 500 | |
| except Exception: | |
| return False | |
| # =================================================================== | |
| # 1. AppPlanner | |
| # =================================================================== | |
| class TestAppPlanner: | |
| """Tests for AppPlanner.analyze().""" | |
| def setup_method(self): | |
| self.planner = AppPlanner() | |
| # -- SDK auto-selection ------------------------------------------------ | |
| def test_sdk_auto_selects_gradio_for_chatbot(self): | |
| plan = self.planner.analyze("Build a chatbot that answers questions", "auto") | |
| assert plan["sdk"] == "gradio" | |
| def test_sdk_auto_selects_gradio_for_image_classifier(self): | |
| plan = self.planner.analyze( | |
| "Build a Gradio app that classifies images using ResNet", "auto" | |
| ) | |
| assert plan["sdk"] == "gradio" | |
| def test_sdk_auto_selects_docker_for_api_service(self): | |
| plan = self.planner.analyze( | |
| "Build a REST API service with FastAPI endpoints", "auto" | |
| ) | |
| assert plan["sdk"] == "docker" | |
| def test_sdk_auto_selects_static_for_landing_page(self): | |
| plan = self.planner.analyze( | |
| "Create a beautiful static landing page for my portfolio", "auto" | |
| ) | |
| assert plan["sdk"] == "static" | |
| def test_sdk_preference_overrides_auto(self): | |
| plan = self.planner.analyze("Build a chatbot", "docker") | |
| assert plan["sdk"] == "docker" | |
| def test_sdk_defaults_to_gradio_when_no_signal(self): | |
| plan = self.planner.analyze("Do something cool", "auto") | |
| assert plan["sdk"] == "gradio" | |
| # -- App name generation (slugified) ----------------------------------- | |
| def test_app_name_is_slugified(self): | |
| plan = self.planner.analyze("Build an Image Classifier for dogs", "auto") | |
| name = plan["app_name"] | |
| assert " " not in name | |
| assert name == name.lower() | |
| # Should contain only lowercase alphanumeric and hyphens | |
| assert all(c.isalnum() or c == "-" for c in name) | |
| def test_app_name_contains_meaningful_words(self): | |
| plan = self.planner.analyze("Create a sentiment analysis dashboard", "auto") | |
| name = plan["app_name"] | |
| # Should contain at least one meaningful keyword | |
| assert any(kw in name for kw in ["sentiment", "analysis", "dashboard"]) | |
| def test_app_name_falls_back_when_only_stop_words(self): | |
| plan = self.planner.analyze("Build a the an is are", "auto") | |
| name = plan["app_name"] | |
| # Should still produce a valid slug (fallback to "my-app") | |
| assert len(name) > 0 | |
| assert "-" in name or name.isalnum() | |
| # -- Plan structure has required keys ---------------------------------- | |
| REQUIRED_KEYS = [ | |
| "sdk", "app_name", "app_type", "title", "description", | |
| "components", "model_task", "template_key", "original_prompt", | |
| ] | |
| def test_plan_has_required_key(self, key): | |
| plan = self.planner.analyze("Build a chatbot", "auto") | |
| assert key in plan, f"Plan missing required key: {key}" | |
| def test_plan_components_is_list(self): | |
| plan = self.planner.analyze("Build a chatbot", "auto") | |
| assert isinstance(plan["components"], list) | |
| assert len(plan["components"]) > 0 | |
| # -- Template matching ------------------------------------------------- | |
| def test_chatbot_matches_chatbot_template(self): | |
| plan = self.planner.analyze("Build a chatbot", "auto") | |
| assert plan["template_key"] == "chatbot" | |
| def test_summarizer_matches_template(self): | |
| plan = self.planner.analyze("Build a text summarization tool", "auto") | |
| assert plan["template_key"] == "text_summarizer" | |
| def test_sentiment_matches_template(self): | |
| plan = self.planner.analyze("Create a sentiment analysis app", "auto") | |
| assert plan["template_key"] == "sentiment_analyzer" | |
| def test_portfolio_matches_template(self): | |
| plan = self.planner.analyze("Create a portfolio website to showcase my work", "auto") | |
| assert plan["template_key"] == "portfolio" | |
| def test_rest_api_matches_template(self): | |
| plan = self.planner.analyze("Build a REST API server with FastAPI", "auto") | |
| assert plan["template_key"] == "rest_api" | |
| # =================================================================== | |
| # 2. ModelRecommender | |
| # =================================================================== | |
| class TestModelRecommender: | |
| """Tests for ModelRecommender.""" | |
| def setup_method(self): | |
| self.recommender = ModelRecommender() | |
| def _make_plan(self, task: str) -> dict: | |
| return {"model_task": task} | |
| # -- Recommendations return valid model IDs ---------------------------- | |
| def test_recommend_returns_list(self): | |
| models = self.recommender.recommend(self._make_plan("text-generation")) | |
| assert isinstance(models, list) | |
| assert len(models) > 0 | |
| def test_recommended_models_have_id(self): | |
| models = self.recommender.recommend(self._make_plan("text-generation")) | |
| for m in models: | |
| assert "id" in m | |
| assert isinstance(m["id"], str) | |
| assert len(m["id"]) > 0 | |
| def test_recommended_models_have_required_fields(self): | |
| models = self.recommender.recommend(self._make_plan("summarization")) | |
| for m in models: | |
| assert "id" in m | |
| assert "desc" in m | |
| assert "size" in m | |
| assert "gpu_recommended" in m | |
| # -- Different task types produce different models --------------------- | |
| def test_different_tasks_produce_different_models(self): | |
| text_gen = self.recommender.recommend(self._make_plan("text-generation")) | |
| img_cls = self.recommender.recommend(self._make_plan("image-classification")) | |
| assert text_gen[0]["id"] != img_cls[0]["id"] | |
| def test_summarization_vs_translation(self): | |
| summ = self.recommender.recommend(self._make_plan("summarization")) | |
| trans = self.recommender.recommend(self._make_plan("translation")) | |
| assert summ[0]["id"] != trans[0]["id"] | |
| # -- Model size filtering (small/medium/large) ------------------------- | |
| def test_small_size_returns_models(self): | |
| models = self.recommender.recommend( | |
| self._make_plan("text-generation"), model_size="small" | |
| ) | |
| assert len(models) > 0 | |
| def test_large_size_returns_models(self): | |
| models = self.recommender.recommend( | |
| self._make_plan("text-generation"), model_size="large" | |
| ) | |
| assert len(models) > 0 | |
| def test_small_and_large_return_different_models(self): | |
| small = self.recommender.recommend( | |
| self._make_plan("text-generation"), model_size="small" | |
| ) | |
| large = self.recommender.recommend( | |
| self._make_plan("text-generation"), model_size="large" | |
| ) | |
| assert small[0]["id"] != large[0]["id"] | |
| def test_invalid_size_falls_back_to_medium(self): | |
| models = self.recommender.recommend( | |
| self._make_plan("text-generation"), model_size="xxl" | |
| ) | |
| medium = self.recommender.recommend( | |
| self._make_plan("text-generation"), model_size="medium" | |
| ) | |
| # Invalid size normalises to medium, so results should match | |
| assert models[0]["id"] == medium[0]["id"] | |
| def test_large_models_recommend_gpu(self): | |
| models = self.recommender.recommend( | |
| self._make_plan("text-generation"), model_size="large" | |
| ) | |
| assert models[0]["gpu_recommended"] is True | |
| def test_no_task_returns_empty(self): | |
| models = self.recommender.recommend({"model_task": None}) | |
| assert models == [] | |
| # -- get_primary_model ------------------------------------------------- | |
| def test_get_primary_model_returns_string(self): | |
| model_id = self.recommender.get_primary_model( | |
| self._make_plan("text-generation") | |
| ) | |
| assert isinstance(model_id, str) | |
| assert "/" in model_id or model_id.startswith("models?") | |
| def test_get_primary_model_none_for_missing_task(self): | |
| model_id = self.recommender.get_primary_model({"model_task": None}) | |
| assert model_id is None | |
| # -- All catalog tasks have all sizes ---------------------------------- | |
| def test_catalog_task_has_all_sizes(self, task): | |
| for size in ("small", "medium", "large"): | |
| assert size in MODEL_CATALOG[task], ( | |
| f"MODEL_CATALOG['{task}'] missing size '{size}'" | |
| ) | |
| # =================================================================== | |
| # 3. RepoGenerator | |
| # =================================================================== | |
| class TestRepoGenerator: | |
| """Tests for RepoGenerator (uses template fallbacks, no API).""" | |
| def setup_method(self): | |
| self.generator = RepoGenerator() | |
| def _make_plan(self, sdk, template_key=None, **overrides): | |
| plan = { | |
| "sdk": sdk, | |
| "app_name": "test-app", | |
| "app_type": template_key or "custom", | |
| "title": "Test App", | |
| "description": "A test application", | |
| "components": ["text_input", "text_output"], | |
| "model_task": "text-generation", | |
| "template_key": template_key, | |
| "original_prompt": "build a test app", | |
| "recommended_models": [ | |
| {"id": "Qwen/Qwen2.5-7B-Instruct", "desc": "Test", "size": "7B"} | |
| ], | |
| "extra_features": [], | |
| } | |
| plan.update(overrides) | |
| return plan | |
| # -- Gradio repo generation ------------------------------------------- | |
| def test_gradio_repo_has_app_py(self): | |
| plan = self._make_plan("gradio", "chatbot") | |
| files = self.generator.generate(plan, "build a chatbot") | |
| assert "app.py" in files | |
| def test_gradio_repo_has_requirements_txt(self): | |
| plan = self._make_plan("gradio", "chatbot") | |
| files = self.generator.generate(plan, "build a chatbot") | |
| assert "requirements.txt" in files | |
| assert "gradio" in files["requirements.txt"] | |
| def test_gradio_repo_has_readme(self): | |
| plan = self._make_plan("gradio", "chatbot") | |
| files = self.generator.generate(plan, "build a chatbot") | |
| assert "README.md" in files | |
| def test_gradio_repo_has_gitignore(self): | |
| plan = self._make_plan("gradio", "chatbot") | |
| files = self.generator.generate(plan, "build a chatbot") | |
| assert ".gitignore" in files | |
| # -- Docker repo generation ------------------------------------------- | |
| def test_docker_repo_has_dockerfile(self): | |
| plan = self._make_plan("docker", "rest_api") | |
| files = self.generator.generate(plan, "build a rest api") | |
| assert "Dockerfile" in files | |
| def test_docker_repo_has_app_py(self): | |
| plan = self._make_plan("docker", "rest_api") | |
| files = self.generator.generate(plan, "build a rest api") | |
| assert "app.py" in files | |
| def test_docker_repo_has_requirements(self): | |
| plan = self._make_plan("docker", "rest_api") | |
| files = self.generator.generate(plan, "build a rest api") | |
| assert "requirements.txt" in files | |
| def test_docker_repo_has_readme(self): | |
| plan = self._make_plan("docker", "rest_api") | |
| files = self.generator.generate(plan, "build a rest api") | |
| assert "README.md" in files | |
| # -- Static repo generation ------------------------------------------- | |
| def test_static_repo_has_index_html(self): | |
| plan = self._make_plan("static", "portfolio", model_task=None) | |
| files = self.generator.generate(plan, "build a portfolio site") | |
| assert "index.html" in files | |
| def test_static_repo_has_style_css(self): | |
| plan = self._make_plan("static", "portfolio", model_task=None) | |
| files = self.generator.generate(plan, "build a portfolio site") | |
| assert "style.css" in files | |
| def test_static_repo_has_readme(self): | |
| plan = self._make_plan("static", "portfolio", model_task=None) | |
| files = self.generator.generate(plan, "build a portfolio site") | |
| assert "README.md" in files | |
| def test_static_index_contains_title(self): | |
| plan = self._make_plan("static", "portfolio", title="My Portfolio", model_task=None) | |
| files = self.generator.generate(plan, "portfolio site") | |
| assert "My Portfolio" in files["index.html"] | |
| # -- Gradio requirements include task-specific deps -------------------- | |
| def test_gradio_image_task_includes_pillow(self): | |
| plan = self._make_plan("gradio", "image_classifier", model_task="image-classification") | |
| files = self.generator.generate(plan, "image classifier") | |
| assert "Pillow" in files["requirements.txt"] | |
| def test_gradio_chart_component_includes_matplotlib(self): | |
| plan = self._make_plan( | |
| "gradio", "sentiment_analyzer", | |
| model_task="text-classification", | |
| components=["text_input", "chart_output"], | |
| ) | |
| files = self.generator.generate(plan, "sentiment dashboard") | |
| assert "matplotlib" in files["requirements.txt"] | |
| # =================================================================== | |
| # 4. GradioGenerator | |
| # =================================================================== | |
| class TestGradioGenerator: | |
| """Tests for GradioGenerator (template fallback, no LLM calls).""" | |
| def setup_method(self): | |
| self.gen = GradioGenerator() | |
| def _make_plan(self, template_key, model_task="text-generation"): | |
| return { | |
| "template_key": template_key, | |
| "title": "Test App", | |
| "description": "A test", | |
| "model_task": model_task, | |
| "components": [], | |
| "recommended_models": [ | |
| {"id": "Qwen/Qwen2.5-7B-Instruct", "desc": "Test", "size": "7B"} | |
| ], | |
| "extra_features": [], | |
| } | |
| def test_generate_produces_valid_python_chatbot(self): | |
| plan = self._make_plan("chatbot") | |
| code = self.gen.generate(plan, "build a chatbot") | |
| # Must parse without syntax errors | |
| ast.parse(code) | |
| def test_generate_produces_valid_python_image_classifier(self): | |
| plan = self._make_plan("image_classifier", "image-classification") | |
| code = self.gen.generate(plan, "image classifier") | |
| ast.parse(code) | |
| def test_generate_produces_valid_python_summarizer(self): | |
| plan = self._make_plan("text_summarizer", "summarization") | |
| code = self.gen.generate(plan, "text summarizer") | |
| ast.parse(code) | |
| def test_generate_produces_valid_python_sentiment(self): | |
| plan = self._make_plan("sentiment_analyzer", "text-classification") | |
| code = self.gen.generate(plan, "sentiment tool") | |
| ast.parse(code) | |
| def test_generate_produces_valid_python_translator(self): | |
| plan = self._make_plan("translator", "translation") | |
| code = self.gen.generate(plan, "translator") | |
| ast.parse(code) | |
| def test_generate_produces_valid_python_qa(self): | |
| plan = self._make_plan("question_answering", "question-answering") | |
| code = self.gen.generate(plan, "question answering") | |
| ast.parse(code) | |
| def test_generate_produces_valid_python_text_gen(self): | |
| plan = self._make_plan("text_generator") | |
| code = self.gen.generate(plan, "text generator") | |
| ast.parse(code) | |
| # -- Fallback templates work without API ------------------------------- | |
| def test_fallback_template_produces_valid_python(self, key): | |
| """Every built-in Gradio template must produce parseable Python.""" | |
| code = GRADIO_TEMPLATES[key].format( | |
| model_id="test/model", | |
| title="Test", | |
| description="Test description", | |
| ) | |
| ast.parse(code) | |
| def test_generic_fallback_for_unknown_template(self): | |
| plan = self._make_plan(None) | |
| code = self.gen.generate(plan, "something unknown") | |
| ast.parse(code) | |
| def test_generated_code_contains_gradio_import(self): | |
| plan = self._make_plan("chatbot") | |
| code = self.gen.generate(plan, "chatbot") | |
| assert "import gradio" in code | |
| def test_generated_code_contains_launch(self): | |
| plan = self._make_plan("chatbot") | |
| code = self.gen.generate(plan, "chatbot") | |
| assert "demo.launch()" in code | |
| # -- _extract_code helper ---------------------------------------------- | |
| def test_extract_code_from_markdown_block(self): | |
| raw = "Here is the code:\n```python\nimport gradio as gr\nprint('hi')\n```\nDone." | |
| code = self.gen._extract_code(raw) | |
| assert "import gradio" in code | |
| assert "```" not in code | |
| def test_extract_code_plain_python(self): | |
| raw = "import gradio as gr\nprint('hi')" | |
| code = self.gen._extract_code(raw) | |
| assert code == raw | |
| # =================================================================== | |
| # 5. DockerGenerator | |
| # =================================================================== | |
| class TestDockerGenerator: | |
| """Tests for DockerGenerator (template fallback, no LLM calls).""" | |
| def setup_method(self): | |
| self.gen = DockerGenerator() | |
| def _make_plan(self, template_key="rest_api"): | |
| return { | |
| "template_key": template_key, | |
| "title": "Test API", | |
| "description": "A test API service", | |
| "model_task": "text-generation", | |
| "components": ["fastapi_app", "model_endpoint"], | |
| "recommended_models": [ | |
| {"id": "Qwen/Qwen2.5-7B-Instruct", "desc": "Test", "size": "7B"} | |
| ], | |
| "extra_features": [], | |
| } | |
| def test_generate_produces_dockerfile(self): | |
| files = self.gen.generate(self._make_plan(), "build an api") | |
| assert "Dockerfile" in files | |
| def test_generate_produces_app_py(self): | |
| files = self.gen.generate(self._make_plan(), "build an api") | |
| assert "app.py" in files | |
| def test_generate_produces_requirements(self): | |
| files = self.gen.generate(self._make_plan(), "build an api") | |
| assert "requirements.txt" in files | |
| def test_app_py_is_valid_python(self): | |
| files = self.gen.generate(self._make_plan(), "build an api") | |
| ast.parse(files["app.py"]) | |
| def test_dockerfile_has_from(self): | |
| files = self.gen.generate(self._make_plan(), "build an api") | |
| assert "FROM" in files["Dockerfile"] | |
| def test_dockerfile_exposes_7860(self): | |
| files = self.gen.generate(self._make_plan(), "build an api") | |
| assert "7860" in files["Dockerfile"] | |
| def test_dockerfile_has_cmd(self): | |
| files = self.gen.generate(self._make_plan(), "build an api") | |
| assert "CMD" in files["Dockerfile"] | |
| def test_generic_docker_fallback(self): | |
| plan = self._make_plan("unknown_template_key") | |
| files = self.gen.generate(plan, "build something") | |
| assert "app.py" in files | |
| assert "Dockerfile" in files | |
| ast.parse(files["app.py"]) | |
| # -- All docker templates produce valid Python ------------------------- | |
| def test_docker_template_app_py_is_valid_python(self, template_name): | |
| template = DOCKER_TEMPLATES[template_name] | |
| code = template["app.py"].format( | |
| model_id="test/model", | |
| title="Test", | |
| description="Test desc", | |
| ) | |
| ast.parse(code) | |
| # -- _parse_files helper ----------------------------------------------- | |
| def test_parse_files_marker_format(self): | |
| text = ( | |
| "=== FILENAME: app.py ===\nprint('hello')\n" | |
| "=== FILENAME: requirements.txt ===\nfastapi\n" | |
| ) | |
| files = self.gen._parse_files(text) | |
| assert "app.py" in files | |
| assert "requirements.txt" in files | |
| def test_parse_files_empty_returns_empty(self): | |
| files = self.gen._parse_files("no files here") | |
| assert files == {} | |
| # =================================================================== | |
| # 6. ReadmeGenerator | |
| # =================================================================== | |
| class TestReadmeGenerator: | |
| """Tests for ReadmeGenerator.""" | |
| def setup_method(self): | |
| self.gen = ReadmeGenerator() | |
| def _make_plan(self, app_type="chatbot", app_name="test-chatbot"): | |
| return { | |
| "app_type": app_type, | |
| "title": "Test Chatbot", | |
| "description": "A test chatbot application", | |
| "app_name": app_name, | |
| "recommended_models": [ | |
| {"id": "Qwen/Qwen2.5-7B-Instruct", "desc": "Test", "size": "7B"} | |
| ], | |
| "components": ["chat_interface", "system_prompt_config"], | |
| } | |
| # -- YAML frontmatter -------------------------------------------------- | |
| def test_readme_has_yaml_frontmatter(self): | |
| readme = self.gen.generate(self._make_plan(), "gradio") | |
| assert readme.startswith("---") | |
| # Should have opening and closing --- | |
| parts = readme.split("---") | |
| assert len(parts) >= 3 # before, frontmatter, after | |
| def test_frontmatter_contains_sdk_gradio(self): | |
| readme = self.gen.generate(self._make_plan(), "gradio") | |
| frontmatter = readme.split("---")[1] | |
| assert "sdk: gradio" in frontmatter | |
| def test_frontmatter_contains_sdk_docker(self): | |
| readme = self.gen.generate(self._make_plan("rest_api"), "docker") | |
| frontmatter = readme.split("---")[1] | |
| assert "sdk: docker" in frontmatter | |
| def test_frontmatter_contains_sdk_static(self): | |
| readme = self.gen.generate(self._make_plan("portfolio"), "static") | |
| frontmatter = readme.split("---")[1] | |
| assert "sdk: static" in frontmatter | |
| def test_frontmatter_contains_title(self): | |
| readme = self.gen.generate(self._make_plan(), "gradio") | |
| frontmatter = readme.split("---")[1] | |
| assert "Test Chatbot" in frontmatter | |
| # -- README body ------------------------------------------------------- | |
| def test_readme_contains_app_name_in_body(self): | |
| plan = self._make_plan(app_name="my-awesome-chatbot") | |
| plan["title"] = "My Awesome Chatbot" | |
| readme = self.gen.generate(plan, "gradio") | |
| # The title (which comes from the plan) should appear in the body | |
| assert "My Awesome Chatbot" in readme | |
| def test_readme_contains_description(self): | |
| readme = self.gen.generate(self._make_plan(), "gradio") | |
| assert "A test chatbot application" in readme | |
| def test_readme_contains_features_section(self): | |
| readme = self.gen.generate(self._make_plan(), "gradio") | |
| assert "## Features" in readme | |
| def test_readme_contains_model_reference(self): | |
| readme = self.gen.generate(self._make_plan(), "gradio") | |
| assert "Qwen/Qwen2.5-7B-Instruct" in readme | |
| def test_readme_contains_tech_stack(self): | |
| readme = self.gen.generate(self._make_plan(), "gradio") | |
| assert "## Tech Stack" in readme | |
| def test_docker_readme_mentions_fastapi(self): | |
| readme = self.gen.generate(self._make_plan("rest_api"), "docker") | |
| assert "FastAPI" in readme | |
| def test_static_readme_mentions_html(self): | |
| readme = self.gen.generate(self._make_plan("portfolio"), "static") | |
| assert "HTML" in readme | |
| # =================================================================== | |
| # 7. CodeChecker | |
| # =================================================================== | |
| class TestCodeChecker: | |
| """Tests for CodeChecker.""" | |
| def setup_method(self): | |
| self.checker = CodeChecker() | |
| # -- Python syntax validation ------------------------------------------ | |
| def test_valid_python_passes(self): | |
| files = {"app.py": "import os\nprint('hello')\n"} | |
| result = self.checker.check(files, "gradio") | |
| # Should have no python-specific errors (cross-file check may warn | |
| # about missing requirements.txt etc.) | |
| py_check = result["file_checks"]["app.py"] | |
| assert py_check["valid"] is True | |
| assert len(py_check["errors"]) == 0 | |
| def test_invalid_python_syntax_caught(self): | |
| files = {"app.py": "def foo(\n pass\n"} | |
| result = self.checker.check(files, "gradio") | |
| py_check = result["file_checks"]["app.py"] | |
| assert py_check["valid"] is False | |
| assert any("syntax error" in e.lower() for e in py_check["errors"]) | |
| def test_empty_file_flagged(self): | |
| files = {"app.py": ""} | |
| result = self.checker.check(files, "gradio") | |
| py_check = result["file_checks"]["app.py"] | |
| assert py_check["valid"] is False | |
| def test_dangerous_pattern_warned(self): | |
| files = {"app.py": "import os\nos.system('rm -rf /')\n"} | |
| result = self.checker.check(files, "gradio") | |
| py_check = result["file_checks"]["app.py"] | |
| assert any("os.system" in w for w in py_check["warnings"]) | |
| def test_eval_warned(self): | |
| files = {"app.py": "x = eval('1+2')\n"} | |
| result = self.checker.check(files, "gradio") | |
| py_check = result["file_checks"]["app.py"] | |
| assert any("eval" in w for w in py_check["warnings"]) | |
| # -- Dockerfile validation --------------------------------------------- | |
| def test_valid_dockerfile_passes(self): | |
| dockerfile = ( | |
| "FROM python:3.11-slim\n" | |
| "WORKDIR /app\n" | |
| "COPY . .\n" | |
| "EXPOSE 7860\n" | |
| "CMD [\"python\", \"app.py\"]\n" | |
| ) | |
| files = {"Dockerfile": dockerfile, "README.md": "---\ntest\n---\n"} | |
| result = self.checker.check(files, "docker") | |
| df_check = result["file_checks"]["Dockerfile"] | |
| assert len(df_check["errors"]) == 0 | |
| def test_dockerfile_missing_from_is_error(self): | |
| files = {"Dockerfile": "COPY . .\nCMD ['python']\n"} | |
| result = self.checker.check(files, "docker") | |
| df_check = result["file_checks"]["Dockerfile"] | |
| assert any("FROM" in e for e in df_check["errors"]) | |
| def test_dockerfile_missing_cmd_is_error(self): | |
| files = {"Dockerfile": "FROM python:3.11\nCOPY . .\n"} | |
| result = self.checker.check(files, "docker") | |
| df_check = result["file_checks"]["Dockerfile"] | |
| assert any("CMD" in e or "ENTRYPOINT" in e for e in df_check["errors"]) | |
| def test_dockerfile_missing_expose_is_warning(self): | |
| files = {"Dockerfile": "FROM python:3.11\nCMD ['python']\n"} | |
| result = self.checker.check(files, "docker") | |
| df_check = result["file_checks"]["Dockerfile"] | |
| assert any("EXPOSE" in w for w in df_check["warnings"]) | |
| # -- Cross-file checks ------------------------------------------------- | |
| def test_gradio_missing_app_py_is_error(self): | |
| files = {"README.md": "---\ntest\n---\n", "requirements.txt": "gradio\n"} | |
| result = self.checker.check(files, "gradio") | |
| assert result["valid"] is False | |
| assert any("app.py" in e for e in result["errors"]) | |
| def test_docker_missing_dockerfile_is_error(self): | |
| files = {"app.py": "print('hi')\n", "README.md": "---\ntest\n---\n"} | |
| result = self.checker.check(files, "docker") | |
| assert any("Dockerfile" in e for e in result["errors"]) | |
| def test_static_missing_index_html_is_error(self): | |
| files = {"README.md": "---\ntest\n---\n", "style.css": "body{}\n"} | |
| result = self.checker.check(files, "static") | |
| assert any("index.html" in e for e in result["errors"]) | |
| def test_overall_valid_when_no_errors(self): | |
| files = { | |
| "app.py": "import gradio as gr\nprint('hi')\n", | |
| "requirements.txt": "gradio>=5.0\n", | |
| "README.md": "---\nsdk: gradio\n---\n# App\n", | |
| } | |
| result = self.checker.check(files, "gradio") | |
| assert result["valid"] is True | |
| # -- HTML validation --------------------------------------------------- | |
| def test_valid_html_passes(self): | |
| html = "<!DOCTYPE html><html><head></head><body></body></html>" | |
| files = {"index.html": html} | |
| result = self.checker.check(files, "static") | |
| html_check = result["file_checks"]["index.html"] | |
| assert len(html_check["errors"]) == 0 | |
| def test_html_missing_tags_warned(self): | |
| files = {"index.html": "<div>Hello</div>"} | |
| result = self.checker.check(files, "static") | |
| html_check = result["file_checks"]["index.html"] | |
| assert len(html_check["warnings"]) > 0 | |
| # -- README validation ------------------------------------------------- | |
| def test_readme_missing_frontmatter_warned(self): | |
| files = {"README.md": "# My App\nNo frontmatter here.\n"} | |
| result = self.checker.check(files, "gradio") | |
| readme_check = result["file_checks"]["README.md"] | |
| assert any("frontmatter" in w.lower() for w in readme_check["warnings"]) | |
| # =================================================================== | |
| # 8. FastAPI App Integration Tests | |
| # =================================================================== | |
| class TestFastAPIApp: | |
| """Integration tests for the FastAPI app endpoints.""" | |
| def test_home_returns_200(self, client): | |
| resp = client.get("/") | |
| assert resp.status_code == 200 | |
| assert "text/html" in resp.headers["content-type"] | |
| def test_home_contains_title(self, client): | |
| resp = client.get("/") | |
| assert "AutoApp" in resp.text or "autoapp" in resp.text.lower() or "<html" in resp.text.lower() | |
| def test_post_generate_returns_html(self, client): | |
| """POST /generate with a chatbot prompt should return 200 HTML.""" | |
| resp = client.post( | |
| "/generate", | |
| data={ | |
| "prompt": "Build a chatbot that answers questions about science", | |
| "sdk_preference": "auto", | |
| "model_size": "medium", | |
| "gpu_needed": "false", | |
| "features": "", | |
| }, | |
| ) | |
| assert resp.status_code == 200 | |
| assert "text/html" in resp.headers["content-type"] | |
| def test_post_generate_contains_file_content(self, client): | |
| """The generated result page should contain references to generated files.""" | |
| resp = client.post( | |
| "/generate", | |
| data={ | |
| "prompt": "Build a simple text summarizer", | |
| "sdk_preference": "auto", | |
| "model_size": "small", | |
| "gpu_needed": "false", | |
| "features": "", | |
| }, | |
| ) | |
| assert resp.status_code == 200 | |
| # The result page should mention file names like app.py or README.md | |
| body = resp.text.lower() | |
| assert "app.py" in body or "readme" in body | |
| def test_download_nonexistent_project_returns_404(self, client): | |
| resp = client.get("/download/nonexistent") | |
| assert resp.status_code == 404 | |
| def test_get_file_nonexistent_project_returns_404(self, client): | |
| resp = client.get("/api/file/nonexistent/app.py") | |
| assert resp.status_code == 404 | |
| def test_edit_nonexistent_project_returns_404(self, client): | |
| resp = client.post( | |
| "/edit", | |
| data={"project_id": "nonexistent", "edit_prompt": "change colour"}, | |
| ) | |
| assert resp.status_code == 404 | |
| # =================================================================== | |
| # 9. End-to-end generation (all SDK types, offline) | |
| # =================================================================== | |
| class TestEndToEnd: | |
| """End-to-end tests exercising the full planner -> recommender -> generator | |
| -> checker pipeline offline (no LLM calls).""" | |
| def setup_method(self): | |
| self.planner = AppPlanner() | |
| self.recommender = ModelRecommender() | |
| self.generator = RepoGenerator() | |
| self.checker = CodeChecker() | |
| def _run_pipeline(self, prompt, sdk_pref="auto", model_size="medium"): | |
| plan = self.planner.analyze(prompt, sdk_pref) | |
| models = self.recommender.recommend(plan, model_size) | |
| plan["recommended_models"] = models | |
| plan["extra_features"] = [] | |
| files = self.generator.generate(plan, prompt) | |
| validation = self.checker.check(files, plan["sdk"]) | |
| return plan, files, validation | |
| def test_chatbot_e2e(self): | |
| plan, files, validation = self._run_pipeline("Build a chatbot") | |
| assert plan["sdk"] == "gradio" | |
| assert "app.py" in files | |
| assert validation["valid"] is True | |
| def test_image_classifier_e2e(self): | |
| plan, files, validation = self._run_pipeline( | |
| "Build a Gradio image classifier" | |
| ) | |
| assert plan["sdk"] == "gradio" | |
| assert "app.py" in files | |
| ast.parse(files["app.py"]) | |
| def test_rest_api_e2e(self): | |
| plan, files, validation = self._run_pipeline( | |
| "Build a REST API with FastAPI endpoints" | |
| ) | |
| assert plan["sdk"] == "docker" | |
| assert "Dockerfile" in files | |
| assert "app.py" in files | |
| ast.parse(files["app.py"]) | |
| def test_portfolio_e2e(self): | |
| plan, files, validation = self._run_pipeline( | |
| "Create a portfolio website to showcase projects" | |
| ) | |
| assert plan["sdk"] == "static" | |
| assert "index.html" in files | |
| def test_summarizer_small_model_e2e(self): | |
| plan, files, validation = self._run_pipeline( | |
| "Build a text summarization tool", model_size="small" | |
| ) | |
| assert plan["sdk"] == "gradio" | |
| assert "app.py" in files | |
| assert validation["valid"] is True | |