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.
- pytest_playwright_artifacts-0.1.0/PKG-INFO +121 -0
- pytest_playwright_artifacts-0.1.0/README.md +108 -0
- pytest_playwright_artifacts-0.1.0/pyproject.toml +33 -0
- pytest_playwright_artifacts-0.1.0/pytest_playwright_artifacts/__init__.py +3 -0
- pytest_playwright_artifacts-0.1.0/pytest_playwright_artifacts/plugin.py +340 -0
|
@@ -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,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)
|