diff --git a/scripts/build_env.sh b/scripts/build_env.sh index c35eafe..530a373 100755 --- a/scripts/build_env.sh +++ b/scripts/build_env.sh @@ -27,4 +27,10 @@ if [ ! -d "$PY_VENV_DIR" ]; then python3 -m venv "$PY_VENV_DIR" source "$PY_VENV_DIR/bin/activate" pip install --extra-index-url https://pypi.python.org/pypi -r $REQ_PATH + # Validation suite plugin used to verify the report-exporter + # entry-points discovery end-to-end. + FAKE_EXPORTER_DIR="$(dirname "$REQ_PATH")/../test/validation/fake_exporter" + if [ -d "$FAKE_EXPORTER_DIR" ]; then + pip install -e "$FAKE_EXPORTER_DIR" + fi fi diff --git a/src/testium/interpreter/test_report/test_report.py b/src/testium/interpreter/test_report/test_report.py index 22ed6ea..68dd6f4 100644 --- a/src/testium/interpreter/test_report/test_report.py +++ b/src/testium/interpreter/test_report/test_report.py @@ -20,6 +20,53 @@ sqlite3.register_converter('JSON', convert_json) TEST_REPORT_FILE_REV = '0.1' +def _load_text(): + from interpreter.test_report.report_export_txt import ReportExportTxt + return ReportExportTxt + +def _load_json(): + from interpreter.test_report.report_export_json import ReportExportJSON + return ReportExportJSON + +def _load_junit(): + try: + from interpreter.test_report.report_export_junit import ReportExportJUnit + return ReportExportJUnit + except ModuleNotFoundError: + raise ETUMRuntimeError( + 'Report format "junit" requires "junit_xml" — pip install junit-xml') + +def _load_html(): + try: + from interpreter.test_report.report_export_html import ReportExportHTML + return ReportExportHTML + except ModuleNotFoundError: + raise ETUMRuntimeError( + 'Report format "html" requires "lxml" — pip install lxml') + +_EXPORTER_REGISTRY: dict = { + cst.REP_TYPE_TEXT: _load_text, + cst.REP_TYPE_JSON: _load_json, + cst.REP_TYPE_JUNIT: _load_junit, + cst.REP_TYPE_HTML: _load_html, +} + +def _discover_plugins(): + try: + from importlib.metadata import entry_points + for ep in entry_points(group='testium.exporters'): + try: + cls = ep.load() + _EXPORTER_REGISTRY[ep.name] = lambda c=cls: c + print(f'[testium] Loaded report exporter plugin: "{ep.name}"') + except Exception as e: + print(f'[testium] Failed to load report exporter plugin "{ep.name}": {e}') + except Exception: + pass + +_discover_plugins() + + def tr_procedure(f): @wraps(f) def wrapper(self, *args, **kwds): @@ -82,28 +129,19 @@ class Export: else: path = os.path.join(path, fname) - if et == cst.REP_TYPE_TEXT: - from interpreter.test_report.report_export_txt import ReportExportTxt - ReportExportTxt(name, con, path, pats, keys, no_header) - elif et == cst.REP_TYPE_JSON: - from interpreter.test_report.report_export_json import ReportExportJSON - ReportExportJSON(name, con, path, pats, keys, no_header) - elif et == cst.REP_TYPE_JUNIT: - try: - from interpreter.test_report.report_export_junit import ReportExportJUnit - ReportExportJUnit(name, con, path, pats, keys, no_header) - except ModuleNotFoundError: - raise ETUMRuntimeError('"junit_xml" module not available') - elif et == cst.REP_TYPE_HTML: - try: - from interpreter.test_report.report_export_html import ReportExportHTML - ReportExportHTML(name, con, path, pats, keys, no_header) - except ModuleNotFoundError: - raise ETUMRuntimeError('"lxml" module not available') - elif et == cst.REP_TYPE_SQLITE: + if et == cst.REP_TYPE_SQLITE: pass + elif et in _EXPORTER_REGISTRY: + try: + cls = _EXPORTER_REGISTRY[et]() + cls(name, con, path, pats, keys, no_header) + except ETUMRuntimeError as e: + print(f'[report] Export skipped: {e}') else: - raise ETUMSyntaxError('Report export not recognized') + available = ', '.join( + sorted(_EXPORTER_REGISTRY.keys()) + [cst.REP_TYPE_SQLITE]) + print(f'[report] Export skipped: format "{et}" not found. ' + f'Available: {available}') class TestReport: TEST_COLS = [[cst.DB_TEST_TIMESTAMP_START, 'INT'], diff --git a/test/validation/fake_exporter/fake_exporter/__init__.py b/test/validation/fake_exporter/fake_exporter/__init__.py new file mode 100644 index 0000000..6831933 --- /dev/null +++ b/test/validation/fake_exporter/fake_exporter/__init__.py @@ -0,0 +1,42 @@ +"""CSV report exporter — used as a real plugin by the testium validation suite. + +Demonstrates the contract: take the SQLite connection, output path, optional +name/key filters, and produce the output. Has no dependency on testium +internals. +""" + +import csv + + +class FakeExporter: + COLUMNS = [ + 'timestamp_start', + 'test_id', + 'parent_id', + 'level', + 'test_name', + 'test_type', + 'report_key', + 'result', + 'message', + 'duration', + ] + + def __init__(self, name, con, path, pats, keys, no_header=False): + clauses = [] + for p in pats: + clauses.append(f'test_name LIKE "{p}"') + for k in keys: + clauses.append(f'report_key LIKE "{k}"') + where = ('WHERE ' + ' OR '.join(clauses) + ' ') if clauses else '' + cols = ', '.join(self.COLUMNS) + rows = con.execute( + f'SELECT {cols} FROM tests {where}ORDER BY timestamp_start' + ).fetchall() + + with open(path, 'w', newline='', encoding='utf-8') as f: + writer = csv.writer(f) + if not no_header: + writer.writerow(self.COLUMNS) + for row in rows: + writer.writerow(row) diff --git a/test/validation/fake_exporter/pyproject.toml b/test/validation/fake_exporter/pyproject.toml new file mode 100644 index 0000000..2d9b5c6 --- /dev/null +++ b/test/validation/fake_exporter/pyproject.toml @@ -0,0 +1,14 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "testium-fake-exporter" +version = "0.1.0" +description = "Fake report exporter used by testium validation suite" + +[project.entry-points."testium.exporters"] +fake_format = "fake_exporter:FakeExporter" + +[tool.setuptools.packages.find] +where = ["."] diff --git a/test/validation/items/report_plugin/file_check.py b/test/validation/items/report_plugin/file_check.py new file mode 100644 index 0000000..36b2aab --- /dev/null +++ b/test/validation/items/report_plugin/file_check.py @@ -0,0 +1,8 @@ +import os + + +def file_contains(path, text): + if not os.path.isfile(path): + return False + with open(path, 'r') as f: + return text in f.read() diff --git a/test/validation/items/report_plugin/param.yaml b/test/validation/items/report_plugin/param.yaml new file mode 100644 index 0000000..0af0f7f --- /dev/null +++ b/test/validation/items/report_plugin/param.yaml @@ -0,0 +1 @@ +no_param: Null diff --git a/test/validation/items/report_plugin/test.tum b/test/validation/items/report_plugin/test.tum new file mode 100644 index 0000000..b2c8aec --- /dev/null +++ b/test/validation/items/report_plugin/test.tum @@ -0,0 +1,23 @@ +- report: + name: Unknown exporter is skipped (must pass) + key: $(test)_PASS + export: + - definitely_not_a_format: + path: $(validation_report_path)$(psep)$(test)_unknown.txt + +- report: + name: Plugin exporter from entry-points (fake_format CSV) + key: $(test)_PASS + export: + - fake_format: + path: $(validation_report_path)$(psep)$(test)_fake.csv + +- py_func: + name: Check fake_format CSV content + file: $(test_path)$(psep)file_check.py + func_name: file_contains + key: $(test)_PASS + param: + - $(validation_report_path)$(psep)$(test)_fake.csv + - "Test preparation,Group" + expected_result: True