tracera-playwright 0.1.0rc1__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.
- tracera_playwright-0.1.0rc1/.gitignore +1 -0
- tracera_playwright-0.1.0rc1/LICENSE.md +23 -0
- tracera_playwright-0.1.0rc1/PKG-INFO +64 -0
- tracera_playwright-0.1.0rc1/README.md +46 -0
- tracera_playwright-0.1.0rc1/pyproject.toml +40 -0
- tracera_playwright-0.1.0rc1/src/tracera_playwright/__init__.py +8 -0
- tracera_playwright-0.1.0rc1/src/tracera_playwright/annotations.py +29 -0
- tracera_playwright-0.1.0rc1/src/tracera_playwright/plugin.py +426 -0
- tracera_playwright-0.1.0rc1/src/tracera_playwright/runtime.py +272 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Package-local stub so hatchling sdist does not embed the monorepo root .gitignore.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Copyright (c) 2026 Tracera. All rights reserved.
|
|
2
|
+
|
|
3
|
+
This software and associated documentation files (the "Software") are the
|
|
4
|
+
proprietary and confidential property of Tracera.
|
|
5
|
+
|
|
6
|
+
No license to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
+
and/or sell copies of the Software is granted by default. Any use of the
|
|
8
|
+
Software requires a separate written agreement with Tracera.
|
|
9
|
+
|
|
10
|
+
Unauthorized use, reproduction, or distribution of the Software, in whole
|
|
11
|
+
or in part, is strictly prohibited and may result in civil and criminal
|
|
12
|
+
penalties.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
15
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
16
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
17
|
+
TRACERA BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
|
|
18
|
+
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
|
19
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
20
|
+
|
|
21
|
+
This is a draft notice pending a full commercial license / EULA.
|
|
22
|
+
Third-party open-source components included with or used by the Software
|
|
23
|
+
remain subject to their respective licenses.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: tracera-playwright
|
|
3
|
+
Version: 0.1.0rc1
|
|
4
|
+
Summary: Tracera Autotest adapter for Playwright (Python)
|
|
5
|
+
Project-URL: Homepage, https://tracera.dev/docs/playwright-python
|
|
6
|
+
Project-URL: Bug-Tracker, https://github.com/tracera-dev/tracera-releases/issues
|
|
7
|
+
Author-email: Tracera <contact@tracera.dev>
|
|
8
|
+
License: SEE LICENSE IN LICENSE.md
|
|
9
|
+
License-File: LICENSE.md
|
|
10
|
+
Keywords: autotest,playwright,pytest,test-reporting,testops,tracera
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: tracera-core==0.1.0rc1
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: playwright>=1.40; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-playwright>=0.5; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# tracera-playwright
|
|
20
|
+
|
|
21
|
+
Tracera Autotest adapter for Playwright for Python (pytest plugin).
|
|
22
|
+
|
|
23
|
+
**Requires Python ≥ 3.11, pytest ≥ 8, and Playwright ≥ 1.40.**
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install tracera-playwright
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
How-to: [Playwright Python](https://tracera.dev/docs/playwright-python). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
30
|
+
|
|
31
|
+
## Setup
|
|
32
|
+
|
|
33
|
+
The package registers as a pytest plugin automatically. Do **not** load `tracera-pytest` and `tracera-playwright` in the same pytest run.
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from tracera_playwright import id as tracera_id
|
|
37
|
+
from tracera_playwright import tracera
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@tracera_id(42, "e2e/login")
|
|
41
|
+
def test_login(page):
|
|
42
|
+
page.goto("/login")
|
|
43
|
+
tracera.step("assert signed in", lambda: None)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Bind
|
|
47
|
+
|
|
48
|
+
`@tracera.id(…)` (preferred) or `tracera.test_case`. Optional name may use `{param}` / `{param_id}` from pytest parametrize. If `name` is omitted → `item.name`.
|
|
49
|
+
|
|
50
|
+
## Skip
|
|
51
|
+
|
|
52
|
+
Stock `pytest.skip`. The reason becomes a result `comment`.
|
|
53
|
+
|
|
54
|
+
## Steps
|
|
55
|
+
|
|
56
|
+
`tracera.step(title, body)`, `@tracera.step("…")`, `with` / `async with tracera.step("…"):`. Function-scoped fixtures → **Setup** / **Teardown**; suite-level fixtures are not reported. Video / trace attach to the result when present under the test output dir.
|
|
57
|
+
|
|
58
|
+
## Comments, errors, and attachments
|
|
59
|
+
|
|
60
|
+
`tracera.comment` / `tracera.error` / `tracera.attachment` — see [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
61
|
+
|
|
62
|
+
## Parameterized
|
|
63
|
+
|
|
64
|
+
`@pytest.mark.parametrize` + `@tracera.id(id, "login as {login}")`. Retries: final attempt only; `PLAYWRIGHT_RETRY` on Env when retry > 0.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# tracera-playwright
|
|
2
|
+
|
|
3
|
+
Tracera Autotest adapter for Playwright for Python (pytest plugin).
|
|
4
|
+
|
|
5
|
+
**Requires Python ≥ 3.11, pytest ≥ 8, and Playwright ≥ 1.40.**
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install tracera-playwright
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
How-to: [Playwright Python](https://tracera.dev/docs/playwright-python). Shared env / run mode: [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
12
|
+
|
|
13
|
+
## Setup
|
|
14
|
+
|
|
15
|
+
The package registers as a pytest plugin automatically. Do **not** load `tracera-pytest` and `tracera-playwright` in the same pytest run.
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from tracera_playwright import id as tracera_id
|
|
19
|
+
from tracera_playwright import tracera
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@tracera_id(42, "e2e/login")
|
|
23
|
+
def test_login(page):
|
|
24
|
+
page.goto("/login")
|
|
25
|
+
tracera.step("assert signed in", lambda: None)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Bind
|
|
29
|
+
|
|
30
|
+
`@tracera.id(…)` (preferred) or `tracera.test_case`. Optional name may use `{param}` / `{param_id}` from pytest parametrize. If `name` is omitted → `item.name`.
|
|
31
|
+
|
|
32
|
+
## Skip
|
|
33
|
+
|
|
34
|
+
Stock `pytest.skip`. The reason becomes a result `comment`.
|
|
35
|
+
|
|
36
|
+
## Steps
|
|
37
|
+
|
|
38
|
+
`tracera.step(title, body)`, `@tracera.step("…")`, `with` / `async with tracera.step("…"):`. Function-scoped fixtures → **Setup** / **Teardown**; suite-level fixtures are not reported. Video / trace attach to the result when present under the test output dir.
|
|
39
|
+
|
|
40
|
+
## Comments, errors, and attachments
|
|
41
|
+
|
|
42
|
+
`tracera.comment` / `tracera.error` / `tracera.attachment` — see [Autotest adapters](https://tracera.dev/docs/adapters).
|
|
43
|
+
|
|
44
|
+
## Parameterized
|
|
45
|
+
|
|
46
|
+
`@pytest.mark.parametrize` + `@tracera.id(id, "login as {login}")`. Retries: final attempt only; `PLAYWRIGHT_RETRY` on Env when retry > 0.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "tracera-playwright"
|
|
3
|
+
version = "0.1.0rc1"
|
|
4
|
+
description = "Tracera Autotest adapter for Playwright (Python)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = { text = "SEE LICENSE IN LICENSE.md" }
|
|
7
|
+
authors = [{ name = "Tracera", email = "contact@tracera.dev" }]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
keywords = ["tracera", "autotest", "playwright", "pytest", "test-reporting", "testops"]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"tracera-core==0.1.0rc1",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = ["pytest>=8", "playwright>=1.40", "pytest-playwright>=0.5"]
|
|
16
|
+
|
|
17
|
+
[project.entry-points.pytest11]
|
|
18
|
+
tracera_playwright = "tracera_playwright.plugin"
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Homepage = "https://tracera.dev/docs/playwright-python"
|
|
22
|
+
Bug-Tracker = "https://github.com/tracera-dev/tracera-releases/issues"
|
|
23
|
+
|
|
24
|
+
[build-system]
|
|
25
|
+
requires = ["hatchling"]
|
|
26
|
+
build-backend = "hatchling.build"
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.wheel]
|
|
29
|
+
packages = ["src/tracera_playwright"]
|
|
30
|
+
|
|
31
|
+
[tool.hatch.build.targets.wheel.sources]
|
|
32
|
+
"src/tracera_playwright" = "tracera_playwright"
|
|
33
|
+
|
|
34
|
+
# Publish surface: runtime package + docs only (no tests / harness / gitignore).
|
|
35
|
+
[tool.hatch.build.targets.sdist]
|
|
36
|
+
only-include = [
|
|
37
|
+
"src/tracera_playwright",
|
|
38
|
+
"LICENSE.md",
|
|
39
|
+
"README.md",
|
|
40
|
+
]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Declarative bind: ``@tracera.id(42)`` / ``@tracera.id(42, "name")``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Callable, TypeVar
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from tracera.bind import make_bind
|
|
10
|
+
|
|
11
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def id(test_case_id: int, autotest_name: str | None = None) -> Callable[[F], F]:
|
|
15
|
+
"""
|
|
16
|
+
Bind a Playwright-for-Python test to a Tracera case (preferred declarative form).
|
|
17
|
+
|
|
18
|
+
Applies ``pytest.mark.tracera(id=…, name=…)`` so early skip still reports.
|
|
19
|
+
|
|
20
|
+
Optional ``autotest_name`` may include ``{param}`` / ``{param_id}`` placeholders;
|
|
21
|
+
the plugin fills them from pytest parametrize values when the test starts.
|
|
22
|
+
"""
|
|
23
|
+
bind = make_bind(test_case_id, autotest_name)
|
|
24
|
+
mark = pytest.mark.tracera(id=bind.test_case_id, name=bind.autotest_name)
|
|
25
|
+
|
|
26
|
+
def decorator(fn: F) -> F:
|
|
27
|
+
return mark(fn)
|
|
28
|
+
|
|
29
|
+
return decorator
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
"""Self-contained Playwright-for-Python pytest plugin (not tracera-pytest)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Generator
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from tracera import (
|
|
12
|
+
run_with_context,
|
|
13
|
+
)
|
|
14
|
+
from tracera.buffer import PendingResult
|
|
15
|
+
from tracera.context import (
|
|
16
|
+
create_test_context,
|
|
17
|
+
format_title,
|
|
18
|
+
record_failure,
|
|
19
|
+
record_skip,
|
|
20
|
+
set_phase,
|
|
21
|
+
snapshot_payload,
|
|
22
|
+
suspend_reporting_context,
|
|
23
|
+
)
|
|
24
|
+
from tracera.env_extras import RunnerEnvContext, build_env_vars_extra
|
|
25
|
+
from tracera.materialize import payload_has_step_error
|
|
26
|
+
from tracera.pending import (
|
|
27
|
+
discard_pending_blobs,
|
|
28
|
+
pending_blobs_for_payload,
|
|
29
|
+
resolve_pending_attachments,
|
|
30
|
+
)
|
|
31
|
+
from tracera.registry import registry_set
|
|
32
|
+
from tracera.session import (
|
|
33
|
+
begin_reporting,
|
|
34
|
+
create_reporter_state,
|
|
35
|
+
duration_minutes_from_ms,
|
|
36
|
+
end_reporting,
|
|
37
|
+
)
|
|
38
|
+
from tracera_playwright import runtime as rt
|
|
39
|
+
|
|
40
|
+
_reporter = create_reporter_state()
|
|
41
|
+
_final_reports: dict[str, dict[str, Any]] = {}
|
|
42
|
+
_attempts: dict[str, int] = {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fixture_scope_name(fixturedef: Any) -> str:
|
|
46
|
+
scope = getattr(fixturedef, "scope", "function")
|
|
47
|
+
value = getattr(scope, "value", scope)
|
|
48
|
+
return str(value)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
52
|
+
config.addinivalue_line(
|
|
53
|
+
"markers",
|
|
54
|
+
"tracera(id, name=None): bind test to Tracera testCaseId",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.hookimpl(optionalhook=True)
|
|
59
|
+
def pytest_configure_node(node: Any) -> None:
|
|
60
|
+
"""xdist master: create owned run once and hand the id to each worker."""
|
|
61
|
+
if _reporter.session is None and not _reporter.soft_disabled:
|
|
62
|
+
begin_reporting(_reporter)
|
|
63
|
+
session = _reporter.session
|
|
64
|
+
if session is not None:
|
|
65
|
+
node.workerinput["tracera_test_run_id"] = session.test_run_id
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def pytest_sessionstart(session: pytest.Session) -> None:
|
|
69
|
+
rt.clear_pending()
|
|
70
|
+
_final_reports.clear()
|
|
71
|
+
_attempts.clear()
|
|
72
|
+
workerinput = getattr(session.config, "workerinput", None)
|
|
73
|
+
if isinstance(workerinput, dict):
|
|
74
|
+
run_id = workerinput.get("tracera_test_run_id")
|
|
75
|
+
if run_id is not None:
|
|
76
|
+
import os
|
|
77
|
+
|
|
78
|
+
os.environ["TRACERA_TEST_RUN_ID"] = str(run_id)
|
|
79
|
+
begin_reporting(_reporter)
|
|
80
|
+
return
|
|
81
|
+
if _reporter.session is not None or _reporter.soft_disabled:
|
|
82
|
+
return
|
|
83
|
+
begin_reporting(_reporter)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|
87
|
+
if (
|
|
88
|
+
_reporter.soft_disabled
|
|
89
|
+
or not _reporter.session
|
|
90
|
+
or not _reporter.buffer
|
|
91
|
+
or not _reporter.upload_pool
|
|
92
|
+
):
|
|
93
|
+
for row in _final_reports.values():
|
|
94
|
+
discard_pending_blobs(
|
|
95
|
+
row.get("payload") or {},
|
|
96
|
+
take_blob=lambda aid, blobs=dict(row.get("pending_blobs") or {}): (
|
|
97
|
+
blobs.pop(aid, None) or rt.take_pending(aid)
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
rt.clear_pending()
|
|
101
|
+
end_reporting(_reporter)
|
|
102
|
+
return
|
|
103
|
+
|
|
104
|
+
for row in _final_reports.values():
|
|
105
|
+
ctx = row["ctx"]
|
|
106
|
+
payload = row["payload"]
|
|
107
|
+
status = row["status"]
|
|
108
|
+
env_vars_extra = build_env_vars_extra(
|
|
109
|
+
retry=row.get("retry", 0),
|
|
110
|
+
retry_key="PLAYWRIGHT_RETRY",
|
|
111
|
+
package_ids=["playwright"],
|
|
112
|
+
version_key="PLAYWRIGHT_VERSION",
|
|
113
|
+
context=row.get("env_context"),
|
|
114
|
+
)
|
|
115
|
+
duration_minutes = duration_minutes_from_ms(row["duration_ms"])
|
|
116
|
+
session_ref = _reporter.session
|
|
117
|
+
buffer = _reporter.buffer
|
|
118
|
+
pool = _reporter.upload_pool
|
|
119
|
+
|
|
120
|
+
def job(
|
|
121
|
+
ctx=ctx,
|
|
122
|
+
payload=payload,
|
|
123
|
+
status=status,
|
|
124
|
+
duration_minutes=duration_minutes,
|
|
125
|
+
env_vars_extra=env_vars_extra,
|
|
126
|
+
pending_blobs=dict(row.get("pending_blobs") or {}),
|
|
127
|
+
) -> None:
|
|
128
|
+
if not session_ref.reporting_enabled:
|
|
129
|
+
discard_pending_blobs(
|
|
130
|
+
payload,
|
|
131
|
+
take_blob=lambda aid: pending_blobs.pop(aid, None) or rt.take_pending(aid),
|
|
132
|
+
)
|
|
133
|
+
return
|
|
134
|
+
errs = resolve_pending_attachments(
|
|
135
|
+
session_ref.client,
|
|
136
|
+
payload,
|
|
137
|
+
resolve_blob=lambda aid: pending_blobs.get(aid) or rt.take_pending(aid),
|
|
138
|
+
)
|
|
139
|
+
if errs:
|
|
140
|
+
session_ref.set_last_error(errs[0])
|
|
141
|
+
if session_ref.config.require_report:
|
|
142
|
+
session_ref.note_failed_report()
|
|
143
|
+
session_ref.note_unsent_results()
|
|
144
|
+
return
|
|
145
|
+
buffer.enqueue(
|
|
146
|
+
PendingResult(
|
|
147
|
+
ctx=ctx,
|
|
148
|
+
status=status,
|
|
149
|
+
duration_minutes=duration_minutes,
|
|
150
|
+
payload=payload,
|
|
151
|
+
env_vars_extra=env_vars_extra,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
pool.enqueue(job)
|
|
156
|
+
|
|
157
|
+
end_reporting(_reporter)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _callspec_title_values(item: pytest.Item) -> dict[str, Any]:
|
|
161
|
+
callspec = getattr(item, "callspec", None)
|
|
162
|
+
if callspec is None:
|
|
163
|
+
return {}
|
|
164
|
+
values: dict[str, Any] = dict(getattr(callspec, "params", {}) or {})
|
|
165
|
+
cid = getattr(callspec, "id", None)
|
|
166
|
+
if isinstance(cid, str) and cid:
|
|
167
|
+
values.setdefault("param_id", cid)
|
|
168
|
+
return values
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _resolve_autotest_name(bind_name: str | None, item: pytest.Item) -> str:
|
|
172
|
+
if bind_name:
|
|
173
|
+
return format_title(bind_name, _callspec_title_values(item))[:128]
|
|
174
|
+
return item.name[:128]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _read_bind(item: pytest.Item) -> tuple[int | None, str | None]:
|
|
178
|
+
marker = item.get_closest_marker("tracera")
|
|
179
|
+
if marker is None:
|
|
180
|
+
return None, None
|
|
181
|
+
case_id = marker.kwargs.get("id")
|
|
182
|
+
if case_id is None and marker.args:
|
|
183
|
+
case_id = marker.args[0]
|
|
184
|
+
try:
|
|
185
|
+
n = int(case_id) if case_id is not None else 0
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
n = 0
|
|
188
|
+
name = marker.kwargs.get("name")
|
|
189
|
+
return (n if n > 0 else None), (name if isinstance(name, str) else None)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _skip_reason(report: pytest.TestReport) -> str | None:
|
|
193
|
+
longrepr = report.longrepr
|
|
194
|
+
if isinstance(longrepr, tuple) and len(longrepr) >= 3:
|
|
195
|
+
text = str(longrepr[2])
|
|
196
|
+
return text if text.strip() else None
|
|
197
|
+
if longrepr is not None:
|
|
198
|
+
text = str(longrepr)
|
|
199
|
+
return text if text.strip() else None
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _failure_source(call: pytest.CallInfo[Any], report: pytest.TestReport) -> Any:
|
|
204
|
+
"""Prefer full traceback text; fall back to ExceptionInfo / longrepr."""
|
|
205
|
+
text = getattr(report, "longreprtext", None)
|
|
206
|
+
if isinstance(text, str) and text.strip():
|
|
207
|
+
return text
|
|
208
|
+
if call.excinfo is not None:
|
|
209
|
+
return call.excinfo
|
|
210
|
+
return report.longrepr
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _safe_node_slug(nodeid: str) -> str:
|
|
214
|
+
return "".join(c if c.isalnum() or c in "._-" else "_" for c in nodeid)[:120] or "test"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _artifact_folder_name(nodeid: str) -> str:
|
|
218
|
+
"""Per-test folder name under ``--output`` (pytest-playwright layout when available)."""
|
|
219
|
+
try:
|
|
220
|
+
from pytest_playwright.pytest_playwright import _truncate_file_name
|
|
221
|
+
from slugify import slugify
|
|
222
|
+
|
|
223
|
+
return _truncate_file_name(slugify(nodeid))
|
|
224
|
+
except Exception:
|
|
225
|
+
return _safe_node_slug(nodeid)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _playwright_artifact_dir(item: pytest.Item) -> Path:
|
|
229
|
+
"""Exact per-test output folder used by pytest-playwright (``--output`` / slug(nodeid))."""
|
|
230
|
+
try:
|
|
231
|
+
output = Path(str(item.config.getoption("--output", default="test-results"))).absolute()
|
|
232
|
+
except (ValueError, AttributeError):
|
|
233
|
+
output = Path("test-results").absolute()
|
|
234
|
+
return output / _artifact_folder_name(item.nodeid)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _attach_path(ctx: Any, path: Path, mime: str) -> None:
|
|
238
|
+
if not path.is_file():
|
|
239
|
+
return
|
|
240
|
+
try:
|
|
241
|
+
with run_with_context(ctx):
|
|
242
|
+
rt.attachment(file_name=path.name, mime_type=mime, path=str(path))
|
|
243
|
+
except Exception:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _try_capture_screenshot(item: pytest.Item, ctx: Any) -> None:
|
|
248
|
+
"""Live screenshot while ``page`` is still open (call failure)."""
|
|
249
|
+
try:
|
|
250
|
+
page = item.funcargs.get("page") # type: ignore[attr-defined]
|
|
251
|
+
except Exception:
|
|
252
|
+
page = None
|
|
253
|
+
if page is None:
|
|
254
|
+
return
|
|
255
|
+
try:
|
|
256
|
+
path = Path.cwd() / f".tracera-pw-{_safe_node_slug(item.nodeid)}.png"
|
|
257
|
+
with run_with_context(ctx):
|
|
258
|
+
page.screenshot(path=str(path))
|
|
259
|
+
rt.attachment(file_name=path.name, mime_type="image/png", path=str(path))
|
|
260
|
+
except Exception:
|
|
261
|
+
pass
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _try_attach_playwright_artifacts(item: pytest.Item, ctx: Any) -> None:
|
|
265
|
+
"""Attach video/trace from this test's pytest-playwright folder only.
|
|
266
|
+
|
|
267
|
+
Called after fixture teardown — ArtifactsRecorder has already moved files into
|
|
268
|
+
``--output/<slug(nodeid)>/``. Never scan sibling test dirs.
|
|
269
|
+
"""
|
|
270
|
+
art = _playwright_artifact_dir(item)
|
|
271
|
+
if not art.is_dir():
|
|
272
|
+
return
|
|
273
|
+
_attach_path(ctx, art / "video.webm", "video/webm")
|
|
274
|
+
_attach_path(ctx, art / "trace.zip", "application/zip")
|
|
275
|
+
for found in sorted(art.glob("video-*.webm")):
|
|
276
|
+
_attach_path(ctx, found, "video/webm")
|
|
277
|
+
for found in sorted(art.glob("trace-*.zip")):
|
|
278
|
+
_attach_path(ctx, found, "application/zip")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
282
|
+
def pytest_fixture_setup(fixturedef: Any, request: Any) -> Generator[None, None, None]:
|
|
283
|
+
"""Session/module/class fixtures must not record steps onto the active test."""
|
|
284
|
+
if _fixture_scope_name(fixturedef) == "function":
|
|
285
|
+
yield
|
|
286
|
+
return
|
|
287
|
+
rt.set_reporting_suspended(True)
|
|
288
|
+
try:
|
|
289
|
+
with suspend_reporting_context():
|
|
290
|
+
yield
|
|
291
|
+
finally:
|
|
292
|
+
rt.set_reporting_suspended(False)
|
|
293
|
+
|
|
294
|
+
def _suspend_before_teardown() -> None:
|
|
295
|
+
rt.set_reporting_suspended(True)
|
|
296
|
+
suspend_reporting_context().__enter__()
|
|
297
|
+
|
|
298
|
+
fixturedef.addfinalizer(_suspend_before_teardown)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@pytest.hookimpl(tryfirst=True)
|
|
302
|
+
def pytest_runtest_setup(item: pytest.Item) -> None:
|
|
303
|
+
rt.set_reporting_suspended(False)
|
|
304
|
+
key = f"pw:{item.nodeid}"
|
|
305
|
+
attempt = _attempts.get(key, -1) + 1
|
|
306
|
+
_attempts[key] = attempt
|
|
307
|
+
case_id, bind_name = _read_bind(item)
|
|
308
|
+
ctx = create_test_context(
|
|
309
|
+
test_case_id=case_id,
|
|
310
|
+
autotest_name=_resolve_autotest_name(bind_name, item),
|
|
311
|
+
)
|
|
312
|
+
registry_set(key, ctx)
|
|
313
|
+
rt.set_current_key(key)
|
|
314
|
+
item._tracera_ctx = ctx # type: ignore[attr-defined]
|
|
315
|
+
item._tracera_started = time.time() # type: ignore[attr-defined]
|
|
316
|
+
item._tracera_attempt = attempt # type: ignore[attr-defined]
|
|
317
|
+
item._tracera_key = key # type: ignore[attr-defined]
|
|
318
|
+
item._tracera_token = run_with_context(ctx) # type: ignore[attr-defined]
|
|
319
|
+
item._tracera_token.__enter__() # type: ignore[attr-defined]
|
|
320
|
+
set_phase("setup")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@pytest.hookimpl(tryfirst=True)
|
|
324
|
+
def pytest_runtest_call(item: pytest.Item) -> None:
|
|
325
|
+
if getattr(item, "_tracera_ctx", None) is not None:
|
|
326
|
+
set_phase("test")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
|
|
330
|
+
def pytest_runtest_teardown(
|
|
331
|
+
item: pytest.Item, nextitem: pytest.Item | None
|
|
332
|
+
) -> Generator[None, None, None]:
|
|
333
|
+
# tryfirst so phase is Teardown before fixture teardown runs (default hook).
|
|
334
|
+
if getattr(item, "_tracera_ctx", None) is not None:
|
|
335
|
+
set_phase("teardown")
|
|
336
|
+
yield
|
|
337
|
+
_ = nextitem
|
|
338
|
+
rt.set_current_key(None)
|
|
339
|
+
rt.set_reporting_suspended(True)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@pytest.hookimpl(hookwrapper=True)
|
|
343
|
+
def pytest_runtest_makereport(
|
|
344
|
+
item: pytest.Item, call: pytest.CallInfo[Any]
|
|
345
|
+
) -> Generator[None, None, None]:
|
|
346
|
+
outcome = yield
|
|
347
|
+
assert outcome is not None
|
|
348
|
+
report: pytest.TestReport = outcome.get_result()
|
|
349
|
+
ctx = getattr(item, "_tracera_ctx", None)
|
|
350
|
+
if ctx is None:
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
browser_name = None
|
|
354
|
+
try:
|
|
355
|
+
browser_name = item.callspec.params.get("browser_name") # type: ignore[attr-defined]
|
|
356
|
+
except Exception:
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
def store(status: str) -> None:
|
|
360
|
+
key = getattr(item, "_tracera_key", item.nodeid)
|
|
361
|
+
payload = snapshot_payload(ctx)
|
|
362
|
+
_final_reports[key] = {
|
|
363
|
+
"ctx": ctx,
|
|
364
|
+
"payload": payload,
|
|
365
|
+
"status": status,
|
|
366
|
+
"duration_ms": (time.time() - getattr(item, "_tracera_started", time.time())) * 1000,
|
|
367
|
+
"retry": getattr(item, "_tracera_attempt", 0),
|
|
368
|
+
"pending_blobs": pending_blobs_for_payload(rt.all_pending(), payload),
|
|
369
|
+
"env_context": RunnerEnvContext(browser=browser_name) if browser_name else None,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if call.when == "setup":
|
|
373
|
+
if report.skipped:
|
|
374
|
+
record_skip(ctx, _skip_reason(report))
|
|
375
|
+
store("Skipped")
|
|
376
|
+
elif report.failed:
|
|
377
|
+
if not payload_has_step_error(snapshot_payload(ctx)):
|
|
378
|
+
record_failure(ctx, _failure_source(call, report))
|
|
379
|
+
store("Failed")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
if call.when == "call":
|
|
383
|
+
if report.failed:
|
|
384
|
+
_try_capture_screenshot(item, ctx)
|
|
385
|
+
if not payload_has_step_error(snapshot_payload(ctx)):
|
|
386
|
+
record_failure(ctx, _failure_source(call, report))
|
|
387
|
+
store("Failed")
|
|
388
|
+
elif report.skipped:
|
|
389
|
+
record_skip(ctx, _skip_reason(report))
|
|
390
|
+
store("Skipped")
|
|
391
|
+
elif report.passed:
|
|
392
|
+
store("Passed")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
if call.when == "teardown":
|
|
396
|
+
key = getattr(item, "_tracera_key", item.nodeid)
|
|
397
|
+
row = _final_reports.get(key)
|
|
398
|
+
if row is None:
|
|
399
|
+
store("Passed")
|
|
400
|
+
row = _final_reports.get(key)
|
|
401
|
+
if row is not None:
|
|
402
|
+
# Fixtures (incl. pytest-playwright ArtifactsRecorder) have finished —
|
|
403
|
+
# attach video/trace from this test's output folder only.
|
|
404
|
+
if row["status"] == "Failed":
|
|
405
|
+
_try_attach_playwright_artifacts(item, ctx)
|
|
406
|
+
payload = snapshot_payload(ctx)
|
|
407
|
+
status = row["status"]
|
|
408
|
+
if status == "Passed":
|
|
409
|
+
failed_step = any(
|
|
410
|
+
isinstance(s, dict) and s.get("result") == "Failed"
|
|
411
|
+
for s in (payload["setup"] + payload["test"] + payload["teardown"])
|
|
412
|
+
)
|
|
413
|
+
if failed_step or ctx.result_error:
|
|
414
|
+
status = "Failed"
|
|
415
|
+
row["payload"] = payload
|
|
416
|
+
row["status"] = status
|
|
417
|
+
row["duration_ms"] = (
|
|
418
|
+
time.time() - getattr(item, "_tracera_started", time.time())
|
|
419
|
+
) * 1000
|
|
420
|
+
row["pending_blobs"] = pending_blobs_for_payload(rt.all_pending(), payload)
|
|
421
|
+
token_cm = getattr(item, "_tracera_token", None)
|
|
422
|
+
if token_cm is not None:
|
|
423
|
+
try:
|
|
424
|
+
token_cm.__exit__(None, None, None)
|
|
425
|
+
except Exception:
|
|
426
|
+
pass
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Runtime API (mirrors tracera-pytest; independent package)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
import uuid
|
|
7
|
+
from contextlib import nullcontext
|
|
8
|
+
from contextvars import ContextVar
|
|
9
|
+
from functools import wraps
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Callable, TypeVar, overload
|
|
12
|
+
|
|
13
|
+
from tracera import get_context, run_with_context
|
|
14
|
+
from tracera.context import AttachmentMeta, StepContext, TestContext, format_step_title
|
|
15
|
+
from tracera.registry import registry_get
|
|
16
|
+
from tracera.runtime import create_tracera_runtime
|
|
17
|
+
|
|
18
|
+
T = TypeVar("T")
|
|
19
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
20
|
+
|
|
21
|
+
_base = create_tracera_runtime()
|
|
22
|
+
_pending: dict[str, dict[str, Any]] = {}
|
|
23
|
+
_current_key: ContextVar[str | None] = ContextVar("tracera_playwright_current_key", default=None)
|
|
24
|
+
_reporting_suspended: bool = False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_current_key(key: str | None) -> None:
|
|
28
|
+
_current_key.set(key)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def current_key() -> str | None:
|
|
32
|
+
return _current_key.get()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def set_reporting_suspended(suspended: bool) -> None:
|
|
36
|
+
global _reporting_suspended
|
|
37
|
+
_reporting_suspended = suspended
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def reporting_suspended() -> bool:
|
|
41
|
+
return _reporting_suspended
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _register_pending(pending_id: str, blob: dict[str, Any]) -> None:
|
|
45
|
+
# Path wins: drop in-memory data when a path is present (memory hygiene).
|
|
46
|
+
path = blob.get("path")
|
|
47
|
+
if isinstance(path, str) and path.strip():
|
|
48
|
+
_pending[pending_id] = {
|
|
49
|
+
"fileName": blob.get("fileName") or "attachment.bin",
|
|
50
|
+
"mimeType": blob.get("mimeType") or "application/octet-stream",
|
|
51
|
+
"path": path,
|
|
52
|
+
}
|
|
53
|
+
return
|
|
54
|
+
_pending[pending_id] = blob
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def take_pending(pending_id: str) -> dict[str, Any] | None:
|
|
58
|
+
return _pending.pop(pending_id, None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def all_pending() -> dict[str, dict[str, Any]]:
|
|
62
|
+
return dict(_pending)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def clear_pending() -> None:
|
|
66
|
+
_pending.clear()
|
|
67
|
+
set_current_key(None)
|
|
68
|
+
set_reporting_suspended(False)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _ensure_ctx() -> TestContext | None:
|
|
72
|
+
if reporting_suspended():
|
|
73
|
+
return None
|
|
74
|
+
ctx = get_context()
|
|
75
|
+
if ctx is not None:
|
|
76
|
+
return ctx
|
|
77
|
+
key = current_key()
|
|
78
|
+
if key:
|
|
79
|
+
existing = registry_get(key)
|
|
80
|
+
if existing:
|
|
81
|
+
return existing
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _run_step_body(title: str, body: Callable[[], T]) -> T:
|
|
86
|
+
ctx = _ensure_ctx()
|
|
87
|
+
if ctx is not None:
|
|
88
|
+
with run_with_context(ctx):
|
|
89
|
+
return _base.step(title, body) # type: ignore[return-value]
|
|
90
|
+
if reporting_suspended():
|
|
91
|
+
return body()
|
|
92
|
+
return _base.step(title, body) # type: ignore[return-value]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_case(test_case_id: int, autotest_name: str | None = None) -> None:
|
|
96
|
+
"""Imperative bind to a Tracera test case (optional result name). Prefer ``@tracera.id``."""
|
|
97
|
+
ctx = _ensure_ctx()
|
|
98
|
+
if ctx is not None:
|
|
99
|
+
ctx.test_case_id = test_case_id
|
|
100
|
+
if autotest_name:
|
|
101
|
+
ctx.autotest_name = autotest_name[:128]
|
|
102
|
+
with run_with_context(ctx):
|
|
103
|
+
_base.test_case(test_case_id, autotest_name)
|
|
104
|
+
return
|
|
105
|
+
_base.test_case(test_case_id, autotest_name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class _EnsuredStep:
|
|
109
|
+
"""Re-bind playwright-python registry context for decorator / ``with`` / ``async with``."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, inner: StepContext) -> None:
|
|
112
|
+
self._inner = inner
|
|
113
|
+
self._token: Any = nullcontext()
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> StepContext:
|
|
116
|
+
if reporting_suspended():
|
|
117
|
+
self._dropped = True
|
|
118
|
+
self._token = nullcontext()
|
|
119
|
+
return self._inner
|
|
120
|
+
self._dropped = False
|
|
121
|
+
ctx = _ensure_ctx()
|
|
122
|
+
self._token = run_with_context(ctx) if ctx is not None else nullcontext()
|
|
123
|
+
self._token.__enter__()
|
|
124
|
+
return self._inner.__enter__()
|
|
125
|
+
|
|
126
|
+
def __exit__(
|
|
127
|
+
self,
|
|
128
|
+
exc_type: type[BaseException] | None,
|
|
129
|
+
exc: BaseException | None,
|
|
130
|
+
tb: Any,
|
|
131
|
+
) -> bool:
|
|
132
|
+
if getattr(self, "_dropped", False):
|
|
133
|
+
return False
|
|
134
|
+
try:
|
|
135
|
+
return self._inner.__exit__(exc_type, exc, tb)
|
|
136
|
+
finally:
|
|
137
|
+
self._token.__exit__(exc_type, exc, tb)
|
|
138
|
+
|
|
139
|
+
async def __aenter__(self) -> StepContext:
|
|
140
|
+
ctx = _ensure_ctx()
|
|
141
|
+
self._token = run_with_context(ctx) if ctx is not None else nullcontext()
|
|
142
|
+
self._token.__enter__()
|
|
143
|
+
return await self._inner.__aenter__()
|
|
144
|
+
|
|
145
|
+
async def __aexit__(
|
|
146
|
+
self,
|
|
147
|
+
exc_type: type[BaseException] | None,
|
|
148
|
+
exc: BaseException | None,
|
|
149
|
+
tb: Any,
|
|
150
|
+
) -> bool:
|
|
151
|
+
try:
|
|
152
|
+
return await self._inner.__aexit__(exc_type, exc, tb)
|
|
153
|
+
finally:
|
|
154
|
+
self._token.__exit__(exc_type, exc, tb)
|
|
155
|
+
|
|
156
|
+
def __call__(self, fn: F) -> F:
|
|
157
|
+
if inspect.iscoroutinefunction(fn):
|
|
158
|
+
|
|
159
|
+
@wraps(fn)
|
|
160
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
161
|
+
title = format_step_title(self._inner.title, fn, args, kwargs)
|
|
162
|
+
async with _EnsuredStep(StepContext(title)):
|
|
163
|
+
return await fn(*args, **kwargs)
|
|
164
|
+
|
|
165
|
+
return async_wrapper # type: ignore[return-value]
|
|
166
|
+
|
|
167
|
+
@wraps(fn)
|
|
168
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
169
|
+
title = format_step_title(self._inner.title, fn, args, kwargs)
|
|
170
|
+
with _EnsuredStep(StepContext(title)):
|
|
171
|
+
return fn(*args, **kwargs)
|
|
172
|
+
|
|
173
|
+
return wrapper # type: ignore[return-value]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@overload
|
|
177
|
+
def step(title: str, body: Callable[[], T]) -> T: ...
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@overload
|
|
181
|
+
def step(title: str, body: None = None) -> _EnsuredStep: ...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@overload
|
|
185
|
+
def step(title: F) -> F: ...
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def step(title: str | F, body: Callable[[], T] | None = None) -> T | _EnsuredStep | F:
|
|
189
|
+
"""Record a step — call / decorator / ``with`` / ``async with`` (same forms as ``tracera.step``)."""
|
|
190
|
+
if callable(title) and body is None:
|
|
191
|
+
return _EnsuredStep(StepContext(title.__name__))(title)
|
|
192
|
+
if body is not None:
|
|
193
|
+
return _run_step_body(str(title), body)
|
|
194
|
+
return _EnsuredStep(_base.step(str(title))) # type: ignore[arg-type]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def comment(text: str) -> None:
|
|
198
|
+
"""Comment on the open step, or on the result when no step is open."""
|
|
199
|
+
if reporting_suspended():
|
|
200
|
+
return
|
|
201
|
+
ctx = _ensure_ctx()
|
|
202
|
+
if ctx is not None:
|
|
203
|
+
with run_with_context(ctx):
|
|
204
|
+
_base.comment(text)
|
|
205
|
+
return
|
|
206
|
+
_base.comment(text)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def error(text: str) -> None:
|
|
210
|
+
"""Soft error text on the open step (or result). Does not by itself fail the test."""
|
|
211
|
+
if reporting_suspended():
|
|
212
|
+
return
|
|
213
|
+
ctx = _ensure_ctx()
|
|
214
|
+
if ctx is not None:
|
|
215
|
+
with run_with_context(ctx):
|
|
216
|
+
_base.error(text)
|
|
217
|
+
return
|
|
218
|
+
_base.error(text)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def attachment(
|
|
222
|
+
meta: AttachmentMeta | dict[str, Any] | None = None,
|
|
223
|
+
*,
|
|
224
|
+
file_name: str | None = None,
|
|
225
|
+
mime_type: str | None = None,
|
|
226
|
+
data: bytes | str | None = None,
|
|
227
|
+
path: str | None = None,
|
|
228
|
+
) -> None:
|
|
229
|
+
"""Attach a file (pending upload) or an already-uploaded AttachmentMeta."""
|
|
230
|
+
if reporting_suspended():
|
|
231
|
+
return
|
|
232
|
+
ctx = _ensure_ctx()
|
|
233
|
+
|
|
234
|
+
def _do() -> None:
|
|
235
|
+
if isinstance(meta, AttachmentMeta) or (
|
|
236
|
+
isinstance(meta, dict) and meta.get("attachmentId") and data is None and path is None
|
|
237
|
+
):
|
|
238
|
+
_base.attachment(meta) # type: ignore[arg-type]
|
|
239
|
+
return
|
|
240
|
+
fname = file_name or (Path(path).name if path else "attachment.bin")
|
|
241
|
+
mime = mime_type or "application/octet-stream"
|
|
242
|
+
blob_data = data.encode("utf-8") if isinstance(data, str) else data
|
|
243
|
+
pending_id = f"pending:{uuid.uuid4()}"
|
|
244
|
+
_register_pending(
|
|
245
|
+
pending_id, {"fileName": fname, "mimeType": mime, "data": blob_data, "path": path}
|
|
246
|
+
)
|
|
247
|
+
_base.attachment({"attachmentId": pending_id, "fileName": fname, "mimeType": mime})
|
|
248
|
+
|
|
249
|
+
if ctx is not None:
|
|
250
|
+
with run_with_context(ctx):
|
|
251
|
+
_do()
|
|
252
|
+
else:
|
|
253
|
+
_do()
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class _TraceraApi:
|
|
257
|
+
test_case = staticmethod(test_case)
|
|
258
|
+
step = staticmethod(step)
|
|
259
|
+
comment = staticmethod(comment)
|
|
260
|
+
error = staticmethod(error)
|
|
261
|
+
attachment = staticmethod(attachment)
|
|
262
|
+
reserve_attachment = staticmethod(_base.reserve_attachment)
|
|
263
|
+
get_context = staticmethod(get_context)
|
|
264
|
+
run_with_context = staticmethod(run_with_context)
|
|
265
|
+
id: object | None = None
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
tracera = _TraceraApi()
|
|
269
|
+
|
|
270
|
+
from tracera_playwright.annotations import id as _id # noqa: E402
|
|
271
|
+
|
|
272
|
+
tracera.id = _id
|