├── ui_coverage_tool ├── cli │ ├── __init__.py │ ├── commands │ │ ├── __init__.py │ │ ├── print_config.py │ │ ├── copy_report.py │ │ └── save_report.py │ └── main.py ├── src │ ├── __init__.py │ ├── tools │ │ ├── __init__.py │ │ ├── selector.py │ │ ├── types.py │ │ ├── logger.py │ │ └── actions.py │ ├── coverage │ │ ├── __init__.py │ │ ├── models.py │ │ └── builder.py │ ├── history │ │ ├── __init__.py │ │ ├── selector.py │ │ ├── models.py │ │ ├── storage.py │ │ └── builder.py │ ├── reports │ │ ├── __init__.py │ │ ├── templates │ │ │ └── __init__.py │ │ ├── models.py │ │ └── storage.py │ └── tracker │ │ ├── __init__.py │ │ ├── core.py │ │ ├── storage.py │ │ └── models.py ├── tests │ ├── __init__.py │ ├── fixtures │ │ ├── __init__.py │ │ ├── config.py │ │ ├── reports.py │ │ └── tracker.py │ └── suites │ │ ├── __init__.py │ │ └── src │ │ ├── __init__.py │ │ ├── history │ │ ├── __init__.py │ │ ├── test_selector.py │ │ ├── test_storage.py │ │ └── test_builder.py │ │ ├── reports │ │ ├── __init__.py │ │ ├── test_models.py │ │ └── test_storage.py │ │ ├── tools │ │ ├── __init__.py │ │ └── test_actions.py │ │ ├── tracker │ │ ├── __init__.py │ │ ├── test_core.py │ │ ├── test_models.py │ │ └── test_storage.py │ │ └── coverage │ │ ├── __init__.py │ │ └── test_builder.py ├── __init__.py └── config.py ├── .gitattributes ├── docs ├── screenshots │ ├── frame.png │ ├── history.png │ ├── summary.png │ ├── elements_table.png │ └── element_details.png └── configs │ ├── ui_coverage_config.json │ ├── .env.example │ └── ui_coverage_config.yaml ├── .gitmodules ├── conftest.py ├── .gitignore ├── .github └── workflows │ ├── workflow-test.yml │ ├── workflow-publish.yml │ ├── reusable-lint.yml │ ├── reusable-pypi.yml │ └── reusable-test.yml ├── pyproject.toml ├── LICENSE └── README.md /ui_coverage_tool/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/cli/commands/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/coverage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/history/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/reports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tracker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/fixtures/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/reports/templates/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/history/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/reports/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/tools/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/tracker/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/coverage/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /docs/screenshots/frame.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikita-Filonov/ui-coverage-tool/HEAD/docs/screenshots/frame.png -------------------------------------------------------------------------------- /docs/screenshots/history.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikita-Filonov/ui-coverage-tool/HEAD/docs/screenshots/history.png -------------------------------------------------------------------------------- /docs/screenshots/summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikita-Filonov/ui-coverage-tool/HEAD/docs/screenshots/summary.png -------------------------------------------------------------------------------- /docs/screenshots/elements_table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikita-Filonov/ui-coverage-tool/HEAD/docs/screenshots/elements_table.png -------------------------------------------------------------------------------- /docs/screenshots/element_details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Nikita-Filonov/ui-coverage-tool/HEAD/docs/screenshots/element_details.png -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "submodules/ui-coverage-report"] 2 | path = submodules/ui-coverage-report 3 | url = https://github.com/Nikita-Filonov/ui-coverage-report 4 | tag = v0.17.0 5 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | pytest_plugins = ( 2 | "ui_coverage_tool.tests.fixtures.config", 3 | "ui_coverage_tool.tests.fixtures.tracker", 4 | "ui_coverage_tool.tests.fixtures.reports", 5 | ) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage-results 2 | 3 | .env 4 | .idea 5 | .DS_Store 6 | 7 | __pycache__ 8 | 9 | coverage-history.json 10 | coverage-report.json 11 | /index.html 12 | !docs/index.html 13 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tools/selector.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class SelectorType(str, Enum): 5 | CSS = "CSS" 6 | XPATH = "XPATH" 7 | 8 | def __str__(self) -> str: 9 | return str(self.value) 10 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tools/types.py: -------------------------------------------------------------------------------- 1 | from typing import NewType 2 | 3 | AppKey = NewType('AppKey', str) 4 | AppName = NewType('AppName', str) 5 | 6 | Selector = NewType('Selector', str) 7 | SelectorKey = NewType('SelectorKey', str) 8 | -------------------------------------------------------------------------------- /ui_coverage_tool/__init__.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.src.tools.actions import ActionType 2 | from ui_coverage_tool.src.tools.selector import SelectorType 3 | from ui_coverage_tool.src.tracker.core import UICoverageTracker 4 | 5 | __all__ = ["ActionType", "SelectorType", "UICoverageTracker"] 6 | -------------------------------------------------------------------------------- /.github/workflows/workflow-test.yml: -------------------------------------------------------------------------------- 1 | name: 🧪 Test 2 | 3 | on: 4 | push: 5 | branches: [ "**" ] 6 | 7 | jobs: 8 | lint: 9 | name: 🧹 lint 10 | uses: ./.github/workflows/reusable-lint.yml 11 | 12 | test: 13 | name: 🧪 test 14 | uses: ./.github/workflows/reusable-test.yml 15 | needs: lint 16 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/history/selector.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.src.tools.selector import SelectorType 2 | from ui_coverage_tool.src.tools.types import Selector, SelectorKey 3 | 4 | 5 | def build_selector_key(selector: Selector, selector_type: SelectorType) -> SelectorKey: 6 | return SelectorKey(f'{selector_type}_{selector}') 7 | -------------------------------------------------------------------------------- /ui_coverage_tool/cli/commands/print_config.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.config import get_settings 2 | from ui_coverage_tool.src.tools.logger import get_logger 3 | 4 | logger = get_logger("PRINT_CONFIG") 5 | 6 | 7 | def print_config_command(): 8 | settings = get_settings() 9 | logger.info(settings.model_dump_json(indent=2)) 10 | -------------------------------------------------------------------------------- /.github/workflows/workflow-publish.yml: -------------------------------------------------------------------------------- 1 | name: 🚀 Publish 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | lint: 10 | name: 🧹 lint 11 | uses: ./.github/workflows/reusable-lint.yml 12 | 13 | test: 14 | name: 🧪 test 15 | uses: ./.github/workflows/reusable-test.yml 16 | needs: lint 17 | 18 | pypi: 19 | name: 📦 pypi 20 | uses: ./.github/workflows/reusable-pypi.yml 21 | needs: test 22 | secrets: inherit 23 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tools/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def get_logger(name: str) -> logging.Logger: 5 | logger = logging.getLogger(name) 6 | logger.setLevel(logging.DEBUG) 7 | 8 | handler = logging.StreamHandler() 9 | handler.setLevel(logging.DEBUG) 10 | 11 | formatter = logging.Formatter('%(asctime)s | %(name)s | %(levelname)s | %(message)s') 12 | handler.setFormatter(formatter) 13 | 14 | logger.addHandler(handler) 15 | 16 | return logger 17 | -------------------------------------------------------------------------------- /.github/workflows/reusable-lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | python-version: 7 | type: string 8 | default: "3.12" 9 | required: false 10 | 11 | jobs: 12 | action: 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ inputs.python-version }} 18 | - run: pip install ruff 19 | - run: ruff check ui_coverage_tool 20 | runs-on: ubuntu-latest 21 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/tools/test_actions.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.src.tools.actions import ActionType 2 | 3 | 4 | # ------------------------------- 5 | # TEST: ActionType.to_list() 6 | # ------------------------------- 7 | 8 | 9 | def test_action_type_to_list_contains_all_members() -> None: 10 | members = ActionType.to_list() 11 | 12 | assert isinstance(members, list) 13 | assert all(isinstance(m, ActionType) for m in members) 14 | assert set(members) == set(ActionType) 15 | assert len(members) == len(ActionType) 16 | -------------------------------------------------------------------------------- /docs/configs/ui_coverage_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps": [ 3 | { 4 | "key": "my-ui-app", 5 | "url": "https://my-ui-app.com/login", 6 | "name": "My UI App", 7 | "tags": [ 8 | "UI", 9 | "PRODUCTION" 10 | ], 11 | "repository": "https://github.com/my-ui-app" 12 | } 13 | ], 14 | "results_dir": "./coverage-results", 15 | "history_file": "./coverage-history.json", 16 | "history_retention_limit": 30, 17 | "html_report_file": "./index.html", 18 | "json_report_file": "./coverage-report.json" 19 | } 20 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tools/actions.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | from typing import Self 3 | 4 | 5 | class ActionType(str, Enum): 6 | FILL = "FILL" 7 | TYPE = "TYPE" 8 | TEXT = "TEXT" 9 | VALUE = "VALUE" 10 | CLICK = "CLICK" 11 | HOVER = "HOVER" 12 | SELECT = "SELECT" 13 | HIDDEN = "HIDDEN" 14 | VISIBLE = "VISIBLE" 15 | CHECKED = "CHECKED" 16 | ENABLED = "ENABLED" 17 | DISABLED = "DISABLED" 18 | UNCHECKED = "UNCHECKED" 19 | 20 | @classmethod 21 | def to_list(cls) -> list[Self]: 22 | return list(cls) 23 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/coverage/models.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field, ConfigDict 2 | 3 | from ui_coverage_tool.src.history.models import ElementHistory, AppHistory 4 | from ui_coverage_tool.src.tools.actions import ActionType 5 | from ui_coverage_tool.src.tools.selector import SelectorType 6 | from ui_coverage_tool.src.tools.types import Selector 7 | 8 | 9 | class ActionCoverage(BaseModel): 10 | type: ActionType 11 | count: int 12 | 13 | 14 | class ElementCoverage(BaseModel): 15 | model_config = ConfigDict(populate_by_name=True) 16 | 17 | history: list[ElementHistory] 18 | actions: list[ActionCoverage] 19 | selector: Selector 20 | selector_type: SelectorType = Field(alias="selectorType") 21 | 22 | 23 | class AppCoverage(BaseModel): 24 | history: list[AppHistory] 25 | elements: list[ElementCoverage] 26 | -------------------------------------------------------------------------------- /ui_coverage_tool/cli/commands/copy_report.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | import shutil 3 | 4 | from ui_coverage_tool.src.tools.logger import get_logger 5 | 6 | logger = get_logger("COPY_REPORT") 7 | 8 | 9 | def copy_report_command(): 10 | source_file = pathlib.Path("./submodules/ui-coverage-report/build/index.html") 11 | destination_file = pathlib.Path("./ui_coverage_tool/src/reports/templates/index.html") 12 | 13 | logger.info(f"Starting to copy report from {source_file} to {destination_file}") 14 | 15 | if not source_file.exists(): 16 | logger.error(f"Source file does not exist: {source_file}") 17 | return 18 | 19 | try: 20 | shutil.copy(src=source_file, dst=destination_file) 21 | logger.info(f"Successfully copied the report to {destination_file}") 22 | except Exception as error: 23 | logger.error(f"Error copying the report: {error}") 24 | -------------------------------------------------------------------------------- /.github/workflows/reusable-pypi.yml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | python-version: 7 | type: string 8 | default: "3.12" 9 | required: false 10 | 11 | jobs: 12 | action: 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: ${{ inputs.python-version }} 20 | 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install build twine 25 | 26 | - name: Build distribution 27 | run: python -m build 28 | 29 | - name: Publish to PyPI 30 | run: twine upload dist/* 31 | env: 32 | TWINE_USERNAME: __token__ 33 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 34 | runs-on: ubuntu-latest 35 | -------------------------------------------------------------------------------- /.github/workflows/reusable-test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_call: 5 | inputs: 6 | python-version: 7 | type: string 8 | default: "3.12" 9 | required: false 10 | 11 | jobs: 12 | action: 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-python@v5 16 | with: 17 | python-version: ${{ inputs.python-version }} 18 | 19 | - name: Install dependencies 20 | run: pip install .[test] pytest pytest-cov 21 | 22 | - name: Run tests 23 | run: pytest --cov=ui_coverage_tool --cov-report=xml --cov-report=term-missing 24 | 25 | - name: Upload coverage 26 | uses: codecov/codecov-action@v4 27 | with: 28 | token: ${{ secrets.CODECOV_TOKEN }} 29 | files: ./coverage.xml 30 | verbose: true 31 | fail_ci_if_error: true 32 | 33 | runs-on: ubuntu-latest 34 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/reports/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel, Field, ConfigDict 4 | 5 | from ui_coverage_tool.config import Settings, AppConfig 6 | from ui_coverage_tool.src.coverage.models import AppCoverage 7 | from ui_coverage_tool.src.tools.types import AppKey 8 | 9 | 10 | class CoverageReportConfig(BaseModel): 11 | apps: list[AppConfig] 12 | 13 | 14 | class CoverageReportState(BaseModel): 15 | model_config = ConfigDict(populate_by_name=True) 16 | 17 | config: CoverageReportConfig 18 | created_at: datetime = Field(alias="createdAt", default_factory=datetime.now) 19 | apps_coverage: dict[AppKey, AppCoverage] = Field(alias="appsCoverage", default_factory=dict) 20 | 21 | @classmethod 22 | def init(cls, settings: Settings): 23 | return CoverageReportState( 24 | config=CoverageReportConfig(apps=settings.apps) 25 | ) 26 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/history/test_selector.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ui_coverage_tool.src.history.selector import build_selector_key 4 | from ui_coverage_tool.src.tools.selector import SelectorType 5 | from ui_coverage_tool.src.tools.types import Selector 6 | 7 | 8 | # ------------------------------- 9 | # TEST: build_selector_key 10 | # ------------------------------- 11 | 12 | @pytest.mark.parametrize( 13 | "selector_type,selector,expected", 14 | [ 15 | (SelectorType.CSS, Selector("#login-btn"), "CSS_#login-btn"), 16 | (SelectorType.XPATH, Selector("//button[@id='submit']"), "XPATH_//button[@id='submit']"), 17 | ], 18 | ) 19 | def test_build_selector_key_various_types( 20 | selector_type: SelectorType, 21 | selector: Selector, 22 | expected: str, 23 | ) -> None: 24 | result = build_selector_key(selector, selector_type) 25 | assert result == expected 26 | -------------------------------------------------------------------------------- /docs/configs/.env.example: -------------------------------------------------------------------------------- 1 | # Define the applications that should be tracked. In the case of multiple apps, they can be added in a comma-separated list. 2 | UI_COVERAGE_APPS='[ 3 | { 4 | "key": "my-ui-app", 5 | "url": "https://my-ui-app.com/login", 6 | "name": "My UI App", 7 | "tags": ["UI", "PRODUCTION"], 8 | "repository": "https://github.com/my-ui-app" 9 | } 10 | ]' 11 | 12 | # The directory where the coverage results will be saved. 13 | UI_COVERAGE_RESULTS_DIR="./coverage-results" 14 | 15 | # The file that stores the history of coverage results. 16 | UI_COVERAGE_HISTORY_FILE="./coverage-history.json" 17 | 18 | # The retention limit for the coverage history. It controls how many historical results to keep. 19 | UI_COVERAGE_HISTORY_RETENTION_LIMIT=30 20 | 21 | # Optional file paths for the HTML and JSON reports. 22 | UI_COVERAGE_HTML_REPORT_FILE="./index.html" 23 | UI_COVERAGE_JSON_REPORT_FILE="./coverage-report.json" -------------------------------------------------------------------------------- /ui_coverage_tool/cli/main.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from ui_coverage_tool.cli.commands.copy_report import copy_report_command 4 | from ui_coverage_tool.cli.commands.print_config import print_config_command 5 | from ui_coverage_tool.cli.commands.save_report import save_report_command 6 | 7 | 8 | @click.command( 9 | name="save-report", 10 | help="Generate a coverage report based on collected result files." 11 | ) 12 | def save_report(): 13 | save_report_command() 14 | 15 | 16 | @click.command(name="copy-report", help="Internal command to update report template.") 17 | def copy_report(): 18 | copy_report_command() 19 | 20 | 21 | @click.command( 22 | name="print-config", 23 | help="Print the resolved configuration to the console." 24 | ) 25 | def show_config(): 26 | print_config_command() 27 | 28 | 29 | @click.group() 30 | def cli(): 31 | pass 32 | 33 | 34 | cli.add_command(save_report) 35 | cli.add_command(copy_report) 36 | cli.add_command(show_config) 37 | 38 | if __name__ == '__main__': 39 | cli() 40 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tracker/core.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.config import Settings, get_settings 2 | from ui_coverage_tool.src.tools.types import Selector, AppKey 3 | from ui_coverage_tool.src.tracker.models import SelectorType, ActionType, CoverageResult 4 | from ui_coverage_tool.src.tracker.storage import UICoverageTrackerStorage 5 | 6 | 7 | class UICoverageTracker: 8 | def __init__(self, app: str, settings: Settings | None = None): 9 | self.app = app 10 | self.settings = settings or get_settings() 11 | 12 | self.storage = UICoverageTrackerStorage(self.settings) 13 | 14 | def track_coverage( 15 | self, 16 | selector: str, 17 | action_type: ActionType, 18 | selector_type: SelectorType, 19 | ): 20 | self.storage.save( 21 | CoverageResult( 22 | app=AppKey(self.app), 23 | selector=Selector(selector), 24 | action_type=action_type, 25 | selector_type=selector_type 26 | ) 27 | ) 28 | -------------------------------------------------------------------------------- /docs/configs/ui_coverage_config.yaml: -------------------------------------------------------------------------------- 1 | # List of application configurations. 2 | apps: 3 | - key: "my-ui-app" # Unique identifier for the app. 4 | url: "https://my-ui-app.com/login" # Entry point URL of the app. 5 | name: "My UI App" # Name of the app. 6 | tags: [ "UI", "PRODUCTION" ] # Optional list of tags to describe the application. 7 | repository: "https://github.com/my-ui-app" # Optional URL to the repository of the application. 8 | 9 | # Directory where coverage results will be stored. 10 | results_dir: "./coverage-results" # This path is relative to the current working directory. 11 | 12 | # File where the history of coverage results is saved. 13 | history_file: "./coverage-history.json" # This path is relative to the current working directory. 14 | history_retention_limit: 30 # The maximum number of history records to keep. 15 | 16 | # File paths for reports. These are optional, and if not set, reports will not be generated. 17 | html_report_file: "./index.html" # Path to the HTML report file. 18 | json_report_file: "./coverage-report.json" # Path to the JSON report file. 19 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/history/models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from pydantic import BaseModel, Field, ConfigDict 4 | 5 | from ui_coverage_tool.src.tools.actions import ActionType 6 | from ui_coverage_tool.src.tools.types import AppKey 7 | 8 | 9 | class ActionHistory(BaseModel): 10 | type: ActionType 11 | count: int 12 | 13 | 14 | class ElementHistory(BaseModel): 15 | model_config = ConfigDict(populate_by_name=True) 16 | 17 | actions: list[ActionHistory] 18 | created_at: datetime = Field(alias="createdAt") 19 | 20 | 21 | class AppHistory(BaseModel): 22 | model_config = ConfigDict(populate_by_name=True) 23 | 24 | actions: list[ActionHistory] 25 | created_at: datetime = Field(alias="createdAt") 26 | total_actions: int = Field(alias="totalActions") 27 | total_elements: int = Field(alias="totalElements") 28 | 29 | 30 | class AppHistoryState(BaseModel): 31 | total: list[AppHistory] = Field(default_factory=list) 32 | elements: dict[str, list[ElementHistory]] = Field(default_factory=dict) 33 | 34 | 35 | class CoverageHistoryState(BaseModel): 36 | apps: dict[AppKey, AppHistoryState] = Field(default_factory=dict) 37 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/fixtures/config.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | from pydantic import HttpUrl 5 | 6 | from ui_coverage_tool.config import Settings, AppConfig 7 | from ui_coverage_tool.src.tools.types import AppKey, AppName 8 | 9 | 10 | @pytest.fixture 11 | def settings(tmp_path: Path) -> Settings: 12 | results_dir = tmp_path / "results" 13 | results_dir.mkdir(parents=True, exist_ok=True) 14 | return Settings( 15 | apps=[ 16 | AppConfig( 17 | url=HttpUrl("https://example.com/login"), 18 | key=AppKey("test-service"), 19 | name=AppName("Test Service") 20 | ) 21 | ], 22 | results_dir=results_dir 23 | ) 24 | 25 | 26 | @pytest.fixture 27 | def coverage_history_settings(tmp_path: Path, settings: Settings) -> Settings: 28 | settings.results_dir = tmp_path / "results" 29 | settings.history_file = tmp_path / "history.json" 30 | settings.history_retention_limit = 3 31 | return settings 32 | 33 | 34 | @pytest.fixture 35 | def reports_settings(tmp_path: Path, coverage_history_settings: Settings) -> Settings: 36 | coverage_history_settings.json_report_file = tmp_path / "report.json" 37 | coverage_history_settings.html_report_file = tmp_path / "report.html" 38 | return coverage_history_settings 39 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tracker/storage.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from ui_coverage_tool.config import Settings 4 | from ui_coverage_tool.src.tools.logger import get_logger 5 | from ui_coverage_tool.src.tracker.models import CoverageResult, CoverageResultList 6 | 7 | logger = get_logger("UI_COVERAGE_TRACKER_STORAGE") 8 | 9 | 10 | class UICoverageTrackerStorage: 11 | def __init__(self, settings: Settings): 12 | self.settings = settings 13 | 14 | def load(self) -> CoverageResultList: 15 | results_dir = self.settings.results_dir 16 | logger.info(f"Loading coverage results from directory: {results_dir}") 17 | 18 | if not results_dir.exists(): 19 | logger.warning(f"Results directory does not exist: {results_dir}") 20 | return CoverageResultList(root=[]) 21 | 22 | results = [ 23 | CoverageResult.model_validate_json(file.read_text()) 24 | for file in results_dir.glob("*.json") if file.is_file() 25 | ] 26 | 27 | logger.info(f"Loaded {len(results)} coverage files from directory: {results_dir}") 28 | return CoverageResultList(root=results) 29 | 30 | def save(self, coverage: CoverageResult): 31 | results_dir = self.settings.results_dir 32 | 33 | if not results_dir.exists(): 34 | logger.info(f"Results directory does not exist, creating: {results_dir}") 35 | results_dir.mkdir(parents=True, exist_ok=True) 36 | 37 | result_file = results_dir.joinpath(f'{uuid.uuid4()}.json') 38 | 39 | try: 40 | result_file.write_text(coverage.model_dump_json()) 41 | except Exception as error: 42 | logger.error(f"Error saving coverage data to file {result_file}: {error}") 43 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/fixtures/reports.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | import pytest 4 | 5 | from ui_coverage_tool import ActionType, SelectorType 6 | from ui_coverage_tool.config import Settings 7 | from ui_coverage_tool.src.coverage.models import AppCoverage, ElementCoverage, ActionCoverage 8 | from ui_coverage_tool.src.history.models import AppHistory, ElementHistory, ActionHistory 9 | from ui_coverage_tool.src.reports.models import CoverageReportState 10 | from ui_coverage_tool.src.reports.storage import UIReportsStorage 11 | from ui_coverage_tool.src.tools.types import Selector, AppKey 12 | 13 | 14 | @pytest.fixture 15 | def coverage_report_state(coverage_history_settings: Settings) -> CoverageReportState: 16 | app_key = AppKey("ui-app") 17 | 18 | action_history = [ActionHistory(type=ActionType.CLICK, count=3)] 19 | element_history = [ElementHistory(actions=action_history, created_at=datetime.now())] 20 | 21 | element_coverage = ElementCoverage( 22 | history=element_history, 23 | actions=[ActionCoverage(type=ActionType.CLICK, count=3)], 24 | selector=Selector("#submit"), 25 | selector_type=SelectorType.CSS, 26 | ) 27 | 28 | app_history = [ 29 | AppHistory( 30 | actions=action_history, 31 | created_at=datetime.now(), 32 | total_actions=10, 33 | total_elements=5, 34 | ) 35 | ] 36 | app_coverage = AppCoverage(history=app_history, elements=[element_coverage]) 37 | 38 | report = CoverageReportState.init(coverage_history_settings) 39 | report.apps_coverage = {app_key: app_coverage} 40 | 41 | return report 42 | 43 | 44 | @pytest.fixture 45 | def reports_storage(reports_settings: Settings) -> UIReportsStorage: 46 | return UIReportsStorage(reports_settings) 47 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/fixtures/tracker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ui_coverage_tool.config import Settings 4 | from ui_coverage_tool.src.tools.actions import ActionType 5 | from ui_coverage_tool.src.tools.selector import SelectorType 6 | from ui_coverage_tool.src.tools.types import AppKey, Selector 7 | from ui_coverage_tool.src.tracker.models import CoverageResult, CoverageResultList 8 | from ui_coverage_tool.src.tracker.storage import UICoverageTrackerStorage 9 | 10 | 11 | @pytest.fixture 12 | def coverage_result() -> CoverageResult: 13 | return CoverageResult( 14 | app=AppKey("ui-app"), 15 | selector=Selector("#submit"), 16 | action_type=ActionType.CLICK, 17 | selector_type=SelectorType.CSS, 18 | ) 19 | 20 | 21 | @pytest.fixture 22 | def coverage_results() -> list[CoverageResult]: 23 | return [ 24 | CoverageResult( 25 | app=AppKey("ui-app"), 26 | selector=Selector("#login"), 27 | action_type=ActionType.CLICK, 28 | selector_type=SelectorType.CSS, 29 | ), 30 | CoverageResult( 31 | app=AppKey("ui-app"), 32 | selector=Selector("#search"), 33 | action_type=ActionType.HOVER, 34 | selector_type=SelectorType.CSS, 35 | ), 36 | CoverageResult( 37 | app=AppKey("other-app"), 38 | selector=Selector("#login"), 39 | action_type=ActionType.CLICK, 40 | selector_type=SelectorType.CSS, 41 | ), 42 | ] 43 | 44 | 45 | @pytest.fixture 46 | def coverage_result_list(coverage_results: list[CoverageResult]) -> CoverageResultList: 47 | return CoverageResultList(root=coverage_results) 48 | 49 | 50 | @pytest.fixture 51 | def coverage_tracker_storage(settings: Settings) -> UICoverageTrackerStorage: 52 | return UICoverageTrackerStorage(settings) 53 | -------------------------------------------------------------------------------- /ui_coverage_tool/cli/commands/save_report.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.config import get_settings 2 | from ui_coverage_tool.src.coverage.builder import UICoverageBuilder 3 | from ui_coverage_tool.src.history.builder import UICoverageHistoryBuilder 4 | from ui_coverage_tool.src.history.models import AppHistoryState 5 | from ui_coverage_tool.src.history.storage import UICoverageHistoryStorage 6 | from ui_coverage_tool.src.reports.models import CoverageReportState 7 | from ui_coverage_tool.src.reports.storage import UIReportsStorage 8 | from ui_coverage_tool.src.tools.logger import get_logger 9 | from ui_coverage_tool.src.tracker.storage import UICoverageTrackerStorage 10 | 11 | logger = get_logger("SAVE_REPORT") 12 | 13 | 14 | def save_report_command(): 15 | logger.info("Starting to save the report") 16 | 17 | settings = get_settings() 18 | 19 | reports_storage = UIReportsStorage(settings=settings) 20 | tracker_storage = UICoverageTrackerStorage(settings=settings) 21 | history_storage = UICoverageHistoryStorage(settings=settings) 22 | 23 | report_state = CoverageReportState.init(settings) 24 | history_state = history_storage.load() 25 | tracker_state = tracker_storage.load() 26 | for app in settings.apps: 27 | results_list = tracker_state.filter(app=app.key) 28 | 29 | coverage_builder = UICoverageBuilder( 30 | results_list=results_list, 31 | history_builder=UICoverageHistoryBuilder( 32 | history=history_state.apps.get(app.key, AppHistoryState()), 33 | settings=settings 34 | ) 35 | ) 36 | report_state.apps_coverage[app.key] = coverage_builder.build() 37 | 38 | history_storage.save_from_report(report_state) 39 | reports_storage.save_json_report(report_state) 40 | reports_storage.save_html_report(report_state) 41 | 42 | logger.info("Report saving process completed") 43 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/tracker/models.py: -------------------------------------------------------------------------------- 1 | from collections import Counter 2 | from functools import cached_property 3 | from itertools import groupby 4 | from typing import Self 5 | 6 | from pydantic import BaseModel, RootModel 7 | 8 | from ui_coverage_tool.src.tools.actions import ActionType 9 | from ui_coverage_tool.src.tools.selector import SelectorType 10 | from ui_coverage_tool.src.tools.types import Selector, AppKey 11 | 12 | SelectorGroupKey = tuple[Selector, SelectorType] 13 | 14 | 15 | class CoverageResult(BaseModel): 16 | app: AppKey 17 | selector: Selector 18 | action_type: ActionType 19 | selector_type: SelectorType 20 | 21 | 22 | class CoverageResultList(RootModel): 23 | root: list[CoverageResult] 24 | 25 | def filter(self, app: AppKey | None = None) -> Self: 26 | results = [ 27 | coverage 28 | for coverage in self.root 29 | if (app is None or coverage.app.lower() == app.lower()) 30 | ] 31 | return CoverageResultList(root=results) 32 | 33 | @cached_property 34 | def grouped_by_action(self) -> dict[ActionType, Self]: 35 | results = sorted(self.root, key=lambda r: r.action_type) 36 | return { 37 | grouper: CoverageResultList(root=results) 38 | for grouper, results in groupby(results, key=lambda r: r.action_type) 39 | } 40 | 41 | @cached_property 42 | def grouped_by_selector(self) -> dict[SelectorGroupKey, Self]: 43 | results = sorted(self.root, key=lambda r: (r.selector, r.selector_type)) 44 | return { 45 | grouper: CoverageResultList(root=results) 46 | for grouper, results in groupby(results, key=lambda r: (r.selector, r.selector_type)) 47 | } 48 | 49 | @property 50 | def total_actions(self) -> int: 51 | return len(self.root) 52 | 53 | @property 54 | def total_selectors(self) -> int: 55 | return len(self.grouped_by_selector) 56 | 57 | def count_action(self, action_type: ActionType) -> int: 58 | counter = Counter(r.action_type for r in self.root) 59 | return counter.get(action_type, 0) 60 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/reports/storage.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from ui_coverage_tool.config import Settings 4 | from ui_coverage_tool.src.reports.models import CoverageReportState 5 | from ui_coverage_tool.src.tools.logger import get_logger 6 | 7 | logger = get_logger("UI_REPORTS_STORAGE") 8 | 9 | 10 | class UIReportsStorage: 11 | def __init__(self, settings: Settings): 12 | self.settings = settings 13 | 14 | def inject_state_into_html(self, state: CoverageReportState) -> str: 15 | state_json = state.model_dump_json(by_alias=True) 16 | html_report_template_file = self.settings.html_report_template_file 17 | 18 | script_regex = re.compile( 19 | r'', 20 | re.IGNORECASE 21 | ) 22 | script_tag = f'' 23 | 24 | return script_regex.sub(script_tag, html_report_template_file.read_text(encoding='utf-8')) 25 | 26 | def save_json_report(self, state: CoverageReportState): 27 | json_report_file = self.settings.json_report_file 28 | 29 | if not json_report_file: 30 | logger.info("JSON report file is not configured — skipping JSON report generation.") 31 | return 32 | 33 | try: 34 | json_report_file.touch(exist_ok=True) 35 | json_report_file.write_text(state.model_dump_json(by_alias=True)) 36 | logger.info(f'JSON report saved to {json_report_file}') 37 | except Exception as error: 38 | logger.error(f'Failed to write JSON report: {error}') 39 | 40 | def save_html_report(self, state: CoverageReportState): 41 | html_report_file = self.settings.html_report_file 42 | 43 | if not html_report_file: 44 | logger.info("HTML report file is not configured — skipping HTML report generation.") 45 | return 46 | 47 | try: 48 | html_report_file.touch(exist_ok=True) 49 | html_report_file.write_text(self.inject_state_into_html(state), encoding='utf-8') 50 | logger.info(f'HTML report saved to {html_report_file}') 51 | except Exception as error: 52 | logger.error(f'Failed to write HTML report: {error}') 53 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "ui-coverage-tool" 7 | version = "0.33.0" 8 | description = "UI Coverage Tool is an innovative, no-overhead solution for tracking and visualizing UI test coverage — directly on your actual application, not static snapshots." 9 | readme = { file = "README.md", content-type = "text/markdown" } 10 | license = { file = "LICENSE" } 11 | authors = [ 12 | { name = "Nikita Filonov", email = "filonov.nikitkaa@gmail.com" } 13 | ] 14 | maintainers = [ 15 | { name = "Nikita Filonov", email = "filonov.nikitkaa@gmail.com" } 16 | ] 17 | keywords = [ 18 | "python", 19 | "coverage", 20 | "ui-coverage", 21 | "ui-testing", 22 | "test-coverage", 23 | "frontend-testing", 24 | "visual-testing", 25 | "automation", 26 | "qa", 27 | "quality-assurance", 28 | "reporting", 29 | "html-report", 30 | "pytest", 31 | "selenium", 32 | "playwright", 33 | "web-testing", 34 | "test-analytics" 35 | ] 36 | classifiers = [ 37 | "Programming Language :: Python :: 3", 38 | "Programming Language :: Python :: 3.11", 39 | "Programming Language :: Python :: 3.12", 40 | "License :: OSI Approved :: MIT License", 41 | "Operating System :: OS Independent", 42 | "Intended Audience :: Developers", 43 | "Topic :: Software Development :: Testing", 44 | "Topic :: Software Development :: Quality Assurance" 45 | ] 46 | requires-python = ">=3.11" 47 | 48 | dependencies = [ 49 | "click", 50 | "pyyaml", 51 | "pydantic", 52 | "pydantic-settings" 53 | ] 54 | 55 | [project.urls] 56 | Homepage = "https://github.com/Nikita-Filonov/ui-coverage-tool" 57 | Repository = "https://github.com/Nikita-Filonov/ui-coverage-tool" 58 | Issues = "https://github.com/Nikita-Filonov/ui-coverage-tool/issues" 59 | 60 | [project.scripts] 61 | ui-coverage-tool = "ui_coverage_tool.cli.main:cli" 62 | 63 | [project.optional-dependencies] 64 | test = ["pytest", "pytest-cov"] 65 | 66 | [tool.setuptools] 67 | include-package-data = true 68 | 69 | [tool.setuptools.packages.find] 70 | where = ["."] 71 | include = ["ui_coverage_tool*"] 72 | 73 | [tool.setuptools.package-data] 74 | "ui_coverage_tool" = ["config.py"] 75 | "ui_coverage_tool.cli" = ["**/*"] 76 | "ui_coverage_tool.src" = ["**/*"] 77 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/coverage/builder.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.src.coverage.models import AppCoverage, ElementCoverage, ActionCoverage 2 | from ui_coverage_tool.src.history.builder import UICoverageHistoryBuilder 3 | from ui_coverage_tool.src.history.models import ActionHistory 4 | from ui_coverage_tool.src.tools.actions import ActionType 5 | from ui_coverage_tool.src.tools.selector import SelectorType 6 | from ui_coverage_tool.src.tools.types import Selector 7 | from ui_coverage_tool.src.tracker.models import CoverageResultList 8 | 9 | 10 | class UICoverageBuilder: 11 | def __init__(self, results_list: CoverageResultList, history_builder: UICoverageHistoryBuilder): 12 | self.results_list = results_list 13 | self.history_builder = history_builder 14 | 15 | def build_element_coverage( 16 | self, 17 | results: CoverageResultList, 18 | selector: Selector, 19 | selector_type: SelectorType 20 | ) -> ElementCoverage: 21 | actions = [ 22 | ActionCoverage(type=action, count=results.count_action(action)) 23 | for action in ActionType.to_list() 24 | if results.count_action(action) > 0 25 | ] 26 | 27 | return ElementCoverage( 28 | history=self.history_builder.get_element_history( 29 | actions=[ActionHistory(type=action.type, count=action.count) for action in actions], 30 | selector=selector, 31 | selector_type=selector_type 32 | ), 33 | actions=actions, 34 | selector=selector, 35 | selector_type=selector_type, 36 | ) 37 | 38 | def build(self) -> AppCoverage: 39 | return AppCoverage( 40 | history=self.history_builder.get_app_history( 41 | actions=[ 42 | ActionHistory(type=action, count=results.total_actions) 43 | for action, results in self.results_list.grouped_by_action.items() 44 | if results.total_actions > 0 45 | ], 46 | total_actions=self.results_list.total_actions, 47 | total_elements=self.results_list.total_selectors 48 | ), 49 | elements=[ 50 | self.build_element_coverage(results, selector, selector_type) 51 | for (selector, selector_type), results in self.results_list.grouped_by_selector.items() 52 | ], 53 | ) 54 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/history/storage.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.config import Settings 2 | from ui_coverage_tool.src.history.models import CoverageHistoryState, AppHistoryState 3 | from ui_coverage_tool.src.history.selector import build_selector_key 4 | from ui_coverage_tool.src.reports.models import CoverageReportState 5 | from ui_coverage_tool.src.tools.logger import get_logger 6 | 7 | logger = get_logger("UI_COVERAGE_HISTORY_STORAGE") 8 | 9 | 10 | class UICoverageHistoryStorage: 11 | def __init__(self, settings: Settings): 12 | self.settings = settings 13 | 14 | def load(self): 15 | history_file = self.settings.history_file 16 | 17 | if not history_file: 18 | logger.debug("No history file path provided, returning empty history state") 19 | return CoverageHistoryState() 20 | 21 | if not history_file.exists(): 22 | logger.debug("History file not found, returning empty history state") 23 | return CoverageHistoryState() 24 | 25 | try: 26 | logger.info(f"Loading history from file: {history_file}") 27 | return CoverageHistoryState.model_validate_json(history_file.read_text()) 28 | except Exception as error: 29 | logger.error(f"Error loading history from file {history_file}: {error}") 30 | return CoverageHistoryState() 31 | 32 | def save(self, state: CoverageHistoryState): 33 | history_file = self.settings.history_file 34 | 35 | if not history_file: 36 | logger.debug("History file path is not defined, skipping history save") 37 | return 38 | 39 | try: 40 | history_file.touch(exist_ok=True) 41 | history_file.write_text(state.model_dump_json(by_alias=True)) 42 | logger.info(f"History state saved to file: {history_file}") 43 | except Exception as error: 44 | logger.error(f"Error saving history to file {history_file}: {error}") 45 | 46 | def save_from_report(self, report: CoverageReportState): 47 | state = CoverageHistoryState( 48 | apps={ 49 | app.key: AppHistoryState( 50 | total=report.apps_coverage[app.key].history, 51 | elements={ 52 | build_selector_key(element.selector, element.selector_type): element.history 53 | for element in report.apps_coverage[app.key].elements 54 | } 55 | ) 56 | for app in self.settings.apps 57 | } 58 | ) 59 | self.save(state) 60 | -------------------------------------------------------------------------------- /ui_coverage_tool/src/history/builder.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from typing import TypeVar, Callable 3 | 4 | from ui_coverage_tool.config import Settings 5 | from ui_coverage_tool.src.history.models import ElementHistory, AppHistoryState, ActionHistory, AppHistory 6 | from ui_coverage_tool.src.history.selector import build_selector_key 7 | from ui_coverage_tool.src.tools.selector import SelectorType 8 | from ui_coverage_tool.src.tools.types import Selector 9 | 10 | T = TypeVar('T') 11 | 12 | 13 | class UICoverageHistoryBuilder: 14 | def __init__(self, history: AppHistoryState, settings: Settings): 15 | self.history = history 16 | self.settings = settings 17 | self.created_at = datetime.now() 18 | 19 | def build_app_history( 20 | self, 21 | actions: list[ActionHistory], 22 | total_actions: int, 23 | total_elements: int 24 | ) -> AppHistory: 25 | return AppHistory( 26 | actions=actions, 27 | created_at=self.created_at, 28 | total_actions=total_actions, 29 | total_elements=total_elements 30 | ) 31 | 32 | def build_element_history(self, actions: list[ActionHistory]) -> ElementHistory: 33 | return ElementHistory(created_at=self.created_at, actions=actions) 34 | 35 | def append_history(self, history: list[T], build_func: Callable[[], T]) -> list[T]: 36 | if not self.settings.history_file: 37 | return [] 38 | 39 | new_item = build_func() 40 | if not new_item.actions: 41 | return history 42 | 43 | combined = [*history, new_item] 44 | combined.sort(key=lambda r: r.created_at) 45 | return combined[-self.settings.history_retention_limit:] 46 | 47 | def get_app_history( 48 | self, 49 | actions: list[ActionHistory], 50 | total_actions: int, 51 | total_elements: int 52 | ) -> list[AppHistory]: 53 | return self.append_history( 54 | self.history.total, 55 | lambda: self.build_app_history( 56 | actions=actions, 57 | total_actions=total_actions, 58 | total_elements=total_elements 59 | ) 60 | ) 61 | 62 | def get_element_history( 63 | self, 64 | actions: list[ActionHistory], 65 | selector: Selector, 66 | selector_type: SelectorType 67 | ) -> list[ElementHistory]: 68 | key = build_selector_key(selector, selector_type) 69 | history = self.history.elements.get(key, []) 70 | return self.append_history(history, lambda: self.build_element_history(actions)) 71 | -------------------------------------------------------------------------------- /ui_coverage_tool/config.py: -------------------------------------------------------------------------------- 1 | import importlib.metadata 2 | import importlib.resources 3 | import os 4 | from functools import lru_cache 5 | from pathlib import Path 6 | 7 | from pydantic import BaseModel, HttpUrl 8 | from pydantic_settings import ( 9 | BaseSettings, 10 | SettingsConfigDict, 11 | YamlConfigSettingsSource, 12 | JsonConfigSettingsSource, 13 | PydanticBaseSettingsSource, 14 | ) 15 | 16 | from ui_coverage_tool.src.tools.types import AppKey, AppName 17 | 18 | 19 | class AppConfig(BaseModel): 20 | key: AppKey 21 | url: HttpUrl 22 | name: AppName 23 | tags: list[str] | None = None 24 | repository: HttpUrl | None = None 25 | 26 | 27 | class Settings(BaseSettings): 28 | model_config = SettingsConfigDict( 29 | extra='allow', 30 | 31 | env_file=os.path.join(os.getcwd(), ".env"), 32 | env_prefix="UI_COVERAGE_", 33 | env_file_encoding="utf-8", 34 | env_nested_delimiter=".", 35 | 36 | yaml_file=os.path.join(os.getcwd(), "ui_coverage_config.yaml"), 37 | yaml_file_encoding="utf-8", 38 | 39 | json_file=os.path.join(os.getcwd(), "ui_coverage_config.json"), 40 | json_file_encoding="utf-8" 41 | ) 42 | 43 | apps: list[AppConfig] 44 | 45 | results_dir: Path = Path(os.path.join(os.getcwd(), "coverage-results")) 46 | 47 | history_file: Path | None = Path(os.path.join(os.getcwd(), "coverage-history.json")) 48 | history_retention_limit: int = 30 49 | 50 | html_report_file: Path | None = Path(os.path.join(os.getcwd(), "index.html")) 51 | json_report_file: Path | None = Path(os.path.join(os.getcwd(), "coverage-report.json")) 52 | 53 | @property 54 | def html_report_template_file(self): 55 | try: 56 | return importlib.resources.files("ui_coverage_tool.src.reports.templates") / "index.html" 57 | except importlib.metadata.PackageNotFoundError: 58 | return Path(os.path.join(os.getcwd(), "ui_coverage_tool/src/reports/templates/index.html")) 59 | 60 | @classmethod 61 | def settings_customise_sources( 62 | cls, 63 | settings_cls: type[BaseSettings], 64 | init_settings: PydanticBaseSettingsSource, 65 | env_settings: PydanticBaseSettingsSource, 66 | dotenv_settings: PydanticBaseSettingsSource, 67 | file_secret_settings: PydanticBaseSettingsSource, 68 | ) -> tuple[PydanticBaseSettingsSource, ...]: 69 | return ( 70 | YamlConfigSettingsSource(cls), 71 | JsonConfigSettingsSource(cls), 72 | env_settings, 73 | dotenv_settings, 74 | init_settings, 75 | ) 76 | 77 | 78 | @lru_cache(maxsize=None) 79 | def get_settings() -> Settings: 80 | return Settings() 81 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/reports/test_models.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from ui_coverage_tool.config import Settings, AppConfig 4 | from ui_coverage_tool.src.coverage.models import AppCoverage 5 | from ui_coverage_tool.src.reports.models import ( 6 | CoverageReportState, 7 | CoverageReportConfig, 8 | ) 9 | 10 | 11 | # ------------------------------- 12 | # TEST: init 13 | # ------------------------------- 14 | 15 | def test_init_creates_valid_report_state(settings: Settings) -> None: 16 | state = CoverageReportState.init(settings) 17 | 18 | assert isinstance(state, CoverageReportState) 19 | assert isinstance(state.config, CoverageReportConfig) 20 | assert isinstance(state.created_at, datetime) 21 | 22 | assert isinstance(state.apps_coverage, dict) 23 | assert state.apps_coverage == {} 24 | 25 | # Проверяем, что конфиг скопировал все приложения из настроек 26 | assert len(state.config.apps) == len(settings.apps) 27 | first_app = state.config.apps[0] 28 | 29 | assert isinstance(first_app, AppConfig) 30 | assert first_app.key == settings.apps[0].key 31 | assert str(first_app.url) == str(settings.apps[0].url) 32 | 33 | 34 | def test_init_with_empty_settings() -> None: 35 | empty_settings = Settings(apps=[]) 36 | state = CoverageReportState.init(empty_settings) 37 | 38 | assert isinstance(state, CoverageReportState) 39 | assert isinstance(state.config, CoverageReportConfig) 40 | assert isinstance(state.created_at, datetime) 41 | assert isinstance(state.apps_coverage, dict) 42 | 43 | assert state.config.apps == empty_settings.apps 44 | assert state.apps_coverage == {} 45 | 46 | 47 | def test_init_creates_distinct_instances(settings: Settings) -> None: 48 | """init() создаёт независимые экземпляры состояния.""" 49 | state1 = CoverageReportState.init(settings) 50 | state2 = CoverageReportState.init(settings) 51 | 52 | assert state1 is not state2 53 | assert state1.created_at != state2.created_at 54 | 55 | app1 = state1.config.apps[0] 56 | app2 = state2.config.apps[0] 57 | 58 | assert isinstance(app1, AppConfig) 59 | assert isinstance(app2, AppConfig) 60 | assert app1.key == app2.key 61 | assert str(app1.url) == str(app2.url) 62 | 63 | 64 | # ------------------------------- 65 | # TEST: structure integrity 66 | # ------------------------------- 67 | 68 | def test_coverage_report_state_allows_adding_apps(settings: Settings) -> None: 69 | state = CoverageReportState.init(settings) 70 | 71 | app_key = settings.apps[0].key 72 | app_coverage = AppCoverage(history=[], elements=[]) 73 | 74 | state.apps_coverage[app_key] = app_coverage 75 | 76 | assert app_key in state.apps_coverage 77 | assert isinstance(state.apps_coverage[app_key], AppCoverage) 78 | assert state.apps_coverage[app_key].history == [] 79 | assert state.apps_coverage[app_key].elements == [] 80 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/tracker/test_core.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from ui_coverage_tool.config import Settings 4 | from ui_coverage_tool.src.tools.actions import ActionType 5 | from ui_coverage_tool.src.tools.selector import SelectorType 6 | from ui_coverage_tool.src.tools.types import AppKey, Selector 7 | from ui_coverage_tool.src.tracker.core import UICoverageTracker 8 | from ui_coverage_tool.src.tracker.models import CoverageResult 9 | 10 | 11 | # ------------------------------- 12 | # TEST: init 13 | # ------------------------------- 14 | 15 | def test_init_creates_storage(settings: Settings) -> None: 16 | tracker = UICoverageTracker(app="ui-app", settings=settings) 17 | 18 | assert tracker.app == "ui-app" 19 | assert isinstance(tracker.storage, object) 20 | assert tracker.settings is settings 21 | 22 | 23 | def test_init_uses_default_settings(monkeypatch: pytest.MonkeyPatch) -> None: 24 | called: dict[str, bool] = {} 25 | 26 | def fake_get_settings() -> Settings: 27 | called["used"] = True 28 | return Settings(apps=[]) 29 | 30 | monkeypatch.setattr("ui_coverage_tool.src.tracker.core.get_settings", fake_get_settings) 31 | 32 | tracker = UICoverageTracker(app="ui-app") 33 | 34 | assert called["used"] is True 35 | assert isinstance(tracker.settings, Settings) 36 | 37 | 38 | # ------------------------------- 39 | # TEST: track_coverage 40 | # ------------------------------- 41 | 42 | def test_track_coverage_calls_storage_save(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: 43 | tracker = UICoverageTracker(app="ui-app", settings=settings) 44 | 45 | called: dict[str, CoverageResult] = {} 46 | 47 | def mock_save(result: CoverageResult) -> None: 48 | called["result"] = result 49 | assert isinstance(result, CoverageResult) 50 | assert result.app == AppKey("ui-app") 51 | assert result.selector == Selector("#login-btn") 52 | assert result.action_type == ActionType.CLICK 53 | assert result.selector_type == SelectorType.CSS 54 | 55 | monkeypatch.setattr(tracker.storage, "save", mock_save) 56 | 57 | tracker.track_coverage( 58 | selector="#login-btn", 59 | action_type=ActionType.CLICK, 60 | selector_type=SelectorType.CSS, 61 | ) 62 | 63 | assert "result" in called 64 | 65 | 66 | def test_track_coverage_multiple_calls(settings: Settings, monkeypatch: pytest.MonkeyPatch) -> None: 67 | tracker = UICoverageTracker(app="ui-app", settings=settings) 68 | saved: list[CoverageResult] = [] 69 | 70 | def mock_save(result: CoverageResult) -> None: 71 | saved.append(result) 72 | 73 | monkeypatch.setattr(tracker.storage, "save", mock_save) 74 | 75 | tracker.track_coverage("#submit", ActionType.CLICK, SelectorType.CSS) 76 | tracker.track_coverage(".input", ActionType.HOVER, SelectorType.CSS) 77 | 78 | assert len(saved) == 2 79 | assert saved[0].selector == "#submit" 80 | assert saved[1].selector == ".input" 81 | assert all(isinstance(r, CoverageResult) for r in saved) 82 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/tracker/test_models.py: -------------------------------------------------------------------------------- 1 | from ui_coverage_tool.src.tools.actions import ActionType 2 | from ui_coverage_tool.src.tools.selector import SelectorType 3 | from ui_coverage_tool.src.tools.types import Selector, AppKey 4 | from ui_coverage_tool.src.tracker.models import CoverageResultList 5 | 6 | 7 | # ------------------------------- 8 | # TEST: filter 9 | # ------------------------------- 10 | 11 | def test_filter_by_app(coverage_result_list: CoverageResultList): 12 | filtered = coverage_result_list.filter(AppKey("ui-app")) 13 | assert isinstance(filtered, CoverageResultList) 14 | assert len(filtered.root) == 2 15 | assert all(r.app == "ui-app" for r in filtered.root) 16 | 17 | 18 | def test_filter_none_returns_all(coverage_result_list: CoverageResultList): 19 | filtered = coverage_result_list.filter() 20 | assert len(filtered.root) == len(coverage_result_list.root) 21 | 22 | 23 | def test_filter_case_insensitive(coverage_result_list: CoverageResultList): 24 | filtered = coverage_result_list.filter(AppKey("UI-APP")) 25 | assert len(filtered.root) == 2 26 | assert all(r.app == "ui-app" for r in filtered.root) 27 | 28 | 29 | # ------------------------------- 30 | # TEST: grouped_by_action 31 | # ------------------------------- 32 | 33 | def test_grouped_by_action_returns_dict(coverage_result_list: CoverageResultList): 34 | grouped = coverage_result_list.grouped_by_action 35 | assert isinstance(grouped, dict) 36 | assert ActionType.CLICK in grouped 37 | assert isinstance(grouped[ActionType.CLICK], CoverageResultList) 38 | assert all(r.action_type == ActionType.CLICK for r in grouped[ActionType.CLICK].root) 39 | 40 | 41 | def test_grouped_by_action_multiple_groups(coverage_result_list: CoverageResultList): 42 | grouped = coverage_result_list.grouped_by_action 43 | assert set(grouped.keys()) == {ActionType.CLICK, ActionType.HOVER} 44 | 45 | 46 | # ------------------------------- 47 | # TEST: grouped_by_selector 48 | # ------------------------------- 49 | 50 | def test_grouped_by_selector_returns_dict(coverage_result_list: CoverageResultList): 51 | grouped = coverage_result_list.grouped_by_selector 52 | assert isinstance(grouped, dict) 53 | assert all(isinstance(k, tuple) for k in grouped.keys()) 54 | assert any(r.selector == "#login" for lst in grouped.values() for r in lst.root) 55 | 56 | 57 | def test_grouped_by_selector_combines_same_selector(coverage_result_list: CoverageResultList): 58 | grouped = coverage_result_list.grouped_by_selector 59 | key = (Selector("#login"), SelectorType.CSS) 60 | assert key in grouped 61 | assert isinstance(grouped[key], CoverageResultList) 62 | assert len(grouped[key].root) == 2 63 | 64 | 65 | # ------------------------------- 66 | # TEST: total_actions & total_selectors 67 | # ------------------------------- 68 | 69 | def test_total_actions_and_total_selectors(coverage_result_list: CoverageResultList): 70 | assert coverage_result_list.total_actions == 3 71 | assert coverage_result_list.total_selectors == len(coverage_result_list.grouped_by_selector) 72 | 73 | 74 | # ------------------------------- 75 | # TEST: count_action 76 | # ------------------------------- 77 | 78 | def test_count_action_returns_correct_values(coverage_result_list: CoverageResultList): 79 | assert coverage_result_list.count_action(ActionType.CLICK) == 2 80 | assert coverage_result_list.count_action(ActionType.HOVER) == 1 81 | 82 | 83 | def test_count_action_returns_zero_for_missing_type(coverage_result_list: CoverageResultList): 84 | assert coverage_result_list.count_action(ActionType.VISIBLE) == 0 85 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/tracker/test_storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from ui_coverage_tool.config import Settings 7 | from ui_coverage_tool.src.tracker.models import CoverageResult, CoverageResultList 8 | from ui_coverage_tool.src.tracker.storage import UICoverageTrackerStorage 9 | 10 | 11 | # ------------------------------- 12 | # TEST: save 13 | # ------------------------------- 14 | 15 | def test_save_creates_json_file( 16 | settings: Settings, 17 | coverage_result: CoverageResult, 18 | coverage_tracker_storage: UICoverageTrackerStorage, 19 | ) -> None: 20 | coverage_tracker_storage.save(coverage_result) 21 | 22 | files: list[Path] = list(settings.results_dir.glob("*.json")) 23 | assert len(files) == 1 24 | 25 | content: dict = json.loads(files[0].read_text()) 26 | assert content["app"] == "ui-app" 27 | assert content["selector"] == "#submit" 28 | assert content["action_type"] == coverage_result.action_type 29 | assert content["selector_type"] == coverage_result.selector_type 30 | 31 | 32 | def test_save_creates_dir_if_missing( 33 | caplog: pytest.LogCaptureFixture, 34 | tmp_path: Path, 35 | settings: Settings, 36 | coverage_result: CoverageResult, 37 | ) -> None: 38 | results_dir: Path = tmp_path / "nested" / "results" 39 | settings.results_dir = results_dir 40 | storage = UICoverageTrackerStorage(settings) 41 | 42 | assert not results_dir.exists() 43 | 44 | storage.save(coverage_result) 45 | 46 | assert results_dir.exists() 47 | assert any("creating" in msg.lower() for msg in caplog.messages) 48 | assert list(results_dir.glob("*.json")) 49 | 50 | 51 | def test_save_logs_error( 52 | caplog: pytest.LogCaptureFixture, 53 | monkeypatch: pytest.MonkeyPatch, 54 | coverage_result: CoverageResult, 55 | coverage_tracker_storage: UICoverageTrackerStorage, 56 | ) -> None: 57 | def fake_write_text(_: str) -> None: 58 | raise OSError("Disk full") 59 | 60 | monkeypatch.setattr(Path, "write_text", fake_write_text) 61 | coverage_tracker_storage.save(coverage_result) 62 | 63 | assert any("error saving coverage data" in msg.lower() for msg in caplog.messages) 64 | 65 | 66 | # ------------------------------- 67 | # TEST: load 68 | # ------------------------------- 69 | 70 | def test_load_returns_empty_if_dir_missing( 71 | caplog: pytest.LogCaptureFixture, 72 | tmp_path: Path, 73 | settings: Settings 74 | ) -> None: 75 | settings.results_dir = tmp_path / "missing" 76 | storage = UICoverageTrackerStorage(settings) 77 | 78 | result: CoverageResultList = storage.load() 79 | 80 | assert isinstance(result, CoverageResultList) 81 | assert result.root == [] 82 | assert any("does not exist" in msg.lower() or "results directory" in msg.lower() for msg in caplog.messages) 83 | 84 | 85 | def test_load_reads_json_files( 86 | settings: Settings, 87 | coverage_result: CoverageResult, 88 | coverage_tracker_storage: UICoverageTrackerStorage, 89 | ) -> None: 90 | file_path: Path = settings.results_dir / "one.json" 91 | settings.results_dir.mkdir(exist_ok=True) 92 | file_path.write_text(coverage_result.model_dump_json()) 93 | 94 | result: CoverageResultList = coverage_tracker_storage.load() 95 | 96 | assert isinstance(result, CoverageResultList) 97 | assert len(result.root) == 1 98 | parsed: CoverageResult = result.root[0] 99 | assert parsed.selector == "#submit" 100 | assert parsed.action_type == coverage_result.action_type 101 | 102 | 103 | def test_load_ignores_non_json_files( 104 | settings: Settings, 105 | coverage_result: CoverageResult, 106 | coverage_tracker_storage: UICoverageTrackerStorage, 107 | ) -> None: 108 | settings.results_dir.mkdir(exist_ok=True) 109 | (settings.results_dir / "valid.json").write_text(coverage_result.model_dump_json()) 110 | (settings.results_dir / "note.txt").write_text("not json") 111 | 112 | result: CoverageResultList = coverage_tracker_storage.load() 113 | 114 | assert len(result.root) == 1 115 | assert all(isinstance(r, CoverageResult) for r in result.root) 116 | assert result.root[0].app == "ui-app" 117 | -------------------------------------------------------------------------------- /ui_coverage_tool/tests/suites/src/reports/test_storage.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from ui_coverage_tool.config import Settings 7 | from ui_coverage_tool.src.reports.models import CoverageReportState 8 | from ui_coverage_tool.src.reports.storage import UIReportsStorage 9 | 10 | 11 | # ------------------------------- 12 | # FIXTURES 13 | # ------------------------------- 14 | 15 | @pytest.fixture 16 | def fake_html_template(tmp_path: Path) -> Path: 17 | html_path = tmp_path / "template.html" 18 | html_path.write_text( 19 | '' 20 | '' 21 | '', 22 | encoding='utf-8', 23 | ) 24 | return html_path 25 | 26 | 27 | # ------------------------------- 28 | # TEST: inject_state_into_html 29 | # ------------------------------- 30 | 31 | def test_inject_state_into_html_replaces_script_tag( 32 | monkeypatch: pytest.MonkeyPatch, 33 | reports_storage: UIReportsStorage, 34 | fake_html_template: Path, 35 | coverage_report_state: CoverageReportState, 36 | ): 37 | monkeypatch.setattr(Settings, "html_report_template_file", fake_html_template) 38 | 39 | result_html = reports_storage.inject_state_into_html(coverage_report_state) 40 | 41 | assert ' 102 | ``` 103 | 104 | That’s it. No other setup required. Without this script, the coverage report will not be able to highlight elements. 105 | 106 | ## Usage 107 | 108 | Below are examples of how to use the tool with two popular UI automation frameworks: `Playwright` and `Selenium`. In 109 | both cases, coverage data is automatically saved to the `./coverage-results` folder after each call to `track_coverage`. 110 | 111 | ### Playwright 112 | 113 | ```python 114 | from playwright.sync_api import sync_playwright 115 | 116 | # Import the main components of the tool: 117 | # - UICoverageTracker — the main class for tracking coverage 118 | # - SelectorType — type of selector (CSS, XPATH) 119 | # - ActionType — type of action (CLICK, FILL, CHECK_VISIBLE, etc.) 120 | from ui_coverage_tool import UICoverageTracker, SelectorType, ActionType 121 | 122 | # Create an instance of the tracker. 123 | # The `app` value should match the name in your UI_COVERAGE_APPS config. 124 | tracker = UICoverageTracker(app="my-ui-app") 125 | 126 | with sync_playwright() as playwright: 127 | browser = playwright.chromium.launch() 128 | page = browser.new_page() 129 | page.goto("https://my-ui-app.com/login") 130 | 131 | username_input = page.locator("#username-input") 132 | username_input.fill('user@example.com') 133 | 134 | # Track this interaction with the tracker 135 | tracker.track_coverage( 136 | selector='#username-input', # The selector (CSS) 137 | action_type=ActionType.FILL, # The action type: FILL 138 | selector_type=SelectorType.CSS # The selector type: CSS 139 | ) 140 | 141 | login_button = page.locator('//button[@id="login-button"]') 142 | login_button.click() 143 | 144 | # Track the click action with the tracker 145 | tracker.track_coverage( 146 | selector='//button[@id="login-button"]', # The selector (XPath) 147 | action_type=ActionType.CLICK, # The action type: CLICK 148 | selector_type=SelectorType.XPATH # The selector type: XPath 149 | ) 150 | 151 | ``` 152 | 153 | Quick summary: 154 | 155 | - Call `tracker.track_coverage()` after each user interaction. 156 | - Provide the selector, action type, and selector type. 157 | - The tool automatically stores tracking data as JSON files. 158 | 159 | ### Selenium 160 | 161 | ```python 162 | from selenium import webdriver 163 | from ui_coverage_tool import UICoverageTracker, SelectorType, ActionType 164 | 165 | driver = webdriver.Chrome() 166 | 167 | # Initialize the tracker with the app key 168 | tracker = UICoverageTracker(app="my-ui-app") 169 | 170 | driver.get("https://my-ui-app.com/login") 171 | 172 | username_input = driver.find_element("css selector", "#username-input") 173 | username_input.send_keys("user@example.com") 174 | 175 | # Track the fill action 176 | tracker.track_coverage('#username-input', ActionType.FILL, SelectorType.CSS) 177 | 178 | login_button = driver.find_element("xpath", '//button[@id="login-button"]') 179 | login_button.click() 180 | 181 | # Track the click action 182 | tracker.track_coverage('//button[@id="login-button"]', ActionType.CLICK, SelectorType.XPATH) 183 | ``` 184 | 185 | ### Coverage Report Generation 186 | 187 | After every call to `tracker.track_coverage(...)`, the tool automatically stores coverage data in 188 | the `./coverage-results/` directory as JSON files. You don’t need to manually manage the folder — it’s created and 189 | populated automatically. 190 | 191 | ``` 192 | ./coverage-results/ 193 | ├── 0a8b92e9-66e1-4c04-aa48-9c8ee28b99fa.json 194 | ├── 0a235af0-67ae-4b62-a034-a0f551c9ebb5.json 195 | └── ... 196 | ``` 197 | 198 | Once your tests are complete and coverage data has been collected, generate a final interactive report using this 199 | command: 200 | 201 | ```shell 202 | ui-coverage-tool save-report 203 | ``` 204 | 205 | This will generate: 206 | 207 | - `index.html` — a standalone HTML report that you can: 208 | - Open directly in your browser 209 | - Share with your team 210 | - Publish to GitHub Pages / GitLab Pages 211 | - `coverage-report.json` — a structured JSON report that can be used for: 212 | - Printing a coverage summary in CI/CD logs 213 | - Sending metrics to external systems 214 | - Custom integrations or dashboards 215 | 216 | **Important!** The `ui-coverage-tool save-report` command must be run from the **root of your project**, where your 217 | config files (`.env`, `ui_coverage_config.yaml`, etc.) are located. Running it from another directory may result in 218 | missing data or an empty report. 219 | 220 | ## Configuration 221 | 222 | You can configure the UI Coverage Tool using a single file: either a YAML, JSON, or `.env` file. By default, the 223 | tool looks for configuration in: 224 | 225 | - `ui_coverage_config.yaml` 226 | - `ui_coverage_config.json` 227 | - `.env` (for environment variable configuration) 228 | 229 | All paths are relative to the current working directory, and configuration is automatically loaded 230 | via [get_settings()](./ui_coverage_tool/config.py). 231 | 232 | **Important!** Files must be in the project root. 233 | 234 | ### Configuration via `.env` 235 | 236 | All settings can be declared using environment variables. Nested fields use dot notation, and all variables must be 237 | prefixed with `UI_COVERAGE_`. 238 | 239 | **Example:** [.env](docs/configs/.env.example) 240 | 241 | ```dotenv 242 | # Define the applications that should be tracked. In the case of multiple apps, they can be added in a comma-separated list. 243 | UI_COVERAGE_APPS='[ 244 | { 245 | "key": "my-ui-app", 246 | "url": "https://my-ui-app.com/login", 247 | "name": "My UI App", 248 | "tags": ["UI", "PRODUCTION"], 249 | "repository": "https://github.com/my-ui-app" 250 | } 251 | ]' 252 | 253 | # The directory where the coverage results will be saved. 254 | UI_COVERAGE_RESULTS_DIR="./coverage-results" 255 | 256 | # The file that stores the history of coverage results. 257 | UI_COVERAGE_HISTORY_FILE="./coverage-history.json" 258 | 259 | # The retention limit for the coverage history. It controls how many historical results to keep. 260 | UI_COVERAGE_HISTORY_RETENTION_LIMIT=30 261 | 262 | # Optional file paths for the HTML and JSON reports. 263 | UI_COVERAGE_HTML_REPORT_FILE="./index.html" 264 | UI_COVERAGE_JSON_REPORT_FILE="./coverage-report.json" 265 | ``` 266 | 267 | ### Configuration via YAML 268 | 269 | **Example:** [ui_coverage_config.yaml](docs/configs/ui_coverage_config.yaml) 270 | 271 | ```yaml 272 | apps: 273 | - key: "my-ui-app" 274 | url: "https://my-ui-app.com/login", 275 | name: "My UI App" 276 | tags: [ "UI", "PRODUCTION" ] 277 | repository: "https://github.com/my-ui-app" 278 | 279 | results_dir: "./coverage-results" 280 | history_file: "./coverage-history.json" 281 | history_retention_limit: 30 282 | html_report_file: "./index.html" 283 | json_report_file: "./coverage-report.json" 284 | ``` 285 | 286 | ### Configuration via JSON 287 | 288 | **Example:** [ui_coverage_config.json](docs/configs/ui_coverage_config.json) 289 | 290 | ```json 291 | { 292 | "apps": [ 293 | { 294 | "key": "my-ui-app", 295 | "url": "https://my-ui-app.com/login", 296 | "name": "My UI App", 297 | "tags": [ 298 | "UI", 299 | "PRODUCTION" 300 | ], 301 | "repository": "https://github.com/my-ui-app" 302 | } 303 | ], 304 | "results_dir": "./coverage-results", 305 | "history_file": "./coverage-history.json", 306 | "history_retention_limit": 30, 307 | "html_report_file": "./index.html", 308 | "json_report_file": "./coverage-report.json" 309 | } 310 | ``` 311 | 312 | ### Configuration Reference 313 | 314 | | Key | Description | Required | Default | 315 | |---------------------------|---------------------------------------------------------------------------|----------|---------------------------| 316 | | `apps` | List of applications to track. Each must define `key`, `name`, and `url`. | ✅ | — | 317 | | `services[].key` | Unique internal identifier for the service. | ✅ | — | 318 | | `services[].url` | Entry point URL of the app. | ✅ | — | 319 | | `services[].name` | Human-friendly name for the service (used in reports). | ✅ | — | 320 | | `services[].tags` | Optional tags used in reports for filtering or grouping. | ❌ | — | 321 | | `services[].repository` | Optional repository URL (will be shown in report). | ❌ | — | 322 | | `results_dir` | Directory to store raw coverage result files. | ❌ | `./coverage-results` | 323 | | `history_file` | File to store historical coverage data. | ❌ | `./coverage-history.json` | 324 | | `history_retention_limit` | Maximum number of historical entries to keep. | ❌ | `30` | 325 | | `html_report_file` | Path to save the final HTML report (if enabled). | ❌ | `./index.html` | 326 | | `json_report_file` | Path to save the raw JSON report (if enabled). | ❌ | `./coverage-report.json` | 327 | 328 | ### How It Works 329 | 330 | Once configured, the tool automatically: 331 | 332 | - Tracks test coverage during UI interactions. 333 | - Writes raw coverage data to `coverage-results/`. 334 | - Stores optional historical data and generates an HTML report at the end. 335 | 336 | No manual data manipulation is required – the tool handles everything automatically based on your config. 337 | 338 | ## Command-Line Interface (CLI) 339 | 340 | The UI Coverage Tool provides several CLI commands to help with managing and generating coverage reports. 341 | 342 | ### Command: `save-report` 343 | 344 | Generates a detailed coverage report based on the collected result files. This command will process all the raw coverage 345 | data stored in the `coverage-results` directory and generate an HTML report. 346 | 347 | **Usage:** 348 | 349 | ```shell 350 | ui-coverage-tool save-report 351 | ``` 352 | 353 | - This is the main command to generate a coverage report. After executing UI tests and collecting coverage data, use 354 | this command to aggregate the results into a final report. 355 | - The report is saved as an HTML file, typically named index.html, which can be opened in any browser. 356 | 357 | ### Command: `copy-report` 358 | 359 | This is an internal command mainly used during local development. It updates the report template for the generated 360 | coverage reports. It is typically used to ensure that the latest report template is available when you generate new 361 | reports. 362 | 363 | **Usage:** 364 | 365 | ```shell 366 | ui-coverage-tool copy-report 367 | ``` 368 | 369 | - This command updates the internal template used by the save-report command. It's useful if the template structure or 370 | styling has changed and you need the latest version for your reports. 371 | - This command is typically only used by developers working on the tool itself. 372 | 373 | ### Command: `print-config` 374 | 375 | Prints the resolved configuration to the console. This can be useful for debugging or verifying that the configuration 376 | file has been loaded and parsed correctly. 377 | 378 | **Usage:** 379 | 380 | ```shell 381 | ui-coverage-tool print-config 382 | ``` 383 | 384 | - This command reads the configuration file (`ui_coverage_config.yaml`, `ui_coverage_config.json`, or `.env`) 385 | and prints the final configuration values to the console. 386 | - It helps verify that the correct settings are being applied and is particularly useful if something is not working as 387 | expected. 388 | 389 | ## Troubleshooting 390 | 391 | ### The report is empty or missing data 392 | 393 | - Ensure that `track_coverage()` is called during your tests. 394 | - Make sure you run `ui-coverage-tool save-report` from the root directory. 395 | - Make sure to setup configuration correctly. 396 | - Check that the `coverage-results` directory contains `.json` files. --------------------------------------------------------------------------------