11import os
2+ import re
23import sys
34import time
45import socket
1415if str (PROJECT_ROOT ) not in sys .path :
1516 sys .path .insert (0 , str (PROJECT_ROOT ))
1617
18+ # ---------------------------------------------------------------------------
19+ # Capture the real API key from .env BEFORE any fixtures modify it.
20+ # This is used by live integration tests that need to hit the real API.
21+ # ---------------------------------------------------------------------------
22+ def _read_real_api_key () -> str :
23+ """Read the real API key from os.environ or .env file.
24+
25+ Called once at conftest import time — before any test fixtures modify
26+ the environment. Falls back to .env on disk if os.environ is empty.
27+ """
28+ # 1. Check os.environ first (may be set by the shell or a prior load_dotenv)
29+ key = os .environ .get ("OPENAI_API_KEY" , "" )
30+ if key and not key .startswith ("sk-fake" ):
31+ return key
32+ # 2. Fall back to reading .env directly
33+ env_path = PROJECT_ROOT / ".env"
34+ if not env_path .exists ():
35+ return ""
36+ for line in env_path .read_text ().splitlines ():
37+ m = re .match (r"^OPENAI_API_KEY=(.+)$" , line )
38+ if m :
39+ val = m .group (1 ).strip ()
40+ if val and not val .startswith ("sk-fake" ):
41+ return val
42+ return ""
43+
44+ REAL_API_KEY : str = _read_real_api_key ()
45+
1746
1847@pytest .fixture
1948def anyio_backend () -> str :
@@ -69,6 +98,27 @@ def _isolate_asyncio_running_loop(request: pytest.FixtureRequest) -> Iterator[No
6998 _aio_events ._set_running_loop (saved )
7099
71100
101+ @pytest .fixture (scope = "session" , autouse = True )
102+ def _backup_dotenv () -> Iterator [None ]:
103+ """Back up .env at session start and restore it at session end.
104+
105+ This ensures a crashed or interrupted test run does not permanently
106+ corrupt the user's .env file.
107+ """
108+ env_path = PROJECT_ROOT / ".env"
109+ try :
110+ original = env_path .read_text ()
111+ except FileNotFoundError :
112+ original = None
113+
114+ yield
115+
116+ if original is not None :
117+ env_path .write_text (original )
118+ else :
119+ env_path .unlink (missing_ok = True )
120+
121+
72122@pytest .fixture (scope = "session" )
73123def app_server () -> Generator [int , None , None ]:
74124 """Start the FastAPI app in a background thread on a free port."""
@@ -108,13 +158,15 @@ def base_url(app_server: int) -> str: # type: ignore[override]
108158
109159
110160@contextmanager
111- def _dotenv (overrides : dict [str , str ]) -> Iterator [None ]:
161+ def _dotenv (overrides : dict [str , str ], * , set_fake_api_key : bool = True ) -> Iterator [None ]:
112162 """
113163 Write a temporary .env for the duration of a single test, then restore.
114164
115- Also keeps OPENAI_API_KEY set to the fake value in os.environ so that the
116- FastAPI Depends(lambda: AsyncOpenAI()) in route signatures never raises
117- before load_dotenv(override=True) has a chance to run inside the route body.
165+ Saves and restores both the .env file and os.environ for every key in
166+ *overrides*. When *set_fake_api_key* is True (the default, used by
167+ Playwright tests), OPENAI_API_KEY is also force-set in os.environ so
168+ the FastAPI ``Depends(lambda: AsyncOpenAI())`` never raises before
169+ ``load_dotenv(override=True)`` has a chance to run inside the route body.
118170 """
119171 env_path = PROJECT_ROOT / ".env"
120172
@@ -129,7 +181,8 @@ def _dotenv(overrides: dict[str, str]) -> Iterator[None]:
129181 original_osenv = {k : os .environ .get (k ) for k in all_keys }
130182
131183 # Always keep a fake key in the process env for the Depends constructor.
132- os .environ ["OPENAI_API_KEY" ] = _FAKE_API_KEY
184+ if set_fake_api_key :
185+ os .environ ["OPENAI_API_KEY" ] = _FAKE_API_KEY
133186
134187 # Write the test-specific .env; the route body's load_dotenv will read it.
135188 env_path .write_text (
@@ -153,6 +206,28 @@ def _dotenv(overrides: dict[str, str]) -> Iterator[None]:
153206 del os .environ [k ]
154207
155208
209+ def parse_sse_events (raw : str ) -> list [dict [str , str ]]:
210+ """Parse raw SSE text into a list of {event, data} dicts."""
211+ events : list [dict [str , str ]] = []
212+ current_event : str | None = None
213+ current_data_lines : list [str ] = []
214+
215+ for line in raw .split ("\n " ):
216+ if line .startswith ("event: " ):
217+ current_event = line [len ("event: " ):]
218+ elif line .startswith ("data: " ):
219+ current_data_lines .append (line [len ("data: " ):])
220+ elif line == "" and current_event is not None :
221+ events .append ({
222+ "event" : current_event ,
223+ "data" : "\n " .join (current_data_lines ),
224+ })
225+ current_event = None
226+ current_data_lines = []
227+
228+ return events
229+
230+
156231# ---------------------------------------------------------------------------
157232# Environment fixtures used by test_setup_page_rendering.py
158233# ---------------------------------------------------------------------------
@@ -206,7 +281,27 @@ def env_function_and_mcp(app_server: int) -> Iterator[None]:
206281 yield
207282
208283
284+ @pytest .fixture
285+ def env_web_search_only (app_server : int ) -> Iterator [None ]:
286+ with _dotenv ({** _BASE_ENV , "ENABLED_TOOLS" : "web_search" }):
287+ yield
288+
289+
290+ @pytest .fixture
291+ def env_web_search_with_config (app_server : int ) -> Iterator [None ]:
292+ with _dotenv ({
293+ ** _BASE_ENV ,
294+ "ENABLED_TOOLS" : "web_search" ,
295+ "WEB_SEARCH_CONTEXT_SIZE" : "high" ,
296+ "WEB_SEARCH_LOCATION_COUNTRY" : "US" ,
297+ "WEB_SEARCH_LOCATION_CITY" : "New York" ,
298+ "WEB_SEARCH_LOCATION_REGION" : "New York" ,
299+ "WEB_SEARCH_LOCATION_TIMEZONE" : "America/New_York" ,
300+ }):
301+ yield
302+
303+
209304@pytest .fixture
210305def env_all_tools (app_server : int ) -> Iterator [None ]:
211- with _dotenv ({** _BASE_ENV , "ENABLED_TOOLS" : "file_search,function,mcp" }):
306+ with _dotenv ({** _BASE_ENV , "ENABLED_TOOLS" : "file_search,function,mcp,web_search " }):
212307 yield
0 commit comments