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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/run-tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,6 @@ jobs:
run: ./dockerw -n ctest --output-on-failure --test-dir build
-
name: Run integration tests
run: ./dockerw -n ./scripts/ci/run_integration_tests.sh
# GitHub-hosted runners have 4 cores; 3 workers keeps one core
# free for the OS/dbus/otbr and avoids CASE/PASE starvation.
run: ./dockerw -n ./scripts/ci/run_integration_tests.sh --parallel=3
9 changes: 9 additions & 0 deletions core/src/subsystems/matter/Matter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,15 @@ bool Matter::Start()

serverInitParams.dataModelProvider = app::CodegenDataModelProviderInstance(&storageDelegate);

#ifdef BARTON_CONFIG_MATTER_USE_RANDOM_PORT
// Let the OS assign the operational (and UDC) ports so multiple Barton
// instances can run concurrently (e.g. parallel integration tests)
// without colliding on the fixed CHIP_PORT/CHIP_UDC_PORT. Mirrors the
// commissioner factoryParams.listenPort = 0 below.
serverInitParams.operationalServicePort = 0;
serverInitParams.userDirectedCommissioningPort = 0;
#endif

if ((err = Server::GetInstance().Init(serverInitParams)) != CHIP_NO_ERROR)
{
icError("Server::Init failed: %s", err.AsString());
Expand Down
3 changes: 2 additions & 1 deletion docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ RUN pip3 install --upgrade --break-system-packages \
pygobject-stubs==2.14.0 \
setuptools==73.0.0 \
python_stdnum \
pytest
pytest \
pytest-xdist

# pygobject-stubs distributed the stub (pyi) files for standard distribution girs, but we also want its tools so we can make
# out own stubs. Those don't come with the pip package.
Expand Down
2 changes: 1 addition & 1 deletion docker/version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2.15
2.18
5 changes: 4 additions & 1 deletion scripts/ci/run_integration_tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,7 @@ echo "End of installation, starting tests"
echo "***********************************"
echo ""

"$REPO_ROOT/testing/py_test.sh" "$REPO_ROOT/testing"
# Forward any extra arguments (e.g. --parallel=<N>) to the pytest wrapper. The
# worker count is specified by the caller/CI workflow rather than hard-coded
# here, because it depends on the runner's core count (GitHub runners have 4).
"$REPO_ROOT/testing/py_test.sh" "$REPO_ROOT/testing" "$@"
72 changes: 62 additions & 10 deletions testing/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,43 @@

logger = logging.getLogger(__name__)


# Runtime-configurable wait timeouts (seconds). Defaults are tuned for serial
# runs; parallel runs raise them (see testing/py_test.sh --parallel) to tolerate
# concurrent-commissioning load. Resolved into testing/utils/timeouts.py in
# pytest_configure and forwarded to each per-test subprocess (see
# _run_in_subprocess) so the child that actually runs the test sees them.
_TIMEOUT_OPTIONS = (
("--client-ready-timeout", "client_ready", 10, "wait for the Barton client to be ready"),
("--device-added-timeout", "device_added", 5, "wait for a device to be commissioned/added"),
("--resource-value-timeout", "resource_value", 30, "wait for an expected resource value"),
)


def pytest_addoption(parser):
group = parser.getgroup("barton", "Barton integration test timeouts")
for flag, dest, default, desc in _TIMEOUT_OPTIONS:
group.addoption(
flag,
dest=dest,
type=int,
default=default,
help=f"Seconds to {desc} (default: {default}).",
)


def pytest_configure(config):
"""Register custom markers."""
"""Register custom markers and resolve the configurable wait timeouts."""
config.addinivalue_line(
"markers",
"requires_matterjs: skip test when Node.js or matter.js is not available",
)

from testing.utils import timeouts

for _flag, dest, _default, _desc in _TIMEOUT_OPTIONS:
setattr(timeouts, dest, config.getoption(dest))


# The following list of plugins are automatically loaded by pytest when running tests.
# Any fixtures defined within these modules are automatically available to all test modules.
Expand Down Expand Up @@ -106,7 +136,17 @@ def _matterjs_available() -> bool:
_has_matterjs = _matterjs_available()


@pytest.hookimpl(tryfirst=True)
def pytest_collection_modifyitems(config, items):
# Keep tests that bind the fixed zhal IPC/event ports (18443/8711) on a
# single xdist worker so they never run concurrently and collide on those
# ports. Requires xdist's loadgroup distribution (testing/py_test.sh
# --parallel enables it); harmless when running serially. Runs tryfirst so
# the marker is applied before xdist reads xdist_group during collection.
for item in items:
if "/mocks/test/zhal/" in item.nodeid or "mock_zhal_implementation" in item.fixturenames:
item.add_marker(pytest.mark.xdist_group("zhal"))

if _has_matterjs:
return

Expand Down Expand Up @@ -223,6 +263,11 @@ def _run_in_subprocess(item):
junit_fd, junit_path = tempfile.mkstemp(suffix=".xml")
os.close(junit_fd)

# xdist's loadgroup scheduling appends "@<group>" to the nodeid (e.g. from an
# xdist_group marker). Strip it so the child pytest receives a real,
# selectable nodeid. Legitimate nodeids never contain "@".
child_nodeid = item.nodeid.split("@", 1)[0]

try:
cmd = [
sys.executable,
Expand All @@ -234,7 +279,13 @@ def _run_in_subprocess(item):
"--no-header",
"-q",
f"--junit-xml={junit_path}",
item.nodeid,
# Forward the resolved wait timeouts so the child running the test
# sees the same values as this (possibly parallel) outer session.
*(
f"{flag}={item.config.getoption(dest)}"
for flag, dest, _default, _desc in _TIMEOUT_OPTIONS
),
child_nodeid,
]

# In CI, py_test.sh preloads libasan via LD_PRELOAD so Python/GI tests can
Expand Down Expand Up @@ -267,20 +318,21 @@ def _run_in_subprocess(item):
)

if outcome in ("failed", "unknown") or result.returncode != 0:
output = result.stdout + result.stderr
lines = output.strip().splitlines()

if not lines:
excerpt = "<no output captured from child pytest process>"
else:
excerpt = "\n".join(lines)
output = (result.stdout + result.stderr).strip()
excerpt = output or "<no output captured from child pytest process>"

# Wrap the captured child output in clear, greppable banners naming
# the test. Under --parallel (pytest-xdist) several failing tests'
# logs are reported together, so unambiguous per-test separators keep
# the dump parseable (grep for "BARTON TEST OUTPUT").
raise AssertionError(
"Subprocess test failed\n"
f"exit code: {result.returncode}\n"
f"cwd: {item.config.rootpath}\n"
f"command: {' '.join(cmd)}\n\n"
f"Captured output:\n{excerpt}"
f"===== BARTON TEST OUTPUT BEGIN [{child_nodeid}] =====\n"
f"{excerpt}\n"
f"===== BARTON TEST OUTPUT END [{child_nodeid}] ====="
)
finally:
try:
Expand Down
45 changes: 37 additions & 8 deletions testing/environment/base_environment_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@
from abc import ABC, abstractmethod
from pathlib import Path
from threading import Condition
import shutil
import os
import tempfile

# Get the expected gir version dynamically. There is some additional information
# about this in the top-level CMakeLists.txt file.
Expand Down Expand Up @@ -58,6 +59,7 @@
from gi.repository import BCore

from testing.credentials import network_credentials_provider
from testing.utils import timeouts

class BaseEnvironmentOrchestrator(ABC):
"""
Expand Down Expand Up @@ -90,6 +92,7 @@ class BaseEnvironmentOrchestrator(ABC):
_ready_to_commission: bool
_device_added_condition: Condition
_commissioned_device: bool
_storage_tmpdir: tempfile.TemporaryDirectory
_barton_storage_path: str
_matter_storage_path: str
_sbmd_dirs: str
Expand All @@ -103,9 +106,22 @@ def __init__(self):
self._device_added_condition = Condition()
self._commissioned_device = False

# Must match what's compiled with barton matter sdk
self._barton_storage_path = str(Path.home()) + "/.brtn-ds"
self._matter_storage_path = self._barton_storage_path + "/matter"
# Per-process storage root on tmpfs (/tmp) so concurrent tests
# (pytest-xdist) don't clobber each other's KVS / dynamic storage, and so
# nothing is left behind in the user's home directory. TemporaryDirectory
# registers a finalizer that removes the tree when this process exits --
# even if a test fails or setup raises before the fixture teardown runs.
#
# NOTE: the Matter SDK's PosixConfig ini files (chip_factory.ini,
# chip_config.ini, chip_counters.ini) still live at the compile-time
# CHIP_BARTON_CONF_DIR (~/.brtn-ds/matter) and are not isolated by this;
# only the KVS honors this runtime path. That shared dir is left in place
# (it may also be used by reference-app runs) and is simply reused.
self._storage_tmpdir = tempfile.TemporaryDirectory(
prefix="brtn-ds-", ignore_cleanup_errors=True
)
self._barton_storage_path = self._storage_tmpdir.name
self._matter_storage_path = os.path.join(self._barton_storage_path, "matter")
# SBMD specs directories relative to workspace root
workspace_root = Path(__file__).parent.parent.parent
production_specs = str(
Expand Down Expand Up @@ -182,10 +198,16 @@ def _on_status_changed(self, _object, statusEvent: BCore.StatusEvent):
self._ready_to_commission = True
self._ready_for_devices_condition.notify_all()

def wait_for_client_to_be_ready(self, timeout=10):
def wait_for_client_to_be_ready(self, timeout=None):
"""
Waits for the Barton client to be ready before proceeding with the test.

When timeout is None the runtime-configurable default is used
(timeouts.client_ready; see testing/utils/timeouts.py).
"""
if timeout is None:
timeout = timeouts.client_ready

with self._ready_for_devices_condition:
if not self._ready_to_commission:
self._ready_for_devices_condition.wait(timeout=timeout)
Expand All @@ -207,17 +229,22 @@ def _on_device_added(self, _object, device: BCore.DeviceAddedEvent):
self._commissioned_device = True
self._device_added_condition.notify_all()

def wait_for_device_added(self, timeout=5):
def wait_for_device_added(self, timeout=None):
"""
Waits for a device to be added and commissioned within a specified timeout.

Args:
timeout (int, optional): The maximum time to wait for the device to be
commissioned, in seconds. Defaults to 5.
commissioned, in seconds. When None the runtime-configurable
default is used (timeouts.device_added; see
testing/utils/timeouts.py).
Raises:
AssertionError: If the device is not commissioned within the specified
timeout.
"""
if timeout is None:
timeout = timeouts.device_added

with self._device_added_condition:
if not self._commissioned_device:
self._device_added_condition.wait(timeout=timeout)
Expand All @@ -232,7 +259,9 @@ def _cleanup(self):
"""
self._barton_client.stop()
self._barton_client = None
shutil.rmtree(self._barton_storage_path, ignore_errors=True)
# Remove the tmpfs storage now; the TemporaryDirectory finalizer is a
# safety net for paths where this method isn't reached.
self._storage_tmpdir.cleanup()

self._ready_for_devices_condition = None
self._device_added_condition = None
Expand Down
86 changes: 86 additions & 0 deletions testing/helpers/matter/code_generators.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,92 @@
import random
from stdnum.verhoeff import calc_check_digit

# Base38 alphabet used by the Matter QR code payload encoding (section 5.1.3.1
# of the Matter Core Specification).
_BASE38_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-."

# Discovery capability bitmask values (section 5.1.3.1, "Discovery Capabilities
# Bitmask"). kOnNetwork (bit 2) indicates the device is discoverable over
# DNS-SD on the operational/commissionable IP network.
DISCOVERY_CAPABILITY_ON_NETWORK = 0b100


def _base38_encode(data: bytes) -> str:
"""
Encodes a byte string using the Matter Base38 scheme (section 5.1.3.1 of
the Matter Core Specification). Bytes are consumed in groups of 3 (emitting
5 characters), 2 (4 characters), or 1 (2 characters), each group written
least-significant character first.
"""
result = []
i = 0
n = len(data)

while i < n:
remaining = n - i

if remaining >= 3:
value = data[i] | (data[i + 1] << 8) | (data[i + 2] << 16)
chars = 5
i += 3
elif remaining == 2:
value = data[i] | (data[i + 1] << 8)
chars = 4
i += 2
else:
value = data[i]
chars = 2
i += 1

for _ in range(chars):
result.append(_BASE38_CHARS[value % 38])
value //= 38

return "".join(result)


def generate_qr_code(
discriminator: int,
passcode: int,
vendor_id: int = 0,
product_id: int = 0,
discovery_capabilities: int = DISCOVERY_CAPABILITY_ON_NETWORK,
commissioning_flow: int = 0,
version: int = 0,
) -> str:
"""
Generates a Matter QR code setup payload ("MT:..." string) according to
section 5.1.3 of the Matter Core Specification.

Unlike the manual pairing code, the QR payload carries the full 12-bit
discriminator. A commissioner therefore matches the exact device rather
than any device that happens to share the same 4-bit short discriminator,
which is essential when many devices advertise concurrently.
"""
# Fields are packed least-significant-bit first, in payload order (section
# 5.1.3.1, Table 38). Total width is 88 bits (11 bytes) including padding.
fields = [
(version, 3),
(vendor_id, 16),
(product_id, 16),
(commissioning_flow, 2),
(discovery_capabilities, 8),
(discriminator, 12),
(passcode, 27),
(0, 4), # padding
]

bit_buffer = 0
bit_count = 0

for value, length in fields:
bit_buffer |= (value & ((1 << length) - 1)) << bit_count
bit_count += length

data = bit_buffer.to_bytes((bit_count + 7) // 8, byteorder="little")

return "MT:" + _base38_encode(data)


def generate_manual_pairing_code(
discriminator: int,
Expand Down
Loading
Loading