Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/1_tests.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
name: Tests

on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
workflow_call:

permissions: {} # deny all by default

Expand Down
30 changes: 17 additions & 13 deletions .github/workflows/2_release.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
name: Release Python Package and Docker Image

on:
workflow_call:
inputs:
publish:
description: 'Whether to publish the Python package and Docker image (true/false)'
required: false
default: 'false'
type: string
workflow_dispatch:
inputs:
publish:
Expand All @@ -25,18 +32,6 @@ jobs:
run: echo "$GITHUB_CONTEXT"
- id: lower
run: echo "github_repository_lowercase=${GITHUB_REPOSITORY,,}" >> $GITHUB_OUTPUT

build-python-package:
needs: params
uses: ./.github/workflows/2.1_build-python-package.yml
permissions:
id-token: write
contents: read
with:
publish: ${{ github.event.inputs.publish }}
upload-github-release: 'false'
secrets: inherit

build-docker-image:
needs: [params, build-python-package]
uses: ./.github/workflows/2.2_build-docker-image.yml
Expand All @@ -50,7 +45,16 @@ jobs:
dockerfile: Dockerfile
push: ${{ github.event.inputs.publish }}
secrets: inherit

build-python-package:
needs: params
uses: ./.github/workflows/2.1_build-python-package.yml
permissions:
id-token: write
contents: read
with:
publish: ${{ github.event.inputs.publish }}
upload-github-release: 'false'
secrets: inherit
create-github-release:
needs: [params, build-python-package, build-docker-image]
if: ${{ github.event.inputs.publish == 'true' && needs.build-python-package.result == 'success' && needs.build-docker-image.result == 'success' }}
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/3_ci-cd.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI/CD

on:
push:
branches:
- main

permissions: {}

jobs:
tests:
name: Run full test suite
uses: ./.github/workflows/1_tests.yml
permissions:
contents: read

release:
name: Release Python Package and Docker Image
needs: tests
if: needs.tests.result == 'success'
uses: ./.github/workflows/2_release.yml
with:
publish: 'true' # change to 'false' if you only want to build without publishing
secrets: inherit
10 changes: 10 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## 📂 Workflow Overview

This repository uses modular, clearly named workflows for CI, integration tests, packaging, Docker, and releases.
A top-level **`0_ci-cd.yaml`** orchestrates the process for pushes to `main`, running **Tests** first and triggering the **Release** workflow only if they pass.

1. 🧪 **Tests** — lint, types checks, unit, integration and e2e tests.
- 🧩 **Unit Tests** — Run unit tests
Expand All @@ -12,6 +13,7 @@ This repository uses modular, clearly named workflows for CI, integration tests,
- 📦 **Build & Publish Python Package** — Build and (optionally) publish the Python package
- 🐋 **Build & Push Docker Image** — Build and (optionally) push the Docker image
- 📝 **Create GitHub Release Only** — Create a GitHub Release from already published artifacts
3. 🔄 **CI/CD Orchestration** (`3_ci-cd.yaml`) — Runs Tests → Release when pushing to `main`.

## ⚡ Quick Start

Expand Down Expand Up @@ -101,6 +103,14 @@ Create a GitHub Release from already published artifacts:
act -W .github/workflows/2.3_create-github-release.yml -P ubuntu-latest=catthehacker/ubuntu:act-latest --rm
```

## 3. 🔄 CI/CD Orchestration

Run the combined CI + Release process (push to main simulation):

```sh
act -W .github/workflows/3_ci-cd.yaml -P ubuntu-latest=catthehacker/ubuntu:act-latest --rm
```

## 💡 Notes

- 🐳 You need Docker running.
Expand Down
1 change: 1 addition & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
package_name: mcp-selenium-grid # pyproject.toml
project_name: MCP Selenium Grid
deployment_mode: docker # one of: docker, kubernetes (DeploymentMode enum values)
api_v1_str: /api/v1
Expand Down
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

[project]
name = "mcp-selenium-grid"
version = "0.1.0.dev5"
version = "0.1.0.dev6"
description = "MCP Server for managing Selenium Grid"
readme = "README.md"
license = { file = "LICENSE" }
Expand Down Expand Up @@ -58,6 +58,7 @@ test = [
"pytest-asyncio>=1.0.0", # Parallel test execution
"pytest-sugar>=1.0.0",
"coverage>=7.10.2",
"pytest-timeout>=2.4.0",
]

[build-system]
Expand Down
55 changes: 0 additions & 55 deletions src/app/common/toml.py

This file was deleted.

23 changes: 13 additions & 10 deletions src/app/core/settings.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,29 @@
"""Core settings for MCP Server."""

from pydantic import Field, SecretStr, field_validator
from importlib.metadata import metadata, version

from pydantic import Field, SecretStr

from app.common.toml import load_value_from_toml
from app.services.selenium_hub.models.general_settings import SeleniumHubGeneralSettings


class Settings(SeleniumHubGeneralSettings):
"""MCP Server settings."""

# API Settings
# Project Settings
PACKAGE_NAME: str = "mcp-selenium-grid"
PROJECT_NAME: str = "MCP Selenium Grid"
VERSION: str = ""

@field_validator("VERSION", mode="before")
@classmethod
def load_version_from_pyproject(cls, v: str) -> str:
return v or load_value_from_toml(["project", "version"])
@property
def VERSION(self) -> str:
return version(self.PACKAGE_NAME)

API_V1_STR: str = "/api/v1"
@property
def DESCRIPTION(self) -> str:
return metadata(self.PACKAGE_NAME).get("Summary", "").strip()

# API Token
# API Settings
API_V1_STR: str = "/api/v1"
API_TOKEN: SecretStr = SecretStr("CHANGE_ME")

# Security Settings
Expand Down
27 changes: 12 additions & 15 deletions src/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,12 @@

from app.common.fastapi_mcp import handle_fastapi_request
from app.common.logger import logger
from app.common.toml import load_value_from_toml
from app.dependencies import get_settings, verify_token
from app.models import HealthCheckResponse, HealthStatus, HubStatusResponse
from app.routers.browsers import router as browsers_router
from app.routers.selenium_proxy import router as selenium_proxy_router
from app.services.selenium_hub import SeleniumHub

DESCRIPTION = load_value_from_toml(["project", "description"])
SETTINGS = get_settings()
MCP_HTTP_PATH = "/mcp"
MCP_SSE_PATH = "/sse"
Expand Down Expand Up @@ -65,7 +63,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, Any]:
app = FastAPI(
title=SETTINGS.PROJECT_NAME,
version=SETTINGS.VERSION,
description=DESCRIPTION,
description=SETTINGS.DESCRIPTION,
lifespan=lifespan,
)

Expand Down Expand Up @@ -119,15 +117,14 @@ async def get_hub_stats(
# Get app_state.browsers_instances using lock to ensure thread safety
app_state = request.app.state
async with app_state.browsers_instances_lock:
browsers = [browser.model_dump() for browser in app_state.browsers_instances.values()]

return HubStatusResponse(
hub_running=is_running,
hub_healthy=is_healthy,
deployment_mode=SETTINGS.DEPLOYMENT_MODE,
max_instances=SETTINGS.selenium_grid.MAX_BROWSER_INSTANCES,
browsers=browsers,
)
return HubStatusResponse(
hub_running=is_running,
hub_healthy=is_healthy,
deployment_mode=SETTINGS.DEPLOYMENT_MODE,
max_instances=SETTINGS.selenium_grid.MAX_BROWSER_INSTANCES,
browsers=app_state.browsers_instances,
webdriver_remote_url=hub.WEBDRIVER_REMOTE_URL,
)

# Include browser management endpoints
app.include_router(browsers_router, prefix=SETTINGS.API_V1_STR)
Expand All @@ -137,8 +134,8 @@ async def get_hub_stats(
# --- MCP Integration ---
mcp = FastApiMCP(
app,
name="MCP Selenium Grid",
description=DESCRIPTION,
name=SETTINGS.PROJECT_NAME,
description=SETTINGS.DESCRIPTION,
describe_full_response_schema=True,
describe_all_responses=True,
auth_config=AuthConfig(
Expand All @@ -148,7 +145,7 @@ async def get_hub_stats(
mcp.mount_http(mount_path=MCP_HTTP_PATH)
mcp.mount_sse(mount_path=MCP_SSE_PATH)

@app.api_route("/", methods=["GET", "POST"])
@app.api_route("/", methods=["GET", "POST"], include_in_schema=False)
async def root_proxy(
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(verify_token),
Expand Down
7 changes: 5 additions & 2 deletions src/app/models.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Response models for MCP Server."""

from enum import Enum
from typing import Any

from pydantic import BaseModel, Field

from app.services.selenium_hub.models import DeploymentMode
from app.services.selenium_hub.models.browser import BrowserInstance


class HealthStatus(str, Enum):
Expand Down Expand Up @@ -38,4 +38,7 @@ class HubStatusResponse(BaseModel):
examples=[DeploymentMode.DOCKER, DeploymentMode.KUBERNETES],
)
max_instances: int = Field(description="Maximum allowed browser instances")
browsers: list[dict[str, Any]] = Field(description="List of current browser instances")
browsers: dict[str, BrowserInstance] = Field(
description="Dict of current browser instances with id as dict key"
)
webdriver_remote_url: str = Field(description="URL to connect to the Grid's Hub or Router")
11 changes: 11 additions & 0 deletions src/app/services/selenium_hub/_selenium_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import asyncio
from urllib.parse import urljoin

from app.services.metrics import track_browser_metrics, track_hub_metrics # TODO: refactor and test

Expand Down Expand Up @@ -97,6 +98,16 @@ def URL(self) -> str:
"""
return self._manager.URL

@property
def WEBDRIVER_REMOTE_URL(self) -> str:
"""
Get the URL to connect to the Grid's Hub or Router

Returns:
str: The URL to Remote WebDriver
"""
return urljoin(self.URL, "/wd/hub")

@track_hub_metrics()
async def check_hub_health(self) -> bool:
"""
Expand Down
14 changes: 13 additions & 1 deletion src/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Pytest configuration file."""

from typing import Any, Generator, cast
from shutil import which
from typing import Any, Callable, Generator, cast
from unittest.mock import MagicMock

import pytest
Expand Down Expand Up @@ -248,6 +249,17 @@ def selenium_hub_basic_auth_headers() -> BasicAuth:
)


def create_cmd_fixture(name: str) -> Callable[[], str]:
@pytest.fixture(name=name + "_path")
def _fixture() -> str:
path: str | None = which(name)
if path is None:
pytest.skip(f"Executable '{name}' not found in PATH")
return path

return _fixture


# ==============================================================================
# E2E TEST FIXTURES
# ==============================================================================
Expand Down
4 changes: 3 additions & 1 deletion src/tests/e2e/test_browser_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ def test_complete_browser_lifecycle(
assert create_response.status_code == status.HTTP_201_CREATED
response_data = create_response.json()
assert "browsers" in response_data
assert isinstance(response_data["browsers"], list)
assert "hub_url" in response_data
assert response_data["status"] == BrowserResponseStatus.CREATED

Expand All @@ -56,7 +57,8 @@ def test_complete_browser_lifecycle(
# Get the value of the 'client' fixture's current parameter (DeploymentMode)
current_mode = request.node.callspec.params["client"]
assert stats_data["deployment_mode"] == current_mode.value
stats_browsers_ids = [b["id"] for b in stats_data["browsers"]]
assert isinstance(stats_data["browsers"], dict)
stats_browsers_ids = [key for key in stats_data["browsers"].keys()]
# Check if all created browsers are in stats (stats might contain more from previous runs)
for browser_id in created_browsers_ids_list:
assert browser_id in stats_browsers_ids
Expand Down
Loading