44
55import logging
66import os
7+ import re
78import typing
89from inspect import signature
9- from pathlib import PurePath
10+ from pathlib import Path , PurePath
1011
1112import pytest
12- from playwright .sync_api import BrowserContext , BrowserType
13+ from playwright .sync_api import Browser , BrowserContext , BrowserType
1314from playwright .sync_api import Error as PlaywrightError
14- from playwright .sync_api import Page , Response
15+ from playwright .sync_api import Page , Response , Video
1516
1617from shiny .pytest import ScopeName as ScopeName
1718from shiny .pytest import create_app_fixture
3233# process crashed). The `page` fixture replaces a page marked this way.
3334_NAVIGATION_WEDGED_ATTR = "_shiny_navigation_wedged"
3435
36+ # Attribute set on a test item when its setup or call phase failed, so
37+ # `_trace_chunk` can honor `--tracing retain-on-failure`.
38+ _TEST_FAILED_ATTR = "_shiny_test_failed"
39+
40+ # Attribute set on the pytest session when any test failed, so
41+ # `_session_context` can honor `--video retain-on-failure`.
42+ _SESSION_FAILED_ATTR = "_shiny_session_failed"
43+
3544
3645def _mark_navigation_wedged (crashed_page : Page ) -> None :
3746 setattr (crashed_page , _NAVIGATION_WEDGED_ATTR , True )
@@ -91,6 +100,146 @@ def goto_and_verify_commit(url: str, **kwargs: typing.Any) -> Response | None:
91100 return page
92101
93102
103+ @pytest .fixture (scope = "session" )
104+ def _session_context (
105+ browser : Browser ,
106+ browser_context_args : dict [str , typing .Any ],
107+ pytestconfig : pytest .Config ,
108+ request : pytest .FixtureRequest ,
109+ ) -> typing .Generator [BrowserContext , None , None ]:
110+ """
111+ Session-scoped context that owns the shared page.
112+
113+ The shared page must come from a context we control, not from
114+ `browser.new_page()`. `new_page()` creates an implicit context with default
115+ options, which is why pytest-playwright's `--tracing`, `--video`, and
116+ `--screenshot` flags used to have no effect on this suite: those artifacts
117+ are only recorded by the plugin's own (function-scoped) `context` fixture,
118+ which this suite never uses.
119+ """
120+ video_mode = typing .cast (str , pytestconfig .getoption ("--video" ))
121+ context_args = dict (browser_context_args )
122+ if video_mode in ("on" , "retain-on-failure" ):
123+ # Video is a context-level option, and this context lives for the whole
124+ # session, so this records one continuous video rather than one per
125+ # test. There is no video equivalent of `tracing.start_chunk()`.
126+ context_args ["record_video_dir" ] = pytestconfig .getoption ("--output" )
127+
128+ context = browser .new_context (** context_args )
129+
130+ # pytest-playwright implements `--video retain-on-failure` by recording into
131+ # a temporary directory and copying out only the videos of failed tests.
132+ # That bookkeeping lives in its function-scoped fixtures, which this suite
133+ # replaces, so the mode has to be honored here or it would be identical to
134+ # `--video on`. Collect the video handles as pages are created, and discard
135+ # the recording once the session ends if nothing failed.
136+ videos : list [Video ] = []
137+ if video_mode == "retain-on-failure" :
138+
139+ def remember_video (page : Page ) -> None :
140+ if page .video is not None :
141+ videos .append (page .video )
142+
143+ context .on ("page" , remember_video )
144+
145+ if pytestconfig .getoption ("--tracing" ) in ("on" , "retain-on-failure" ):
146+ context .tracing .start (screenshots = True , snapshots = True , sources = True )
147+
148+ yield context
149+ # Videos are only written out when the context closes, so they cannot be
150+ # deleted before then.
151+ context .close ()
152+
153+ session_failed = getattr (request .session , _SESSION_FAILED_ATTR , False )
154+ if video_mode == "retain-on-failure" and not session_failed :
155+ for video in videos :
156+ try :
157+ video .delete ()
158+ except PlaywrightError :
159+ # A page that recorded nothing has no file to delete.
160+ pass
161+
162+
163+ def _request_item (request : pytest .FixtureRequest ) -> pytest .Item :
164+ """
165+ Return the test item a fixture request belongs to.
166+
167+ `FixtureRequest.node` carries no return annotation in pytest, so its type
168+ (and the type of everything read off it) is unknown to the type checker.
169+ Reading it through an explicitly-typed `Any` confines that to this one
170+ function instead of leaking a suppression comment to each use site. For a
171+ function-scoped fixture the node is always the test item.
172+ """
173+ untyped_request : typing .Any = request
174+ return typing .cast (pytest .Item , untyped_request .node )
175+
176+
177+ @pytest .fixture (scope = "function" , autouse = True )
178+ def _trace_chunk (
179+ request : pytest .FixtureRequest ,
180+ _session_context : BrowserContext ,
181+ pytestconfig : pytest .Config ,
182+ ) -> typing .Generator [None , None , None ]:
183+ """
184+ Record one trace file per test from the session-scoped context.
185+
186+ `start_chunk()` / `stop_chunk()` slice a single long-lived trace into
187+ per-test files, so a shared page still yields a trace per test rather than
188+ one trace for the whole session.
189+ """
190+ tracing_mode = pytestconfig .getoption ("--tracing" )
191+ if tracing_mode == "off" :
192+ yield
193+ return
194+
195+ item = _request_item (request )
196+ _session_context .tracing .start_chunk (title = item .nodeid )
197+ yield
198+
199+ failed : bool = getattr (item , _TEST_FAILED_ATTR , False )
200+ if tracing_mode == "on" or (tracing_mode == "retain-on-failure" and failed ):
201+ # `<output>/<slug>/trace.zip` matches pytest-playwright's own artifact
202+ # layout, and is what `make playwright-show-trace` globs for
203+ # (`test-results/*/trace.zip`).
204+ slug = re .sub (r"[^A-Za-z0-9_.-]+" , "-" , item .nodeid ).strip ("-" )
205+ output_dir = typing .cast (str , pytestconfig .getoption ("--output" ))
206+ trace_dir = Path (output_dir ) / slug
207+ trace_dir .mkdir (parents = True , exist_ok = True )
208+ _session_context .tracing .stop_chunk (path = str (trace_dir / "trace.zip" ))
209+ else :
210+ _session_context .tracing .stop_chunk ()
211+
212+
213+ @pytest .hookimpl (hookwrapper = True )
214+ def pytest_runtest_makereport (
215+ item : pytest .Item , call : pytest .CallInfo [None ]
216+ ) -> typing .Generator [None , typing .Any , None ]:
217+ """Record outcomes for the two `retain-on-failure` modes.
218+
219+ `_trace_chunk` needs the per-test outcome; `_session_context` needs to know
220+ whether the session had any failure at all.
221+ """
222+ # A `hookwrapper=True` generator is sent pluggy's `Result` object, which is
223+ # why the generator's send type is `Any`: pluggy is not a direct dependency
224+ # here, so `Result` cannot be named. The report it wraps is annotated below.
225+ outcome = yield
226+ report : pytest .TestReport = outcome .get_result ()
227+ # A setup failure counts: `_trace_chunk` is autouse, so its chunk is already
228+ # recording while the fixtures a test asks for (the app fixtures, `page`)
229+ # set up, and that chunk is what shows why one of them failed. pytest
230+ # reports the setup phase before running finalizers, so the attribute is set
231+ # in time for `_trace_chunk` to see it. Teardown is not included: its report
232+ # is only produced once every finalizer has run, by which point
233+ # `_trace_chunk` has already saved or discarded the chunk.
234+ if report .failed and report .when in ("setup" , "call" ):
235+ setattr (item , _TEST_FAILED_ATTR , True )
236+ if report .failed :
237+ # The session-long video is kept or dropped after every test has
238+ # finished, so unlike the per-test trace chunk it can also account for
239+ # failures reported during teardown.
240+ setattr (item .session , _SESSION_FAILED_ATTR , True )
241+
242+
94243@pytest .fixture (scope = "session" )
95244def _session_page_holder () -> list [Page ]:
96245 """
@@ -107,15 +256,15 @@ def _session_page_holder() -> list[Page]:
107256# By going to `about:blank`, we _reset_ the page to a known state before each test.
108257# It is not perfect, but it is faster than making a new page for each test.
109258# This must be done before each test
110- def page (browser : BrowserContext , _session_page_holder : list [Page ]) -> Page :
259+ def page (_session_context : BrowserContext , _session_page_holder : list [Page ]) -> Page :
111260 """
112261 Reset the shared page to a known state before each test.
113262 The page is maintained over the full session and reset by visiting
114263 "about:blank" between apps. If the page has become unusable (crashed or
115264 wedged so navigations no longer commit), it is replaced with a new page.
116265 The default viewport size is set to 1920 x 1080 (1080p) for each test function.
117266 Parameters:
118- browser (BrowserContext): The browser context used to create replacement pages.
267+ _session_context (BrowserContext): The browser context used to create replacement pages.
119268 _session_page_holder (list[Page]): Holder for the shared page.
120269 """
121270 session_page = _session_page_holder [0 ] if _session_page_holder else None
@@ -138,7 +287,7 @@ def page(browser: BrowserContext, _session_page_holder: list[Page]) -> Page:
138287 session_page = None
139288 if session_page is None :
140289 _session_page_holder .clear ()
141- session_page = _new_session_page (browser )
290+ session_page = _new_session_page (_session_context )
142291 _session_page_holder .append (session_page )
143292 # Reset screen size to 1080p
144293 session_page .set_viewport_size ({"width" : 1920 , "height" : 1080 })
0 commit comments