jleechanorg-pr-automation 0.1.1__py3-none-any.whl → 0.2.45__py3-none-any.whl

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.
Files changed (46) hide show
  1. jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
  2. jleechanorg_pr_automation/__init__.py +64 -9
  3. jleechanorg_pr_automation/automation_safety_manager.py +306 -95
  4. jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
  5. jleechanorg_pr_automation/automation_utils.py +87 -65
  6. jleechanorg_pr_automation/check_codex_comment.py +7 -1
  7. jleechanorg_pr_automation/codex_branch_updater.py +21 -9
  8. jleechanorg_pr_automation/codex_config.py +70 -3
  9. jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
  10. jleechanorg_pr_automation/logging_utils.py +86 -0
  11. jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
  12. jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
  13. jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
  14. jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
  15. jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
  16. jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
  17. jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
  18. jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
  19. jleechanorg_pr_automation/tests/__init__.py +0 -0
  20. jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
  21. jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
  22. jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
  23. jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
  24. jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
  25. jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
  26. jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
  27. jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
  28. jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
  29. jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
  30. jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
  31. jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
  32. jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
  33. jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
  34. jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
  35. jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
  36. jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
  37. jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
  38. jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
  39. jleechanorg_pr_automation/utils.py +81 -56
  40. jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
  41. jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
  42. jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
  43. jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
  44. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
  45. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
  46. {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Debug script to check what's actually on the Codex page when connected via CDP.
4
+ """
5
+ import asyncio
6
+ import os
7
+ import tempfile
8
+ from pathlib import Path
9
+
10
+ from jleechanorg_pr_automation.openai_automation.codex_github_mentions import (
11
+ CodexGitHubMentionsAutomation,
12
+ )
13
+
14
+
15
+ async def debug_page():
16
+ """Connect to Chrome and inspect the actual page content."""
17
+ automation = CodexGitHubMentionsAutomation(cdp_url="http://127.0.0.1:9222", all_tasks=True)
18
+
19
+ # Connect to existing browser
20
+ if not await automation.connect_to_existing_browser():
21
+ print("❌ Failed to connect")
22
+ return
23
+
24
+ # Navigate to Codex
25
+ await automation.navigate_to_codex()
26
+
27
+ # Wait extra time for dynamic content
28
+ print("\n⏳ Waiting 10 seconds for page to fully load...")
29
+ await asyncio.sleep(10)
30
+
31
+ # Get page title
32
+ title = await automation.page.title()
33
+ print(f"\n📄 Page title: {title}")
34
+
35
+ # Get current URL
36
+ url = automation.page.url
37
+ print(f"🔗 Current URL: {url}")
38
+
39
+ # Try multiple selectors
40
+ print("\n🔍 Testing different selectors:")
41
+
42
+ selectors = [
43
+ 'a[href*="/codex/"]',
44
+ 'a:has-text("GitHub Mention:")',
45
+ 'a[href^="https://chatgpt.com/codex/"]',
46
+ '[role="link"]',
47
+ 'a',
48
+ 'div[role="article"]',
49
+ 'article',
50
+ ]
51
+
52
+ for selector in selectors:
53
+ try:
54
+ elements = await automation.page.locator(selector).all()
55
+ print(f" {selector}: {len(elements)} elements")
56
+ if 0 < len(elements) < 20:
57
+ for i, elem in enumerate(elements[:3]):
58
+ try:
59
+ text = await elem.text_content()
60
+ preview = text[:80] if text else "(no text)"
61
+ print(f" [{i}]: {preview}")
62
+ except Exception as inner_err:
63
+ print(f" [{i}]: error reading text_content: {inner_err!r}")
64
+ except Exception as e:
65
+ print(f" {selector}: Error - {e}")
66
+
67
+ # Get page HTML (first 2000 chars)
68
+ html = await automation.page.content()
69
+ print(f"\n📝 Page HTML (first 2000 chars):")
70
+ print(html[:2000])
71
+
72
+ # Take screenshot using a secure temp file
73
+ base_dir = Path("/tmp/automate_codex_update")
74
+ base_dir.mkdir(parents=True, exist_ok=True)
75
+ fd, screenshot_path = tempfile.mkstemp(prefix="debug_screenshot_", suffix=".png", dir=str(base_dir))
76
+ await automation.page.screenshot(path=screenshot_path)
77
+ try:
78
+ os.close(fd)
79
+ except OSError:
80
+ # Debug script: safe to ignore close errors on temp fd
81
+ pass
82
+ print(f"\n📸 Screenshot saved to: {screenshot_path}")
83
+
84
+ await automation.cleanup()
85
+
86
+
87
+ if __name__ == "__main__":
88
+ asyncio.run(debug_page())
@@ -0,0 +1,364 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Oracle CLI - Ask GPT-5 Pro questions via browser automation
4
+
5
+ This tool opens a browser (reusing existing session), navigates to ChatGPT,
6
+ and sends a question to GPT-5 Pro (or GPT-4 Pro), then retrieves the answer.
7
+
8
+ Inspired by the existing Oracle tool's browser-based approach for querying
9
+ AI models without needing API keys.
10
+
11
+ Usage:
12
+ # Ask a question
13
+ oracle "What is the capital of France?"
14
+
15
+ # With specific model
16
+ oracle "Explain quantum computing" --model gpt-5-pro
17
+
18
+ # Interactive mode
19
+ oracle --interactive
20
+
21
+ # Use existing browser (requires start_chrome_debug.sh)
22
+ oracle "Question" --use-existing-browser
23
+ """
24
+
25
+ import argparse
26
+ import asyncio
27
+ import traceback
28
+ from typing import Optional
29
+
30
+ from playwright.async_api import (
31
+ Browser,
32
+ Page,
33
+ Playwright,
34
+ TimeoutError as PlaywrightTimeoutError,
35
+ async_playwright,
36
+ )
37
+
38
+
39
+ class OracleCLI:
40
+ """CLI tool to ask GPT-5 Pro questions via browser automation."""
41
+
42
+ def __init__(
43
+ self,
44
+ cdp_url: Optional[str] = None,
45
+ model: str = "gpt-5-pro",
46
+ timeout: int = 60
47
+ ):
48
+ """
49
+ Initialize Oracle CLI.
50
+
51
+ Args:
52
+ cdp_url: Chrome DevTools Protocol URL (if connecting to existing browser)
53
+ model: Model to use (gpt-5-pro, gpt-4, etc.)
54
+ timeout: Timeout in seconds for response
55
+ """
56
+ self.cdp_url = cdp_url
57
+ self.model = model
58
+ self.timeout = timeout
59
+ self.playwright: Optional[Playwright] = None
60
+ self.browser: Optional[Browser] = None
61
+ self.page: Optional[Page] = None
62
+
63
+ async def setup(self):
64
+ """Set up browser connection."""
65
+ if self.playwright is None:
66
+ self.playwright = await async_playwright().start()
67
+
68
+ if self.cdp_url:
69
+ # Connect to existing browser
70
+ print(f"🔌 Connecting to existing browser at {self.cdp_url}...")
71
+ self.browser = await self.playwright.chromium.connect_over_cdp(self.cdp_url)
72
+ contexts = self.browser.contexts
73
+ if contexts:
74
+ context = contexts[0]
75
+ if context.pages:
76
+ self.page = context.pages[0]
77
+ else:
78
+ self.page = await context.new_page()
79
+ else:
80
+ context = await self.browser.new_context()
81
+ self.page = await context.new_page()
82
+ else:
83
+ # Launch new browser (visible)
84
+ self.browser = await self.playwright.chromium.launch(headless=False)
85
+ context = await self.browser.new_context()
86
+ self.page = await context.new_page()
87
+
88
+ print("✅ Browser ready")
89
+
90
+ async def navigate_to_chatgpt(self):
91
+ """Navigate to ChatGPT and ensure logged in."""
92
+ print("📍 Navigating to ChatGPT...")
93
+
94
+ await self.page.goto("https://chatgpt.com/", wait_until="networkidle")
95
+ await asyncio.sleep(2)
96
+
97
+ # Check if logged in
98
+ try:
99
+ await self.page.wait_for_selector(
100
+ 'button[aria-label*="User"], [data-testid="profile-button"]',
101
+ timeout=5000
102
+ )
103
+ print("✅ Logged in to ChatGPT")
104
+ except PlaywrightTimeoutError:
105
+ print("⚠️ Not logged in - please log in manually")
106
+ print(" Waiting 30 seconds for you to log in...")
107
+ await asyncio.sleep(30)
108
+ except Exception as login_error:
109
+ print(f"⚠️ Unexpected login check error: {login_error}")
110
+ await asyncio.sleep(5)
111
+
112
+ async def select_model(self):
113
+ """Select the specified model (GPT-5 Pro, GPT-4, etc.)."""
114
+ print(f"🤖 Selecting model: {self.model}...")
115
+
116
+ try:
117
+ # Look for model selector (adjust based on actual UI)
118
+ model_selector = await self.page.wait_for_selector(
119
+ 'button:has-text("GPT-"), [data-testid="model-selector"]',
120
+ timeout=5000
121
+ )
122
+
123
+ await model_selector.click()
124
+ await asyncio.sleep(1)
125
+
126
+ # Click on desired model
127
+ model_option = await self.page.wait_for_selector(
128
+ f'text="{self.model}"',
129
+ timeout=3000
130
+ )
131
+
132
+ await model_option.click()
133
+ await asyncio.sleep(1)
134
+
135
+ print(f"✅ Selected {self.model}")
136
+
137
+ except Exception as e:
138
+ print(f"⚠️ Could not select model: {e}")
139
+ print(" Using default model")
140
+
141
+ async def ask_question(self, question: str) -> str:
142
+ """
143
+ Ask a question and get the response.
144
+
145
+ Args:
146
+ question: The question to ask
147
+
148
+ Returns:
149
+ The AI's response text
150
+ """
151
+ print(f"\n❓ Question: {question}")
152
+ print("=" * 60)
153
+
154
+ try:
155
+ # Find the input textarea
156
+ input_box = await self.page.wait_for_selector(
157
+ 'textarea[placeholder*="Message"], textarea[data-id="message-input"]',
158
+ timeout=10000
159
+ )
160
+
161
+ # Type the question
162
+ await input_box.click()
163
+ await input_box.fill(question)
164
+ await asyncio.sleep(0.5)
165
+
166
+ # Find and click send button
167
+ send_button = await self.page.wait_for_selector(
168
+ 'button[data-testid="send-button"], button:has-text("Send")',
169
+ timeout=5000
170
+ )
171
+
172
+ await send_button.click()
173
+
174
+ # Wait for response
175
+ print("⏳ Waiting for response...")
176
+
177
+ # Wait for response to appear and complete
178
+ # This is tricky - ChatGPT streams responses
179
+ # We need to wait for the stop button to disappear (indicating response is complete)
180
+ try:
181
+ # Wait for stop button to appear (response started)
182
+ await self.page.wait_for_selector(
183
+ 'button:has-text("Stop"), [data-testid="stop-button"]',
184
+ timeout=10000
185
+ )
186
+
187
+ # Wait for stop button to disappear (response completed)
188
+ await self.page.wait_for_selector(
189
+ 'button:has-text("Stop"), [data-testid="stop-button"]',
190
+ state="hidden",
191
+ timeout=self.timeout * 1000
192
+ )
193
+
194
+ await asyncio.sleep(1)
195
+
196
+ except Exception as e:
197
+ print(f"⚠️ Response detection issue: {e}")
198
+ # Fallback: just wait a bit
199
+ await asyncio.sleep(10)
200
+
201
+ # Extract the response
202
+ # Find the last message (should be the AI's response)
203
+ messages = await self.page.locator('[data-message-author-role="assistant"]').all()
204
+
205
+ if messages:
206
+ response = await messages[-1].text_content()
207
+ response_text = (response or "").strip()
208
+ return response_text or "❌ Empty response received"
209
+ else:
210
+ return "❌ Could not extract response"
211
+
212
+ except Exception as e:
213
+ print(f"❌ Error asking question: {e}")
214
+ traceback.print_exc()
215
+ return f"Error: {e}"
216
+
217
+ async def interactive_mode(self):
218
+ """Run in interactive mode for multiple questions."""
219
+ print("\n🎙️ Oracle Interactive Mode")
220
+ print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
221
+ print("Type your questions (or 'exit' to quit)")
222
+ print("")
223
+
224
+ while True:
225
+ try:
226
+ question = input("\n❓ Your question: ").strip()
227
+
228
+ if not question:
229
+ continue
230
+
231
+ if question.lower() in ['exit', 'quit', 'q']:
232
+ print("👋 Goodbye!")
233
+ break
234
+
235
+ response = await self.ask_question(question)
236
+ print(f"\n💡 Answer:\n{response}")
237
+ print("\n" + "━" * 60)
238
+
239
+ except KeyboardInterrupt:
240
+ print("\n\n👋 Interrupted by user")
241
+ break
242
+
243
+ async def run(self, question: Optional[str] = None, interactive: bool = False):
244
+ """
245
+ Main workflow.
246
+
247
+ Args:
248
+ question: Single question to ask (if not interactive)
249
+ interactive: Run in interactive mode
250
+ """
251
+ try:
252
+ await self.setup()
253
+ await self.navigate_to_chatgpt()
254
+ await self.select_model()
255
+
256
+ if interactive:
257
+ await self.interactive_mode()
258
+ elif question:
259
+ response = await self.ask_question(question)
260
+ print(f"\n💡 Answer:\n{response}")
261
+ return response
262
+ else:
263
+ print("❌ No question provided and not in interactive mode")
264
+ return None
265
+
266
+ except KeyboardInterrupt:
267
+ print("\n\n⚠️ Interrupted by user")
268
+ return None
269
+
270
+ except Exception as e:
271
+ print(f"\n❌ Oracle failed: {e}")
272
+ traceback.print_exc()
273
+ return None
274
+
275
+ finally:
276
+ if self.browser and not self.cdp_url:
277
+ # Only close if we launched it (not using existing browser)
278
+ await self.browser.close()
279
+ if self.playwright:
280
+ await self.playwright.stop()
281
+ self.playwright = None
282
+
283
+
284
+ async def main():
285
+ """CLI entry point."""
286
+ parser = argparse.ArgumentParser(
287
+ description="Ask GPT-5 Pro questions via browser automation",
288
+ formatter_class=argparse.RawDescriptionHelpFormatter,
289
+ epilog="""
290
+ Examples:
291
+ # Ask a single question
292
+ oracle "What is the meaning of life?"
293
+
294
+ # Interactive mode
295
+ oracle --interactive
296
+
297
+ # Use specific model
298
+ oracle "Explain AI" --model gpt-4
299
+
300
+ # Connect to existing browser
301
+ oracle "Question" --use-existing-browser --cdp-port 9222
302
+ """
303
+ )
304
+
305
+ parser.add_argument(
306
+ "question",
307
+ nargs="?",
308
+ help="Question to ask (optional if using --interactive)"
309
+ )
310
+
311
+ parser.add_argument(
312
+ "-i", "--interactive",
313
+ action="store_true",
314
+ help="Run in interactive mode for multiple questions"
315
+ )
316
+
317
+ parser.add_argument(
318
+ "--model",
319
+ default="gpt-5-pro",
320
+ help="Model to use (default: gpt-5-pro)"
321
+ )
322
+
323
+ parser.add_argument(
324
+ "--use-existing-browser",
325
+ action="store_true",
326
+ help="Connect to existing Chrome instance (requires start_chrome_debug.sh)"
327
+ )
328
+
329
+ parser.add_argument(
330
+ "--cdp-port",
331
+ type=int,
332
+ default=9222,
333
+ help="CDP port if using existing browser (default: 9222)"
334
+ )
335
+
336
+ parser.add_argument(
337
+ "--timeout",
338
+ type=int,
339
+ default=60,
340
+ help="Timeout in seconds for response (default: 60)"
341
+ )
342
+
343
+ args = parser.parse_args()
344
+
345
+ # Validate inputs
346
+ if not args.question and not args.interactive:
347
+ parser.error("Must provide either a question or use --interactive mode")
348
+
349
+ # Build CDP URL if using existing browser
350
+ cdp_url = f"http://localhost:{args.cdp_port}" if args.use_existing_browser else None
351
+
352
+ # Create Oracle instance
353
+ oracle = OracleCLI(
354
+ cdp_url=cdp_url,
355
+ model=args.model,
356
+ timeout=args.timeout
357
+ )
358
+
359
+ # Run
360
+ await oracle.run(question=args.question, interactive=args.interactive)
361
+
362
+
363
+ if __name__ == "__main__":
364
+ asyncio.run(main())
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unit tests for authentication state restoration in CodexGitHubMentionsAutomation.
4
+ Focuses on cookie validation, localStorage restoration, and error handling.
5
+ """
6
+
7
+ import json
8
+ from unittest.mock import AsyncMock, Mock, patch
9
+ import pytest
10
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
11
+ from jleechanorg_pr_automation.openai_automation.codex_github_mentions import (
12
+ CodexGitHubMentionsAutomation,
13
+ AUTH_STATE_PATH,
14
+ )
15
+
16
+ @pytest.fixture
17
+ def automation():
18
+ """Create automation instance with mocked browser/context/page."""
19
+ auto = CodexGitHubMentionsAutomation()
20
+ auto.context = AsyncMock()
21
+
22
+ # Setup page mock
23
+ page_mock = AsyncMock()
24
+ # is_closed is synchronous in Playwright
25
+ page_mock.is_closed = Mock(return_value=False)
26
+ page_mock.url = "https://chatgpt.com/c/123"
27
+
28
+ auto.page = page_mock
29
+ return auto
30
+
31
+ @pytest.mark.asyncio
32
+ async def test_auth_restoration_cookies_and_localstorage(automation):
33
+ """Test full restoration of cookies and localStorage from valid auth state."""
34
+
35
+ mock_state = {
36
+ "cookies": [
37
+ {
38
+ "name": "session_token",
39
+ "value": "xyz",
40
+ "domain": ".chatgpt.com",
41
+ "path": "/"
42
+ }
43
+ ],
44
+ "origins": [
45
+ {
46
+ "origin": "https://chatgpt.com",
47
+ "localStorage": [
48
+ {"name": "theme", "value": "dark"},
49
+ {"name": "feature_flags", "value": "true"}
50
+ ]
51
+ }
52
+ ]
53
+ }
54
+
55
+ # Mock file existence and read
56
+ with patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions.AUTH_STATE_PATH") as mock_path, \
57
+ patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions._ensure_auth_state_permissions") as mock_perms:
58
+
59
+ mock_path.exists.return_value = True
60
+ mock_path.read_text.return_value = json.dumps(mock_state)
61
+ mock_path.__str__.return_value = "/tmp/fake_path"
62
+
63
+ # Configure wait_for_selector to fail first, then succeed
64
+ automation.page.wait_for_selector.side_effect = [
65
+ PlaywrightTimeoutError("Not logged in"),
66
+ True
67
+ ]
68
+
69
+ result = await automation.ensure_openai_login()
70
+
71
+ # Verify permissions check
72
+ mock_perms.assert_called_once_with(mock_path)
73
+
74
+ # Verify cookie injection
75
+ automation.context.add_cookies.assert_awaited_once_with(mock_state["cookies"])
76
+
77
+ # Verify localStorage injection via page.evaluate
78
+ assert automation.page.evaluate.call_count == 2
79
+
80
+ # Verify calls contain the correct keys/values
81
+ call_args = automation.page.evaluate.await_args_list
82
+ # Note: calls might be in any order if list iteration order varies, but list is ordered here
83
+ assert 'setItem("theme", "dark")' in call_args[0][0][0]
84
+ assert 'setItem("feature_flags", "true")' in call_args[1][0][0]
85
+
86
+ assert result is True
87
+
88
+ @pytest.mark.asyncio
89
+ async def test_auth_restoration_origin_mismatch(automation):
90
+ """Test that localStorage is NOT injected if origin doesn't match."""
91
+
92
+ mock_state = {
93
+ "cookies": [{"name": "c", "value": "v", "domain": "chatgpt.com", "path": "/"}],
94
+ "origins": [
95
+ {
96
+ "origin": "https://other-domain.com",
97
+ "localStorage": [{"name": "secret", "value": "fail"}]
98
+ }
99
+ ]
100
+ }
101
+
102
+ # Page URL is chatgpt.com (from fixture), so origin shouldn't match
103
+ with patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions.AUTH_STATE_PATH") as mock_path, \
104
+ patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions._ensure_auth_state_permissions"):
105
+
106
+ mock_path.exists.return_value = True
107
+ mock_path.read_text.return_value = json.dumps(mock_state)
108
+
109
+ automation.page.wait_for_selector.side_effect = [PlaywrightTimeoutError("Fail"), True]
110
+
111
+ await automation.ensure_openai_login()
112
+
113
+ # Should NOT call evaluate to set items
114
+ automation.page.evaluate.assert_not_awaited()
115
+
116
+ @pytest.mark.asyncio
117
+ async def test_auth_restoration_secure_origin_matching(automation):
118
+ """Test that subdomain matching prevents injection into wrong subdomains."""
119
+
120
+ # State has origin https://chatgpt.com
121
+ mock_state = {
122
+ "cookies": [{"name": "c", "value": "v", "domain": "chatgpt.com", "path": "/"}],
123
+ "origins": [
124
+ {
125
+ "origin": "https://chatgpt.com",
126
+ "localStorage": [{"name": "key", "value": "val"}]
127
+ }
128
+ ]
129
+ }
130
+
131
+ # 1. Malicious subdomain matching test
132
+ automation.page.url = "https://chatgpt.com.evil.com/login"
133
+
134
+ with patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions.AUTH_STATE_PATH") as mock_path, \
135
+ patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions._ensure_auth_state_permissions"):
136
+
137
+ mock_path.exists.return_value = True
138
+ mock_path.read_text.return_value = json.dumps(mock_state)
139
+
140
+ automation.page.wait_for_selector = AsyncMock(side_effect=[
141
+ PlaywrightTimeoutError("Fail"),
142
+ True
143
+ ])
144
+ await automation.ensure_openai_login()
145
+ automation.page.evaluate.assert_not_awaited()
146
+
147
+ # 2. Correct domain test
148
+ automation.page.url = "https://chatgpt.com/c/123"
149
+
150
+ with patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions.AUTH_STATE_PATH") as mock_path, \
151
+ patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions._ensure_auth_state_permissions"):
152
+
153
+ mock_path.exists.return_value = True
154
+ mock_path.read_text.return_value = json.dumps(mock_state)
155
+
156
+ # Reset mock for second run
157
+ automation.page.evaluate = AsyncMock()
158
+ automation.page.wait_for_selector = AsyncMock(side_effect=[
159
+ PlaywrightTimeoutError("Fail"),
160
+ True
161
+ ])
162
+
163
+ await automation.ensure_openai_login()
164
+ automation.page.evaluate.assert_awaited()
165
+
166
+ @pytest.mark.asyncio
167
+ async def test_auth_restoration_cookie_validation(automation):
168
+ """Test validation of cookies (missing fields, incomplete data)."""
169
+
170
+ mock_state = {
171
+ "cookies": [
172
+ {"name": "valid", "value": "1", "domain": ".com", "path": "/"}, # Valid
173
+ {"name": "bad1"}, # Missing value
174
+ {"name": "bad2", "value": "2"}, # Missing domain/path AND url
175
+ {"not_a_dict": True}, # Invalid type
176
+ ]
177
+ }
178
+
179
+ with patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions.AUTH_STATE_PATH") as mock_path, \
180
+ patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions._ensure_auth_state_permissions"):
181
+
182
+ mock_path.exists.return_value = True
183
+ mock_path.read_text.return_value = json.dumps(mock_state)
184
+
185
+ automation.page.wait_for_selector.side_effect = [PlaywrightTimeoutError("Fail"), True]
186
+
187
+ await automation.ensure_openai_login()
188
+
189
+ # Should only inject the one valid cookie
190
+ automation.context.add_cookies.assert_awaited_once()
191
+ call_args = automation.context.add_cookies.call_args[0][0]
192
+ assert len(call_args) == 1
193
+ assert call_args[0]["name"] == "valid"
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_auth_restoration_null_cookies(automation):
197
+ """Test handling of 'cookies': null in JSON."""
198
+
199
+ mock_state = {"cookies": None}
200
+
201
+ with patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions.AUTH_STATE_PATH") as mock_path, \
202
+ patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions._ensure_auth_state_permissions"):
203
+
204
+ mock_path.exists.return_value = True
205
+ mock_path.read_text.return_value = json.dumps(mock_state)
206
+
207
+ automation.page.wait_for_selector.side_effect = [PlaywrightTimeoutError("Fail"), True]
208
+
209
+ await automation.ensure_openai_login()
210
+
211
+ # Should not crash, should not call add_cookies
212
+ automation.context.add_cookies.assert_not_awaited()
213
+
214
+ @pytest.mark.asyncio
215
+ async def test_auth_restoration_empty_localstorage_values(automation):
216
+ """Test that empty string values in localStorage are preserved (not skipped)."""
217
+
218
+ mock_state = {
219
+ "cookies": [{"name": "c", "value": "v", "domain": "chatgpt.com", "path": "/"}],
220
+ "origins": [
221
+ {
222
+ "origin": "https://chatgpt.com",
223
+ "localStorage": [
224
+ {"name": "empty_val", "value": ""}, # Should be kept
225
+ {"name": "null_val", "value": None} # Should be skipped if logic checks for None
226
+ ]
227
+ }
228
+ ]
229
+ }
230
+
231
+ with patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions.AUTH_STATE_PATH") as mock_path, \
232
+ patch("jleechanorg_pr_automation.openai_automation.codex_github_mentions._ensure_auth_state_permissions"):
233
+
234
+ mock_path.exists.return_value = True
235
+ mock_path.read_text.return_value = json.dumps(mock_state)
236
+
237
+ automation.page.wait_for_selector.side_effect = [PlaywrightTimeoutError("Fail"), True]
238
+ await automation.ensure_openai_login()
239
+
240
+ # Should call evaluate for empty string value
241
+ # But NOT for None value (logic: if key is not None and value is not None)
242
+ assert automation.page.evaluate.call_count == 1
243
+ call_arg = automation.page.evaluate.call_args[0][0]
244
+ assert 'setItem("empty_val", "")' in call_arg