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.
@@ -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,8 @@
1
+ """Tracera Autotest adapter for Playwright (Python)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from tracera_playwright.annotations import id
6
+ from tracera_playwright.runtime import tracera
7
+
8
+ __all__ = ["tracera", "id"]
@@ -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