Skip to content

Commit 3404e48

Browse files
uodate
1 parent b8331b4 commit 3404e48

3 files changed

Lines changed: 181 additions & 410 deletions

File tree

source/isaaclab/changelog.d/mataylor-kit-test-reuse-fixture.skip

Whitespace-only changes.

tools/_kit_session_plugin.py

Lines changed: 42 additions & 192 deletions
Original file line numberDiff line numberDiff line change
@@ -3,56 +3,61 @@
33
#
44
# SPDX-License-Identifier: BSD-3-Clause
55

6-
"""Pytest plugin for shared Kit sessions across multiple test files.
6+
"""Pytest plugin for Kit session reuse across a batch of test files.
77
88
Loaded via ``-p _kit_session_plugin`` in batch subprocess commands built by
9-
``tools/conftest.py`` when :envvar:`ISAACLAB_KIT_SESSION_FILES` is greater
10-
than 1. It should never be loaded in single-file subprocess invocations.
11-
12-
Responsibilities
13-
----------------
14-
1. **Kit session reuse** — ``pytest_configure`` monkey-patches
15-
:class:`~isaaclab.app.AppLauncher.__init__` so that the first call in the
16-
process starts Kit normally while every subsequent call (from other test
17-
file modules imported during collection) copies all attributes from the
18-
first instance instead of launching a second :class:`SimulationApp`. The
19-
patch is applied before pytest begins collecting and importing test modules,
20-
which is the only window in which it can intercept module-level
21-
``AppLauncher(...)`` calls.
22-
23-
2. **Per-file JUnit XML reports** — ``pytest_runtest_logreport`` accumulates
24-
test results and the module-scoped autouse fixture ``_kit_module_fence``
25-
writes one ``tests/test-reports-<slug>.xml`` per test file in its teardown
26-
phase. This replaces the ``--junitxml`` flag (which would write a single
27-
combined report) and preserves the per-file report layout expected by
28-
``tools/conftest.py``.
29-
30-
3. **Kit/USD state reset** — ``_kit_module_fence`` calls
31-
:func:`_reset_kit_state` in teardown so residual USD prims, timeline
32-
position, and physics state from file *N* do not bleed into file *N+1*.
9+
``tools/conftest.py``. It should never be loaded in single-file runs.
10+
11+
The module starts a single Kit / SimulationApp at **import time** — before
12+
pytest begins collecting test modules. This means all subsequent
13+
``from isaaclab.* import ...`` statements in collected test files land in an
14+
already-running Kit process.
15+
16+
``AppLauncher.__init__`` is then patched so that every module-level
17+
``simulation_app = AppLauncher(...).app`` call in a collected test file
18+
silently reuses the shared launcher instead of starting a second
19+
SimulationApp.
20+
21+
``set ISAACLAB_BATCH_CAMERAS=1`` in the subprocess environment to start Kit
22+
with camera support enabled (used for batches that contain camera tests).
3323
"""
3424

3525
from __future__ import annotations
3626

3727
import os
3828

3929
import pytest
40-
from junitparser import Error, Failure, JUnitXml, Skipped, TestCase, TestSuite
30+
31+
# ---------------------------------------------------------------------------
32+
# Start Kit at import time (before pytest collection)
33+
# ---------------------------------------------------------------------------
34+
35+
from isaaclab.app import AppLauncher as _AppLauncher
36+
37+
_enable_cameras = os.environ.get("ISAACLAB_BATCH_CAMERAS") == "1"
38+
_launcher = _AppLauncher(headless=True, enable_cameras=_enable_cameras)
39+
40+
# Patch so every subsequent AppLauncher() call reuses the shared session.
41+
_original_init = _AppLauncher.__init__
42+
43+
44+
def _shared_init(self, launcher_args=None, **kwargs):
45+
self.__dict__.update(_launcher.__dict__)
46+
47+
48+
_AppLauncher.__init__ = _shared_init
49+
50+
# Prevent test teardown from closing the shared SimulationApp.
51+
_launcher.app.close = lambda *a, **kw: None
4152

4253

4354
# ---------------------------------------------------------------------------
44-
# Kit state reset
55+
# Kit state reset between files
4556
# ---------------------------------------------------------------------------
4657

4758

4859
def _reset_kit_state() -> None:
49-
"""Reset Kit/USD state between test files in a shared session.
50-
51-
Opens a fresh empty stage and stops the timeline so that prims, physics
52-
state, and timeline position from the previous test file do not bleed into
53-
the next one. All errors are suppressed — a reset failure should not
54-
abort the entire batch run.
55-
"""
60+
"""Reset Kit/USD state between test files in a shared session."""
5661
try:
5762
import omni.timeline
5863

@@ -68,42 +73,6 @@ def _reset_kit_state() -> None:
6873
pass
6974

7075

71-
# ---------------------------------------------------------------------------
72-
# AppLauncher singleton patch
73-
# ---------------------------------------------------------------------------
74-
75-
_shared_launcher: object = None
76-
77-
78-
def pytest_configure(config) -> None:
79-
"""Patch AppLauncher.__init__ to reuse the first Kit session."""
80-
global _shared_launcher
81-
82-
try:
83-
from isaaclab.app import AppLauncher
84-
except Exception:
85-
return
86-
87-
original_init = AppLauncher.__init__
88-
89-
def _shared_init(self, launcher_args=None, **kwargs):
90-
global _shared_launcher
91-
if _shared_launcher is not None:
92-
self.__dict__.update(_shared_launcher.__dict__)
93-
return
94-
original_init(self, launcher_args, **kwargs)
95-
_shared_launcher = self
96-
# Guard: prevent test files from closing the shared SimulationApp.
97-
# Any call to simulation_app.close() in a batch file's teardown would
98-
# shut down Kit for all remaining files in the batch, causing cryptic
99-
# import/startup errors instead of a clear failure message.
100-
app = getattr(self, "app", None)
101-
if app is not None:
102-
app.close = lambda *a, **kw: None
103-
104-
AppLauncher.__init__ = _shared_init
105-
106-
10776
# ---------------------------------------------------------------------------
10877
# Fixtures
10978
# ---------------------------------------------------------------------------
@@ -112,130 +81,11 @@ def _shared_init(self, launcher_args=None, **kwargs):
11281
@pytest.fixture(scope="session", autouse=True)
11382
def _kit_session():
11483
"""Hold the shared Kit session alive for the entire batch."""
115-
yield
84+
yield _launcher
11685

11786

11887
@pytest.fixture(scope="module", autouse=True)
119-
def _kit_module_fence(request):
120-
"""Write the per-file JUnit XML and reset Kit state after each test file."""
88+
def _kit_module_fence():
89+
"""Reset Kit/USD state after each test file completes."""
12190
yield
122-
_write_report(str(request.fspath))
12391
_reset_kit_state()
124-
125-
126-
# ---------------------------------------------------------------------------
127-
# Hooks: accumulate test results and flush reports
128-
# ---------------------------------------------------------------------------
129-
130-
# nodeid -> TestCase that started setup but whose call phase has not yet been recorded.
131-
_pending: dict[str, TestCase] = {}
132-
# filepath -> TestSuite for all test cases from that file so far.
133-
_suites: dict[str, TestSuite] = {}
134-
# Paths whose JUnit XML has already been written (guards against double-write).
135-
_written: set[str] = set()
136-
137-
138-
def pytest_runtest_logreport(report) -> None:
139-
"""Record individual test phase outcomes into the per-file suite."""
140-
filepath = str(report.fspath)
141-
nodeid = report.nodeid
142-
143-
if report.when == "setup":
144-
if report.passed:
145-
case = TestCase(name=_case_name(nodeid), classname=_classname(nodeid))
146-
case.time = 0.0
147-
_pending[nodeid] = case
148-
elif report.failed:
149-
case = TestCase(name=_case_name(nodeid), classname=_classname(nodeid))
150-
case.time = report.duration
151-
case.result = Error(message=str(report.longrepr))
152-
_suite(filepath).add_testcase(case)
153-
elif report.skipped:
154-
case = TestCase(name=_case_name(nodeid), classname=_classname(nodeid))
155-
case.time = report.duration
156-
case.result = Skipped()
157-
_suite(filepath).add_testcase(case)
158-
159-
elif report.when == "call":
160-
case = _pending.pop(nodeid, None)
161-
if case is None:
162-
case = TestCase(name=_case_name(nodeid), classname=_classname(nodeid))
163-
case.time = 0.0
164-
case.time = (case.time or 0.0) + report.duration
165-
if report.failed:
166-
case.result = Failure(message=str(report.longrepr))
167-
elif report.skipped:
168-
case.result = Skipped()
169-
# passed: leave result unset — no child element means "passed" in JUnit XML
170-
_suite(filepath).add_testcase(case)
171-
172-
elif report.when == "teardown" and report.failed:
173-
# Teardown errors are appended as a separate Error case so they
174-
# appear in the report without overwriting the call-phase result.
175-
case = TestCase(
176-
name=f"{_case_name(nodeid)}[teardown]",
177-
classname=_classname(nodeid),
178-
)
179-
case.time = report.duration
180-
case.result = Error(message=str(report.longrepr))
181-
_suite(filepath).add_testcase(case)
182-
183-
184-
def pytest_sessionfinish(session, exitstatus) -> None:
185-
"""Drain _pending and flush any per-file report not yet written.
186-
187-
Tests whose setup passed but whose call phase was never recorded (e.g.
188-
session aborted via ``--maxfail`` or ``KeyboardInterrupt``) are written as
189-
``Error`` entries so they appear in the JUnit output instead of vanishing.
190-
"""
191-
for nodeid, case in list(_pending.items()):
192-
filepath = nodeid.split("::")[0]
193-
case.result = Error(message="Test interrupted: call phase never executed")
194-
_suite(filepath).add_testcase(case)
195-
_pending.clear()
196-
197-
for filepath in list(_suites):
198-
_write_report(filepath)
199-
200-
201-
# ---------------------------------------------------------------------------
202-
# Helpers
203-
# ---------------------------------------------------------------------------
204-
205-
206-
def _case_name(nodeid: str) -> str:
207-
parts = nodeid.split("::")
208-
return parts[-1] if len(parts) > 1 else nodeid
209-
210-
211-
def _classname(nodeid: str) -> str:
212-
parts = nodeid.split("::")
213-
if len(parts) >= 3:
214-
# path/test_foo.py::TestClass::test_method -> TestClass
215-
return parts[-2]
216-
if len(parts) == 2:
217-
# path/test_foo.py::test_function -> test_foo
218-
return os.path.splitext(os.path.basename(parts[0]))[0]
219-
return ""
220-
221-
222-
def _suite(filepath: str) -> TestSuite:
223-
if filepath not in _suites:
224-
name = os.path.splitext(os.path.basename(filepath))[0]
225-
_suites[filepath] = TestSuite(name=name)
226-
return _suites[filepath]
227-
228-
229-
def _write_report(filepath: str) -> None:
230-
if filepath in _written:
231-
return
232-
suite = _suites.get(filepath)
233-
if suite is None:
234-
return
235-
os.makedirs("tests", exist_ok=True)
236-
slug = filepath.replace("/", "__").replace("\\", "__")
237-
report_path = f"tests/test-reports-{slug}.xml"
238-
xml = JUnitXml()
239-
xml.add_testsuite(suite)
240-
xml.write(report_path)
241-
_written.add(filepath)

0 commit comments

Comments
 (0)