Skip to content

Commit 4463bc7

Browse files
authored
test(playwright): Make pytest-playwright's --tracing flag work (#2452)
1 parent 2b5e750 commit 4463bc7

5 files changed

Lines changed: 213 additions & 16 deletions

File tree

.claude/references/architecture.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,25 @@ Modules enable namespaced, reusable components:
142142
- Run with `make playwright` (all browsers) or `make playwright-debug`
143143
(chromium, headed)
144144
- Prefer `.expect_*()` controller methods, which auto-wait, over manual sleeps
145+
- Pass one-off pytest options with `PYTEST_EXTRA_ARGS="..."`
146+
147+
**Playwright traces**:
148+
149+
Record a trace per test with `PYTEST_EXTRA_ARGS="--tracing on"` (or
150+
`--tracing retain-on-failure` to keep only failures). Each test writes
151+
`test-results/<test>/trace.zip`; open one with `make playwright-show-trace`,
152+
which picks the newest unless you pass `TRACE="path/to/trace.zip"`. Tracing
153+
needs no `--headed`: screenshots and DOM snapshots are captured from the
154+
renderer, which headless still rasterizes.
155+
156+
`--video` and `--screenshot` behave differently here because
157+
`tests/playwright/conftest.py` shares one page (and one context) across the
158+
whole session: video records one continuous file per session rather than per
159+
test (`--video retain-on-failure` keeps or deletes that one file based on
160+
whether any test failed), and `--screenshot` does nothing at all. Tracing avoids that limitation by
161+
slicing the session-long trace with `tracing.start_chunk()` / `stop_chunk()`
162+
per test. The NOTE in `tests/playwright/playwright-pytest.ini` records the same
163+
caveats next to the options themselves.
145164

146165
**Playwright controller pattern**:
147166

CLAUDE.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ make test-update-snapshots
6565
# Debug Playwright tests (headed, chromium only)
6666
make playwright-debug TEST_FILE="tests/playwright/shiny/inputs/test_foo.py"
6767

68-
# Show trace of failed Playwright tests
68+
# Record a Playwright trace per test, into test-results/<test>/trace.zip
69+
# (use --tracing retain-on-failure to keep traces only for failing tests).
70+
# Tracing does not require --headed; it works in the default headless runs.
71+
make playwright-shiny SUB_FILE="inputs/test_foo.py" PYTEST_EXTRA_ARGS="--tracing on"
72+
73+
# Open a recorded trace: the newest one, or TRACE="path/to/trace.zip"
6974
make playwright-show-trace
7075

7176
# Run specific test suites

Makefile

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,12 @@ PLAYWRIGHT_VERBOSE:= -v
213213
else
214214
PLAYWRIGHT_VERBOSE:=
215215
endif
216+
# Extra pytest options for one-off local runs, e.g.
217+
# `make playwright-shiny PYTEST_EXTRA_ARGS="--tracing on"` to record a
218+
# Playwright trace per test. See `playwright-show-trace` to open one.
219+
PYTEST_EXTRA_ARGS:=
220+
# A specific trace for `playwright-show-trace`; defaults to the newest one.
221+
TRACE:=
216222

217223

218224
# Full test path to playwright tests
@@ -243,13 +249,25 @@ install-rsconnect: FORCE
243249
# Stale snapshots can still be cleaned up with an all-browser, unsharded run
244250
# using `--snapshot-update`.
245251
playwright: install-playwright ## All end-to-end tests with playwright; (TEST_FILE="" from root of repo)
246-
pytest $(PLAYWRIGHT_VERBOSE) --snapshot-warn-unused --timeout=$(PLAYWRIGHT_TEST_TIMEOUT) --timeout-method=$(PLAYWRIGHT_TEST_TIMEOUT_METHOD) -o faulthandler_timeout=$(PLAYWRIGHT_FAULTHANDLER_TIMEOUT) $(TEST_FILE) $(PYTEST_BROWSERS)
252+
pytest $(PLAYWRIGHT_VERBOSE) --snapshot-warn-unused --timeout=$(PLAYWRIGHT_TEST_TIMEOUT) --timeout-method=$(PLAYWRIGHT_TEST_TIMEOUT_METHOD) -o faulthandler_timeout=$(PLAYWRIGHT_FAULTHANDLER_TIMEOUT) $(TEST_FILE) $(PYTEST_BROWSERS) $(PYTEST_EXTRA_ARGS)
247253

248254
playwright-debug: install-playwright ## All end-to-end tests, chrome only, headed; (TEST_FILE="" from root of repo)
249-
pytest -c tests/playwright/playwright-pytest.ini $(TEST_FILE)
250-
251-
playwright-show-trace: ## Show trace of failed tests
252-
npx playwright show-trace test-results/*/trace.zip
255+
pytest -c tests/playwright/playwright-pytest.ini $(TEST_FILE) $(PYTEST_EXTRA_ARGS)
256+
257+
# `show-trace` takes exactly one trace, and `--tracing on` writes one per test,
258+
# so default to the most recently written trace rather than globbing them all.
259+
playwright-show-trace: ## Show a Playwright trace: the newest under test-results/, or TRACE="path/to/trace.zip"
260+
@trace="$(TRACE)"; \
261+
if [ -z "$$trace" ]; then \
262+
trace=$$(ls -t test-results/*/trace.zip 2>/dev/null | head -1); \
263+
fi; \
264+
if [ -z "$$trace" ]; then \
265+
echo "No trace found under test-results/. Record one with:"; \
266+
echo " make playwright-shiny SUB_FILE=\"inputs/test_foo.py\" PYTEST_EXTRA_ARGS=\"--tracing on\""; \
267+
exit 1; \
268+
fi; \
269+
echo "npx playwright show-trace $$trace"; \
270+
npx playwright show-trace "$$trace"
253271

254272
# end-to-end tests with playwright; (SUB_FILE="" within tests/playwright/shiny/)
255273
playwright-shiny: FORCE

tests/playwright/conftest.py

Lines changed: 155 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44

55
import logging
66
import os
7+
import re
78
import typing
89
from inspect import signature
9-
from pathlib import PurePath
10+
from pathlib import Path, PurePath
1011

1112
import pytest
12-
from playwright.sync_api import BrowserContext, BrowserType
13+
from playwright.sync_api import Browser, BrowserContext, BrowserType
1314
from 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

1617
from shiny.pytest import ScopeName as ScopeName
1718
from shiny.pytest import create_app_fixture
@@ -32,6 +33,14 @@
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

3645
def _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")
95244
def _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})

tests/playwright/playwright-pytest.ini

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@ asyncio_mode=strict
55
# --durations-min <n>: Require that the top `k` slowest durations are longer than `n` seconds
66
# --browser <name>: browser type to run on playwright
77
# --numprocesses auto: number of testing workers. auto is number of (virtual) cores
8-
# NOTE: pytest-playwright's --tracing/--video/--screenshot options have no
9-
# effect in this test suite: conftest.py overrides the `page` fixture with a
10-
# session-shared page created directly from the browser, which bypasses the
11-
# plugin's `context` fixture where those artifacts are recorded.
8+
# NOTE: conftest.py overrides the `page` fixture with a session-shared page, so
9+
# pytest-playwright's artifact options behave differently here:
10+
# * --tracing works and writes one trace per test (conftest.py slices the
11+
# session trace with `tracing.start_chunk()` / `stop_chunk()`).
12+
# * --video records one continuous video for the whole session, not one per
13+
# test: video is a context option and the context is session-scoped, and
14+
# there is no video equivalent of `start_chunk()`. `retain-on-failure` keeps
15+
# that session-long video if any test failed and deletes it otherwise.
16+
# * --screenshot has no effect: the plugin captures screenshots when tearing
17+
# down its own `page` fixture, which this conftest.py replaces.
1218
# -vv: Extra extra verbose output
1319
# # --headed: Headed browser testing
1420
# # -r P: Show extra test summary info: (f)ailed, (E)rror, (s)kipped, (x)failed, (X)passed, (p)assed, (P)assed with output, (a)ll except passed (p/P), or (A)ll. (w)arnings...

0 commit comments

Comments
 (0)