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
88Loaded 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
3525from __future__ import annotations
3626
3727import os
3828
3929import 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
4859def _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 )
11382def _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