pytest-playwright-artifacts 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ Metadata-Version: 2.3
2
+ Name: pytest-playwright-artifacts
3
+ Version: 0.1.0
4
+ Summary: Pytest plugin that captures HTML, screenshots, and console logs on Playwright test failures
5
+ Keywords: pytest,playwright,testing,artifacts
6
+ Author: Michael Bianco
7
+ Author-email: Michael Bianco <mike@mikebian.co>
8
+ Requires-Dist: structlog-config>=0.6.0
9
+ Requires-Dist: playwright>=1.40.0
10
+ Requires-Python: >=3.11
11
+ Project-URL: Repository, https://github.com/iloveitaly/pytest-playwright-artifacts
12
+ Description-Content-Type: text/markdown
13
+
14
+ # Capture debugging artifacts on Playwright test failures
15
+
16
+ When your Playwright tests fail, you need to see what went wrong. This pytest plugin automatically captures HTML, screenshots, console logs, and failure summaries the moment a test fails.
17
+
18
+ I built this because debugging failed tests without artifacts is painful. You're left guessing what the page looked like, what JavaScript errors occurred, or what the actual DOM content was. This plugin captures all of that automatically.
19
+
20
+ ## Installation
21
+
22
+ ```bash
23
+ uv add pytest-playwright-artifacts
24
+ ```
25
+
26
+ ## Usage
27
+
28
+ The plugin activates automatically once installed. No configuration needed.
29
+
30
+ ### Basic Test
31
+
32
+ ```python
33
+ def test_my_page(page):
34
+ page.goto("https://example.com")
35
+ assert page.title() == "Example"
36
+ ```
37
+
38
+ When this test fails, you'll find artifacts in `test-results/<test-name>/`:
39
+ - `failure.html` - Rendered DOM content at the moment of failure
40
+ - `screenshot.png` - Full-page screenshot
41
+ - `failure.txt` - Failure summary with traceback
42
+ - `console_logs.log` - All captured console messages
43
+
44
+ ### Fail tests on console errors
45
+
46
+ ```python
47
+ from pytest_playwright_artifacts import assert_no_console_errors
48
+
49
+ def test_no_console_errors(page, request):
50
+ page.goto("https://example.com")
51
+ assert_no_console_errors(request)
52
+ ```
53
+
54
+ This fails the test if any `console.error()` messages were logged during the test.
55
+
56
+ ## Features
57
+
58
+ - Automatic artifact capture on test failure: HTML, screenshots, failure summary, and console logs
59
+ - Console log monitoring for all browser console messages during tests
60
+ - Regex-based filtering to ignore noisy console messages
61
+ - Helper assertion `assert_no_console_errors()` to fail tests on console errors
62
+ - Per-test artifact directories for clean organization
63
+
64
+ ## Configuration
65
+
66
+ ### Filter noisy console messages
67
+
68
+ Use regex patterns to ignore known noisy messages:
69
+
70
+ **pyproject.toml:**
71
+
72
+ ```toml
73
+ [tool.pytest.ini_options]
74
+ playwright_console_ignore = [
75
+ "Invalid Sentry Dsn:.*",
76
+ "Radar SDK: initialized.*",
77
+ "\\[Meta Pixel\\].*",
78
+ ]
79
+ ```
80
+
81
+ **pytest.ini:**
82
+
83
+ ```ini
84
+ [pytest]
85
+ playwright_console_ignore =
86
+ Invalid Sentry Dsn:.*
87
+ Radar SDK: initialized.*
88
+ \\[Meta Pixel\\].*
89
+ ```
90
+
91
+ Patterns match against both the raw console text and the formatted log line.
92
+
93
+ ### Change artifact output directory
94
+
95
+ ```bash
96
+ pytest --output=my-artifacts
97
+ ```
98
+
99
+ ## How it works
100
+
101
+ The plugin uses pytest hooks and fixtures to capture artifacts:
102
+
103
+ 1. An autouse fixture attaches a console listener to every Playwright page
104
+ 2. Console logs are stored in memory at `request.config._playwright_console_logs[nodeid]`
105
+ 3. The `pytest_runtest_makereport` hook detects test failures
106
+ 4. On failure, the plugin captures page content, takes a screenshot, and writes all artifacts
107
+ 5. Console logs are cleaned up from memory after test completion
108
+
109
+ ## Disabling features
110
+
111
+ **Disable console logging:**
112
+ ```python
113
+ # In pytest_playwright_artifacts/plugin.py, change:
114
+ @pytest.fixture(autouse=False)
115
+ def playwright_console_logging(...):
116
+ ```
117
+
118
+ **Disable failure artifacts:**
119
+ Comment out the `pytest_runtest_makereport` hook in `plugin.py`.
120
+
121
+ # [MIT License](LICENSE.md)
@@ -0,0 +1,108 @@
1
+ # Capture debugging artifacts on Playwright test failures
2
+
3
+ When your Playwright tests fail, you need to see what went wrong. This pytest plugin automatically captures HTML, screenshots, console logs, and failure summaries the moment a test fails.
4
+
5
+ I built this because debugging failed tests without artifacts is painful. You're left guessing what the page looked like, what JavaScript errors occurred, or what the actual DOM content was. This plugin captures all of that automatically.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ uv add pytest-playwright-artifacts
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ The plugin activates automatically once installed. No configuration needed.
16
+
17
+ ### Basic Test
18
+
19
+ ```python
20
+ def test_my_page(page):
21
+ page.goto("https://example.com")
22
+ assert page.title() == "Example"
23
+ ```
24
+
25
+ When this test fails, you'll find artifacts in `test-results/<test-name>/`:
26
+ - `failure.html` - Rendered DOM content at the moment of failure
27
+ - `screenshot.png` - Full-page screenshot
28
+ - `failure.txt` - Failure summary with traceback
29
+ - `console_logs.log` - All captured console messages
30
+
31
+ ### Fail tests on console errors
32
+
33
+ ```python
34
+ from pytest_playwright_artifacts import assert_no_console_errors
35
+
36
+ def test_no_console_errors(page, request):
37
+ page.goto("https://example.com")
38
+ assert_no_console_errors(request)
39
+ ```
40
+
41
+ This fails the test if any `console.error()` messages were logged during the test.
42
+
43
+ ## Features
44
+
45
+ - Automatic artifact capture on test failure: HTML, screenshots, failure summary, and console logs
46
+ - Console log monitoring for all browser console messages during tests
47
+ - Regex-based filtering to ignore noisy console messages
48
+ - Helper assertion `assert_no_console_errors()` to fail tests on console errors
49
+ - Per-test artifact directories for clean organization
50
+
51
+ ## Configuration
52
+
53
+ ### Filter noisy console messages
54
+
55
+ Use regex patterns to ignore known noisy messages:
56
+
57
+ **pyproject.toml:**
58
+
59
+ ```toml
60
+ [tool.pytest.ini_options]
61
+ playwright_console_ignore = [
62
+ "Invalid Sentry Dsn:.*",
63
+ "Radar SDK: initialized.*",
64
+ "\\[Meta Pixel\\].*",
65
+ ]
66
+ ```
67
+
68
+ **pytest.ini:**
69
+
70
+ ```ini
71
+ [pytest]
72
+ playwright_console_ignore =
73
+ Invalid Sentry Dsn:.*
74
+ Radar SDK: initialized.*
75
+ \\[Meta Pixel\\].*
76
+ ```
77
+
78
+ Patterns match against both the raw console text and the formatted log line.
79
+
80
+ ### Change artifact output directory
81
+
82
+ ```bash
83
+ pytest --output=my-artifacts
84
+ ```
85
+
86
+ ## How it works
87
+
88
+ The plugin uses pytest hooks and fixtures to capture artifacts:
89
+
90
+ 1. An autouse fixture attaches a console listener to every Playwright page
91
+ 2. Console logs are stored in memory at `request.config._playwright_console_logs[nodeid]`
92
+ 3. The `pytest_runtest_makereport` hook detects test failures
93
+ 4. On failure, the plugin captures page content, takes a screenshot, and writes all artifacts
94
+ 5. Console logs are cleaned up from memory after test completion
95
+
96
+ ## Disabling features
97
+
98
+ **Disable console logging:**
99
+ ```python
100
+ # In pytest_playwright_artifacts/plugin.py, change:
101
+ @pytest.fixture(autouse=False)
102
+ def playwright_console_logging(...):
103
+ ```
104
+
105
+ **Disable failure artifacts:**
106
+ Comment out the `pytest_runtest_makereport` hook in `plugin.py`.
107
+
108
+ # [MIT License](LICENSE.md)
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "pytest-playwright-artifacts"
3
+ version = "0.1.0"
4
+ description = "Pytest plugin that captures HTML, screenshots, and console logs on Playwright test failures"
5
+ keywords = ["pytest", "playwright", "testing", "artifacts"]
6
+ readme = "README.md"
7
+ requires-python = ">=3.11"
8
+ dependencies = [
9
+ "structlog-config>=0.6.0",
10
+ "playwright>=1.40.0",
11
+ ]
12
+ authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
13
+ urls = { "Repository" = "https://github.com/iloveitaly/pytest-playwright-artifacts" }
14
+
15
+ [project.entry-points."pytest11"]
16
+ playwright_artifacts = "pytest_playwright_artifacts.plugin"
17
+
18
+ [build-system]
19
+ requires = ["uv_build>=0.9.0,<0.10.0"]
20
+ build-backend = "uv_build"
21
+
22
+ [tool.uv.build-backend]
23
+ # avoids the src/ directory structure
24
+ module-root = ""
25
+
26
+ [dependency-groups]
27
+ dev = ["pytest>=8.3.3", "pyright[nodejs]>=1.1.380", "ruff>=0.8.0", "pytest-playwright>=0.5.0"]
28
+
29
+ [tool.pyright]
30
+ exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
31
+
32
+ [tool.ruff]
33
+ extend-exclude = ["playground.py", "playground/"]
@@ -0,0 +1,3 @@
1
+ from pytest_playwright_artifacts.plugin import assert_no_console_errors
2
+
3
+ __all__ = ["assert_no_console_errors"]
@@ -0,0 +1,340 @@
1
+ """
2
+ Pytest plugin for enhanced Playwright testing.
3
+
4
+ Features:
5
+ - Automatically captures and logs console messages from Playwright pages during tests.
6
+ - On test failure, persists the rendered page HTML, a PNG screenshot, a concise text summary
7
+ of the failure, and console logs in a per-test artifact directory (mirroring
8
+ pytest-playwright's structure for screenshots/traces).
9
+ - Provides `assert_no_console_errors` helper to fail tests if any 'error' type console logs are detected.
10
+
11
+ The captured console logs are stored in `request.config._playwright_console_logs[nodeid]` as a list of dicts
12
+ for access in custom hooks/reporters if needed.
13
+
14
+ To disable:
15
+ - Change the `autouse=True` to `False` in the `playwright_console_logging` fixture.
16
+ - For failure artifacts, remove/comment out the `pytest_runtest_makereport` hook.
17
+ - The assertion is manual, so only impacts tests where it's called.
18
+
19
+ Configuration:
20
+ - Use the pytest ini option `playwright_console_ignore` to filter out console messages.
21
+ These values are regular expressions and are matched against both the raw console text and the
22
+ fully formatted line. Messages that match are not emitted to stdout and are not stored in the
23
+ in-memory buffer used for artifacts.
24
+
25
+ Example (pyproject.toml):
26
+ [tool.pytest.ini_options]
27
+ playwright_console_ignore = [
28
+ "Invalid Sentry Dsn:.*",
29
+ "Radar SDK: initialized.*",
30
+ "\\[Meta Pixel\\].*",
31
+ ]
32
+
33
+ Example (pytest.ini):
34
+ [pytest]
35
+ playwright_console_ignore =
36
+ Invalid Sentry Dsn:.*
37
+ Radar SDK: initialized.*
38
+ \\[Meta Pixel\\].*
39
+
40
+ Artifacts:
41
+ On test failure, the following files are written to `<output-dir>/<sanitized-nodeid>/`:
42
+
43
+ - `failure.html`: The rendered DOM content of the page at the moment of failure.
44
+ - `screenshot.png`: A full-page PNG screenshot of the browser viewport.
45
+ - `failure.txt`: A concise text summary containing test nodeid, phase, error message,
46
+ location, and full failure traceback.
47
+ - `console_logs.log`: All captured browser console messages (only written on failure).
48
+
49
+ The output directory defaults to `test-results` and can be changed via pytest-playwright's
50
+ `--output` option.
51
+ """
52
+
53
+ import re
54
+ from pathlib import Path
55
+ from typing import Generator, Protocol, TypedDict, cast
56
+
57
+ import pytest
58
+ from playwright.sync_api import ConsoleMessage, Page
59
+ import structlog
60
+ from structlog_config import configure_logger
61
+
62
+ configure_logger()
63
+ log = structlog.get_logger(logger_name="pytest_playwright_artifacts")
64
+
65
+ ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]")
66
+
67
+
68
+ class StructuredConsoleLog(TypedDict):
69
+ type: str
70
+ text: str
71
+ args: list[object]
72
+ location: object
73
+
74
+
75
+ class FailureInfo(TypedDict):
76
+ error_message: str | None
77
+ error_file: str | None
78
+ error_line: int | None
79
+ longrepr_text: str | None
80
+
81
+
82
+ class PlaywrightConfig(Protocol):
83
+ _playwright_console_logs: dict[str, list[StructuredConsoleLog]]
84
+ _playwright_console_ignore_patterns: list[re.Pattern[str]]
85
+
86
+ def getoption(self, name: str) -> object | None: ...
87
+ def getini(self, name: str) -> object | None: ...
88
+
89
+
90
+ def pytest_addoption(parser: pytest.Parser) -> None:
91
+ # register ini option for filtering playwright console logs (no cli flag)
92
+ parser.addini(
93
+ "playwright_console_ignore",
94
+ "List of regex (one per line) to ignore Playwright console messages.",
95
+ type="linelist",
96
+ default=[],
97
+ )
98
+
99
+
100
+ def _compile_ignore_patterns(config: PlaywrightConfig) -> list[re.Pattern[str]]:
101
+ # collect and compile unique ignore regex from ini configuration
102
+ ini_patterns = cast(list[str], config.getini("playwright_console_ignore") or [])
103
+ unique_patterns = list(dict.fromkeys(ini_patterns))
104
+ return [re.compile(p) for p in unique_patterns]
105
+
106
+
107
+ def pytest_configure(config: PlaywrightConfig) -> None:
108
+ config._playwright_console_logs = {}
109
+ config._playwright_console_ignore_patterns = _compile_ignore_patterns(config)
110
+
111
+
112
+ def format_console_msg(msg: StructuredConsoleLog) -> str:
113
+ # helper to format a console message dict into a log string
114
+ args_str = ", ".join(str(arg) for arg in msg["args"]) if msg["args"] else "None"
115
+ return f"Type: {msg['type']}, Text: {msg['text']}, Args: {args_str}, Location: {msg['location']}"
116
+
117
+
118
+ def _safe_json_value(arg):
119
+ return arg.json_value()
120
+
121
+
122
+ def extract_structured_log(msg: ConsoleMessage) -> StructuredConsoleLog:
123
+ # helper to extract console message into a structured dict
124
+ return {
125
+ "type": msg.type,
126
+ "text": msg.text,
127
+ "args": [_safe_json_value(arg) for arg in msg.args],
128
+ "location": msg.location,
129
+ }
130
+
131
+
132
+ def _should_ignore_console_log(
133
+ structured_log: StructuredConsoleLog, patterns: list[re.Pattern[str]]
134
+ ) -> bool:
135
+ if not patterns:
136
+ return False
137
+
138
+ formatted = format_console_msg(structured_log)
139
+ candidates = [structured_log["text"], formatted]
140
+
141
+ for candidate in candidates:
142
+ for pattern in patterns:
143
+ if pattern.search(candidate):
144
+ return True
145
+
146
+ return False
147
+
148
+
149
+ @pytest.fixture(autouse=True)
150
+ def playwright_console_logging(
151
+ request: pytest.FixtureRequest, pytestconfig: PlaywrightConfig
152
+ ) -> Generator[None, None, None]:
153
+ # fixture to capture and log playwright console messages
154
+ if "page" not in request.fixturenames:
155
+ yield
156
+ return
157
+
158
+ page: Page = request.getfixturevalue("page")
159
+ logs: list[StructuredConsoleLog] = []
160
+ pytestconfig._playwright_console_logs[request.node.nodeid] = logs
161
+
162
+ def log_console(msg: ConsoleMessage) -> None:
163
+ structured_log = extract_structured_log(msg)
164
+ if _should_ignore_console_log(
165
+ structured_log, pytestconfig._playwright_console_ignore_patterns
166
+ ):
167
+ return
168
+
169
+ logs.append(structured_log)
170
+ log_msg = format_console_msg(structured_log)
171
+ log.debug("captured browser console message", message=log_msg)
172
+
173
+ page.on("console", log_console)
174
+ yield
175
+
176
+ if request.node.nodeid in pytestconfig._playwright_console_logs:
177
+ del pytestconfig._playwright_console_logs[request.node.nodeid]
178
+
179
+
180
+ def assert_no_console_errors(request: pytest.FixtureRequest) -> None:
181
+ # assertion helper to ensure no 'error' type console logs occurred
182
+ config = cast(PlaywrightConfig, request.config)
183
+ logs = config._playwright_console_logs.get(request.node.nodeid, [])
184
+ errors = [log for log in logs if log["type"].lower() == "error"]
185
+
186
+ if not errors:
187
+ return
188
+
189
+ error_msgs = "\n".join(format_console_msg(log) for log in errors)
190
+ assert not errors, f"Console errors found:\n{error_msgs}"
191
+
192
+
193
+ def strip_ansi(text: str) -> str:
194
+ # helper to remove ansi escape sequences from text
195
+ return ANSI_ESCAPE_RE.sub("", text)
196
+
197
+
198
+ def sanitize_for_artifacts(text: str) -> str:
199
+ # helper to sanitize test nodeid for artifact directory naming
200
+ sanitized = re.sub(r"[^A-Za-z0-9]+", "-", text)
201
+ sanitized = re.sub(r"-+", "-", sanitized).strip("-")
202
+ return sanitized or "unknown-test"
203
+
204
+
205
+ def get_artifact_dir(item: pytest.Item) -> Path:
206
+ # helper to get or create the per-test artifact directory
207
+ output_dir = item.config.getoption("output") or "test-results"
208
+ output_path = Path(output_dir)
209
+ output_path.mkdir(exist_ok=True)
210
+ per_test_dir = output_path / sanitize_for_artifacts(item.nodeid)
211
+ per_test_dir.mkdir(parents=True, exist_ok=True)
212
+ return per_test_dir
213
+
214
+
215
+ def extract_failure_info(
216
+ rep: pytest.TestReport, call: pytest.CallInfo[object], item: pytest.Item
217
+ ) -> FailureInfo:
218
+ # helper to extract failure details from pytest report
219
+ error_message = None
220
+ error_file = None
221
+ error_line = None
222
+ longrepr_text = None
223
+
224
+ if hasattr(rep, "longrepr") and rep.longrepr is not None:
225
+ reprcrash = getattr(rep.longrepr, "reprcrash", None)
226
+ if reprcrash is not None:
227
+ error_message = getattr(reprcrash, "message", None)
228
+ error_file = getattr(reprcrash, "path", None)
229
+ error_line = getattr(reprcrash, "lineno", None)
230
+ longrepr_text = getattr(rep, "longreprtext", None) or str(rep.longrepr)
231
+
232
+ if not error_message and hasattr(call, "excinfo") and call.excinfo is not None:
233
+ error_message = call.excinfo.exconly()
234
+
235
+ if error_file is None or error_line is None:
236
+ location_filename, location_lineno, _ = item.location
237
+ error_file = error_file or location_filename
238
+ error_line = error_line or location_lineno
239
+
240
+ return {
241
+ "error_message": strip_ansi(error_message) if error_message else None,
242
+ "error_file": error_file,
243
+ "error_line": error_line,
244
+ "longrepr_text": strip_ansi(longrepr_text) if longrepr_text else None,
245
+ }
246
+
247
+
248
+ def write_failure_summary(
249
+ per_test_dir: Path,
250
+ item: pytest.Item,
251
+ rep: pytest.TestReport,
252
+ failure_info: FailureInfo,
253
+ ) -> None:
254
+ # helper to write concise failure text summary
255
+ from string import Template
256
+
257
+ template_str = """test: $test_nodeid
258
+ phase: $phase
259
+ error: $error_message
260
+ location: $location
261
+
262
+ full failure:
263
+ $longrepr_text"""
264
+
265
+ location = ""
266
+ if failure_info["error_file"]:
267
+ if failure_info["error_line"] is not None:
268
+ location = f"{failure_info['error_file']}:{failure_info['error_line']}"
269
+ else:
270
+ location = failure_info["error_file"]
271
+
272
+ template = Template(template_str)
273
+ content = template.substitute(
274
+ test_nodeid=item.nodeid,
275
+ phase=rep.when,
276
+ error_message=failure_info["error_message"] or "",
277
+ location=location,
278
+ longrepr_text=failure_info["longrepr_text"] or "",
279
+ )
280
+
281
+ content = strip_ansi(content)
282
+ failure_text_file = per_test_dir / "failure.txt"
283
+ failure_text_file.write_text(content)
284
+ log.info("wrote test failure summary", file_path=failure_text_file)
285
+
286
+
287
+ def write_console_logs(
288
+ per_test_dir: Path, config: PlaywrightConfig, nodeid: str
289
+ ) -> None:
290
+ # helper to write captured console logs to a file
291
+ if nodeid not in config._playwright_console_logs:
292
+ return
293
+
294
+ logs = config._playwright_console_logs[nodeid]
295
+ logs_content = "\n".join(format_console_msg(log) for log in logs)
296
+ logs_file = per_test_dir / "console_logs.log"
297
+ logs_file.write_text(logs_content)
298
+ log.info("wrote console logs", file_path=logs_file)
299
+ del config._playwright_console_logs[nodeid]
300
+
301
+
302
+ @pytest.hookimpl(tryfirst=True, hookwrapper=True)
303
+ def pytest_runtest_makereport(
304
+ item: pytest.Item, call: pytest.CallInfo[object]
305
+ ) -> Generator[None, object, None]:
306
+ # hook to persist page html, screenshot, failure summary, and console logs on test failure
307
+ outcome = yield
308
+
309
+ class _HookOutcome(Protocol):
310
+ def get_result(self) -> pytest.TestReport: ...
311
+
312
+ rep = cast(_HookOutcome, outcome).get_result()
313
+
314
+ if rep.when != "call" or not rep.failed:
315
+ return
316
+
317
+ fixturenames = cast(list[str], getattr(item, "fixturenames", []))
318
+ if "page" not in fixturenames:
319
+ return
320
+
321
+ funcargs = cast(dict[str, object], getattr(item, "funcargs", {}))
322
+ page = funcargs.get("page")
323
+ if page is None:
324
+ return
325
+
326
+ page = cast(Page, page)
327
+ per_test_dir = get_artifact_dir(item)
328
+
329
+ failure_file = per_test_dir / "failure.html"
330
+ failure_file.write_text(page.content())
331
+ log.info("wrote rendered playwright page html", file_path=failure_file)
332
+
333
+ screenshot_file = per_test_dir / "screenshot.png"
334
+ page.screenshot(path=screenshot_file, full_page=True)
335
+ log.info("wrote playwright screenshot", file_path=screenshot_file)
336
+
337
+ failure_info = extract_failure_info(rep, call, item)
338
+ write_failure_summary(per_test_dir, item, rep, failure_info)
339
+
340
+ write_console_logs(per_test_dir, cast(PlaywrightConfig, item.config), item.nodeid)