1from __future__ import annotations
2
3import os
4from collections.abc import Generator
5from typing import Any
6
7from opentelemetry.sdk.metrics.export import InMemoryMetricReader
8from opentelemetry.sdk.trace.export.in_memory_span_exporter import (
9 InMemorySpanExporter,
10)
11
12import pytest
13from plain.runtime import settings as plain_settings
14from plain.runtime import setup
15from plain.test.otel import install_test_meter, install_test_tracer
16
17from .browser import TestBrowser
18
19
20def pytest_configure(config: Any) -> None:
21 # Ensure tests run with PLAIN_ENV=test so plain.dev's dotenv loader picks
22 # up `.env.test*` and skips `.env.local` for determinism. `plain test`
23 # already sets this via the CLI dispatcher; this covers direct `pytest`.
24 os.environ.setdefault("PLAIN_ENV", "test")
25
26 # Opportunistically load `.env.test*` via plain.dev's loader if it's
27 # installed. plain.pytest doesn't require plain.dev, so we skip silently
28 # when it's absent — install plain.dev if you want `.env` loading under
29 # direct `pytest` invocations. Narrow to ModuleNotFoundError so a broken
30 # plain.dev install (or a renamed symbol) surfaces instead of being
31 # swallowed.
32 try:
33 from plain.dev.dotenv import load_dotenv_files
34 except ModuleNotFoundError:
35 pass
36 else:
37 load_dotenv_files()
38
39 # Run Plain setup before anything else
40 setup()
41
42
43class SettingsProxy:
44 def __init__(self) -> None:
45 self._original: dict[str, Any] = {}
46
47 def __getattr__(self, name: str) -> Any:
48 return getattr(plain_settings, name)
49
50 def __setattr__(self, name: str, value: Any) -> None:
51 if name.startswith("_"):
52 super().__setattr__(name, value)
53 else:
54 if name not in self._original:
55 self._original[name] = getattr(plain_settings, name, None)
56 setattr(plain_settings, name, value)
57
58 def _restore(self) -> None:
59 for key, value in self._original.items():
60 setattr(plain_settings, key, value)
61
62
63@pytest.fixture
64def settings() -> Generator[SettingsProxy]:
65 proxy = SettingsProxy()
66 yield proxy
67 proxy._restore()
68
69
70@pytest.fixture
71def otel_spans() -> InMemorySpanExporter:
72 """OpenTelemetry spans emitted during the test.
73
74 Returns the InMemorySpanExporter; call `.get_finished_spans()` to read.
75 """
76 exporter = install_test_tracer()
77 exporter.clear()
78 return exporter
79
80
81@pytest.fixture
82def otel_metrics() -> InMemoryMetricReader:
83 """OpenTelemetry metrics emitted during the test.
84
85 Returns the InMemoryMetricReader; call `.get_metrics_data()` or
86 `.collect()` to read. Drains any prior observations on entry.
87 """
88 reader = install_test_meter()
89 reader.get_metrics_data() # drain
90 return reader
91
92
93@pytest.fixture
94def testbrowser(browser: Any, request: Any) -> Generator[TestBrowser]:
95 """Use playwright and pytest-playwright to run browser tests against a test server."""
96 try:
97 # Check if isolated_db fixture is available from the plain-postgres package.
98 # If it is, then we need to run a server that has a database connection to the isolated database for this test.
99 request.getfixturevalue("isolated_db")
100
101 from plain.postgres import get_connection
102 from plain.postgres.database_url import build_database_url
103
104 # Get a database url for the isolated db that we can have the plain server connect to also.
105 database_url = build_database_url(get_connection().settings_dict)
106 except pytest.FixtureLookupError:
107 # isolated_db fixture not available, use empty database_url
108 database_url = ""
109
110 testbrowser = TestBrowser(browser=browser, database_url=database_url)
111
112 try:
113 testbrowser.run_server()
114 yield testbrowser
115 finally:
116 testbrowser.cleanup_server()