Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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
1 change: 1 addition & 0 deletions client/.gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
dist
docs/build
junit_result.xml
1 change: 1 addition & 0 deletions client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"couchbase==4.3.4",
"cryptography==44.0.0",
"deepdiff==8.6.1",
"junitparser==4.0.2",
"netifaces==0.11.0",
"opentelemetry-api==1.29.0",
"opentelemetry-exporter-otlp-proto-common==1.29.0",
Expand Down
144 changes: 135 additions & 9 deletions client/src/cbltest/greenboarduploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,50 @@
from couchbase.auth import PasswordAuthenticator
from couchbase.cluster import Cluster
from couchbase.options import ClusterOptions
from junitparser import JUnitXml
from pydantic import BaseModel, ConfigDict, Field

from cbltest.api.syncgateway import CouchbaseVersion
from cbltest.logging import cbl_info, cbl_warning


def count_from_junit_xml(xml_path: Path) -> tuple[int, int, int]:
"""Return ``(passed, failed, errored)`` summed across every
``<testsuite>`` in a JUnit XML file at ``xml_path``.

Per junitparser's mapping of pytest output:

- ``passed`` = ``tests - failures - errors - skipped`` (the cases that
ran and asserted successfully).
- ``failed`` = ``failures`` (cases that ran but assertion-failed).
- ``errored`` = ``errors`` (cases that never executed — collection
failure, setup crash, etc.).

Failures and errors are kept separate so callers can distinguish
"tests legitimately failed" from "harness was broken and tests didn't
run." Skipped cases count as neither pass nor fail.

Used by the greenboard pytest fixture to derive upload counts from
pytest's ``--junitxml`` output instead of in-process hook-driven
counters.

Raises the underlying exception if the file can't be read or parsed.
"""
xml = JUnitXml.fromfile(str(xml_path))
total_pass = 0
total_fail = 0
total_error = 0
for suite in xml:
tests = suite.tests or 0
failures = suite.failures or 0
errors = suite.errors or 0
skipped = suite.skipped or 0
total_pass += max(0, tests - failures - errors - skipped)
total_fail += failures
total_error += errors
return total_pass, total_fail, total_error


class RunResult(BaseModel):
"""
Store the information for a test run in greenboard
Expand All @@ -33,13 +71,23 @@ class RunResult(BaseModel):

class GreenboardUploader:
"""
A class for uploading results to a specified greenboard server bucket.

Supports two modes:
- **Normal mode**: uploads results for regular test sessions.
- **Upgrade mode** (``SGW_UPGRADE_VERSIONS`` is set): uploads per-step
upgrade results with ``upgradePath``, ``upgradeFrom``, and ``upgradeTo``
fields under ``platform="sgw-upgrade"``.
Uploads test-run results to a greenboard couchbase bucket.

Three entry points, each for a different upload-time context:

- :py:meth:`upload` — in-process pytest fixture path. Pass/fail counts
come from :py:meth:`pytest_runtest_makereport` tallying ``TestReport``
objects as tests run within the same session.
- :py:meth:`upload_from_junit_file` — JUnit-XML-driven path. Pass/fail
counts come from a JUnit XML file produced by pytest's
``--junitxml``. Falls back to :py:meth:`upload` if the file is
absent. Decouples count-collection from in-process pytest hooks
(needed for pipelines whose pytest invocations are orchestrated
from a shell script).
- :py:meth:`record_upgrade_step` + :py:meth:`upload_upgrade_batch` —
legacy upgrade-job path. Each pytest session appends to a JSON
state file; the wrapper script invokes ``upload_upgrade_batch`` at
the end to emit one aggregate ``platform="sgw-upgrade"`` doc.
"""

def __init__(self, url: str, username: str, password: str):
Expand Down Expand Up @@ -92,6 +140,9 @@ def upload(
os_name: str,
version: str,
sgw_version: CouchbaseVersion | None,
*,
pass_count: int | None = None,
fail_count: int | None = None,
):
"""
Uploads the results using the specified platform and version. The reason that they
Expand All @@ -101,11 +152,21 @@ def upload(
:param platform: The platform name (e.g. couchbase-lite-net) as specified by the test server
:param version: The version string (e.g. 3.2.0-b0136, etc) as specified by the test server
:param sgw_version: The parsed SGW CouchbaseVersion object, or None if unavailable
:param pass_count: Optional override for the pass count. When ``None``
(default), uses the in-process counter populated by
:py:meth:`pytest_runtest_makereport`. When provided (e.g. the
greenboard fixture derived counts from a JUnit XML), the supplied
value is used instead.
:param fail_count: Optional override for the fail count. Same
semantics as ``pass_count``.
"""
if self.__overall_fail:
cbl_warning("Overall result is failure, skipping upload...")
return

resolved_pass = pass_count if pass_count is not None else self.__pass_count
resolved_fail = fail_count if fail_count is not None else self.__fail_count

parsed_version = "0.0.0"
parsed_build = 0
sgw_version_str = "n/a"
Expand Down Expand Up @@ -143,13 +204,78 @@ def upload(
build=parsed_build,
version=parsed_version,
sgwVersion=sgw_version_str,
failCount=self.__fail_count,
passCount=self.__pass_count,
failCount=resolved_fail,
Comment thread
borrrden marked this conversation as resolved.
passCount=resolved_pass,
platform=platform,
os=os_name,
)
)

def upload_from_junit_file(
self,
junit_output: Path,
platform: str,
os_name: str,
version: str,
sgw_version: CouchbaseVersion | None,
) -> None:
"""
Upload one greenboard doc whose pass/fail counts come from a JUnit
XML file produced by pytest.

Policy:
- If ``junit_output`` doesn't exist, fall back to the in-process
counter (``self.upload()`` without count overrides). This covers
the cold-start case where pytest never wrote an XML — e.g.
``--junitxml`` was explicitly disabled by the caller.
- If the file exists but reports zero tests collected, skip the
upload (selector misfired or the harness never executed
anything; no signal worth recording).
- If every collected test errored before running (zero passes,
zero failures, errors > 0), skip the upload — that's a
harness/setup failure, not a real test result. A run with
legitimate assertion failures DOES upload; a red bar is the
signal that those tests broke and we want to see it.
- Otherwise, upload with the JUnit-derived counts. Errors are
collapsed into ``failCount`` on the doc since the greenboard
schema doesn't distinguish failures from errors.

Implementation notes:
- Parse errors from a malformed JUnit XML propagate to the
caller — a bad XML is a real bug and should fail the Jenkins
job loudly.
- An absent JUnit XML is *not* an error; it's the documented
fall-back path described in the first bullet.
"""
if not junit_output.is_file():
# Pytest didn't write an XML for this session; use the in-process
# counter populated by pytest_runtest_makereport instead.
self.upload(platform, os_name, version, sgw_version)
return

junit_pass, junit_fail, junit_error = count_from_junit_xml(junit_output)
if junit_pass + junit_fail + junit_error == 0:
cbl_info(
f"Greenboard: JUnit XML at {junit_output} reports no tests collected; "
"skipping upload"
)
return
if junit_pass == 0 and junit_fail == 0 and junit_error > 0:
cbl_info(
f"Greenboard: all {junit_error} tests errored before running "
"(harness failure); skipping upload"
)
return

self.upload(
platform,
os_name,
version,
sgw_version,
pass_count=junit_pass,
fail_count=junit_fail + junit_error,
)

def record_upgrade_step(
self,
results_file: str,
Expand Down
48 changes: 42 additions & 6 deletions client/src/cbltest/plugins/greenboard_fixture.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
from pathlib import Path

import pytest
import pytest_asyncio
Comment thread
vipbhardwaj marked this conversation as resolved.
Expand Down Expand Up @@ -68,9 +69,9 @@ async def greenboard(cblpytest: CBLPyTest, pytestconfig: pytest.Config):
if len(cblpytest.sync_gateways) > 0:
try:
sgw_version = await cblpytest.sync_gateways[0].get_version()
except Exception as ve:
except Exception as e:
cbl_warning(
f"Could not fetch SGW version for upgrade record: {ve}; "
f"Could not fetch SGW version for upgrade record: {e}; "
"recording iteration with sgw_version=None"
)
uploader.record_upgrade_step(
Expand All @@ -96,10 +97,27 @@ async def greenboard(cblpytest: CBLPyTest, pytestconfig: pytest.Config):
if "systemName" in test_server_info.device:
os_name = test_server_info.device["systemName"]
if len(cblpytest.sync_gateways) > 0:
sgw_version = await cblpytest.sync_gateways[0].get_version()
uploader.upload(test_platform, os_name, library_version, sgw_version)
except Exception as e:
cbl_warning(f"Failed to upload results to Greenboard: {e}")
try:
sgw_version = await cblpytest.sync_gateways[0].get_version()
except Exception as e:
cbl_warning(f"Could not fetch SGW version for greenboard doc: {e}")
xmlpath = pytestconfig.option.xmlpath
if xmlpath:
uploader.upload_from_junit_file(
Path(xmlpath),
test_platform,
os_name,
library_version,
sgw_version,
)
else:
# No --junitxml configured. Normally our pytest_configure
# hook defaults this to "junit_result.xml", but it doesn't
# fire for synthetic Configs (e.g. ones built via
# pytest.Config.fromdictargs in unit tests). Fall back to
# the in-process counter — mirrors upload_from_junit_file's
# file-missing branch.
uploader.upload(test_platform, os_name, library_version, sgw_version)
finally:
pytestconfig.pluginmanager.unregister(uploader)

Expand All @@ -120,3 +138,21 @@ def pytest_addoption(parser: pytest.Parser) -> None:
"(e.g. '3.3.0,4.0.1,4.1.0'). First is the baseline, rest are upgrade "
"targets. Triggers sgw-upgrade platform upload.",
)


def pytest_configure(config: pytest.Config) -> None:
"""Default ``--junitxml=junit_result.xml`` so the greenboard fixture's
session-finish step can read pass/fail counts from the XML.

Doing this in code (instead of via ``addopts`` in ``client/pyproject.toml``)
is necessary because pytest's rootdir discovery walks up from the cwd and
typically picks the repo-root ``pyproject.toml`` for production runs from
``tests/QE`` or ``tests/dev_e2e`` — never reaching ``client/pyproject.toml``.
The plugin's entry-point registration guarantees this hook fires on every
pytest invocation that imports ``cbltest``.

Users can override with ``--junitxml=<path>`` on the CLI; pytest's
last-wins behavior leaves the explicit flag in charge.
"""
if not getattr(config.option, "xmlpath", None):
config.option.xmlpath = "junit_result.xml"
15 changes: 11 additions & 4 deletions client/tests/test_greenboarduploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import inspect
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import Literal, cast
from unittest.mock import MagicMock, patch

Expand Down Expand Up @@ -130,7 +131,10 @@ def _make_cblpytest(


def _make_pytestconfig(*, no_upload: bool = False) -> pytest.Config:
args = ["--config", "tests/empty_config.json"]
# Resolve relative to this test file so the helper works regardless of
# pytest's cwd. A cwd-relative "tests/empty_config.json" only worked
# when pytest was invoked from the client/ directory.
args = ["--config", str(Path(__file__).with_name("empty_config.json"))]
if no_upload:
args.append("--no-result-upload")
return pytest.Config.fromdictargs({}, args)
Comment thread
vipbhardwaj marked this conversation as resolved.
Expand Down Expand Up @@ -480,16 +484,19 @@ async def test_only_sync_gateway_no_test_server(self):
)

@pytest.mark.asyncio
async def test_upload_exception_is_caught_and_plugin_unregistered(self):
"""An exception from _upload_document is swallowed; the finally block still unregisters."""
async def test_upload_exception_propagates_and_plugin_unregistered(self):
"""An exception from _upload_document propagates (fail-loud policy);
the finally block still unregisters the plugin so the next session
starts clean."""
server = _make_server()
cblpytest = _make_cblpytest(test_servers=[server])
config = _make_pytestconfig()
with patch(
"cbltest.greenboarduploader.GreenboardUploader._upload_document",
side_effect=RuntimeError("connection refused"),
):
await _run_fixture(_raw_greenboard(cblpytest, config))
with pytest.raises(RuntimeError, match="connection refused"):
await _run_fixture(_raw_greenboard(cblpytest, config))
assert not any(
isinstance(p, GreenboardUploader)
for p in config.pluginmanager.get_plugins()
Expand Down
35 changes: 35 additions & 0 deletions scripts/hooks/check-commit-msg.sh
Comment thread
vipbhardwaj marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -euo pipefail

commit_msg_file="$1"
commit_msg=$(head -1 "$commit_msg_file")

# Allow merge commits
if echo "$commit_msg" | grep -qE '^Merge '; then
exit 0
fi

# Allow revert commits
if echo "$commit_msg" | grep -qE '^Revert '; then
exit 0
fi

# Conventional Commits: <type>[optional scope][!]: <description>
pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_./-]+\))?(!)?: .+'

if ! echo "$commit_msg" | grep -qE "$pattern"; then
echo "ERROR: Commit message does not follow Conventional Commits format."
echo ""
echo " Expected: <type>[optional scope]: <description>"
echo ""
echo " Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert"
echo ""
echo " Examples:"
echo " feat: add login page"
echo " fix(auth): resolve token expiry issue"
echo " docs: update README"
echo " feat!: breaking change to API"
echo ""
echo " Your message: $commit_msg"
exit 1
fi
13 changes: 12 additions & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading