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.
- jleechanorg_pr_automation/STORAGE_STATE_TESTING_PROTOCOL.md +326 -0
- jleechanorg_pr_automation/__init__.py +64 -9
- jleechanorg_pr_automation/automation_safety_manager.py +306 -95
- jleechanorg_pr_automation/automation_safety_wrapper.py +13 -19
- jleechanorg_pr_automation/automation_utils.py +87 -65
- jleechanorg_pr_automation/check_codex_comment.py +7 -1
- jleechanorg_pr_automation/codex_branch_updater.py +21 -9
- jleechanorg_pr_automation/codex_config.py +70 -3
- jleechanorg_pr_automation/jleechanorg_pr_monitor.py +1954 -234
- jleechanorg_pr_automation/logging_utils.py +86 -0
- jleechanorg_pr_automation/openai_automation/__init__.py +3 -0
- jleechanorg_pr_automation/openai_automation/codex_github_mentions.py +1111 -0
- jleechanorg_pr_automation/openai_automation/debug_page_content.py +88 -0
- jleechanorg_pr_automation/openai_automation/oracle_cli.py +364 -0
- jleechanorg_pr_automation/openai_automation/test_auth_restoration.py +244 -0
- jleechanorg_pr_automation/openai_automation/test_codex_comprehensive.py +355 -0
- jleechanorg_pr_automation/openai_automation/test_codex_integration.py +254 -0
- jleechanorg_pr_automation/orchestrated_pr_runner.py +516 -0
- jleechanorg_pr_automation/tests/__init__.py +0 -0
- jleechanorg_pr_automation/tests/test_actionable_counting_matrix.py +84 -86
- jleechanorg_pr_automation/tests/test_attempt_limit_logic.py +124 -0
- jleechanorg_pr_automation/tests/test_automation_marker_functions.py +175 -0
- jleechanorg_pr_automation/tests/test_automation_over_running_reproduction.py +9 -11
- jleechanorg_pr_automation/tests/test_automation_safety_limits.py +91 -79
- jleechanorg_pr_automation/tests/test_automation_safety_manager_comprehensive.py +53 -53
- jleechanorg_pr_automation/tests/test_codex_actor_matching.py +1 -1
- jleechanorg_pr_automation/tests/test_fixpr_prompt.py +54 -0
- jleechanorg_pr_automation/tests/test_fixpr_return_value.py +140 -0
- jleechanorg_pr_automation/tests/test_graphql_error_handling.py +26 -26
- jleechanorg_pr_automation/tests/test_model_parameter.py +317 -0
- jleechanorg_pr_automation/tests/test_orchestrated_pr_runner.py +697 -0
- jleechanorg_pr_automation/tests/test_packaging_integration.py +127 -0
- jleechanorg_pr_automation/tests/test_pr_filtering_matrix.py +246 -193
- jleechanorg_pr_automation/tests/test_pr_monitor_eligibility.py +354 -0
- jleechanorg_pr_automation/tests/test_pr_targeting.py +102 -7
- jleechanorg_pr_automation/tests/test_version_consistency.py +51 -0
- jleechanorg_pr_automation/tests/test_workflow_specific_limits.py +202 -0
- jleechanorg_pr_automation/tests/test_workspace_dispatch_missing_dir.py +119 -0
- jleechanorg_pr_automation/utils.py +81 -56
- jleechanorg_pr_automation-0.2.45.dist-info/METADATA +864 -0
- jleechanorg_pr_automation-0.2.45.dist-info/RECORD +45 -0
- jleechanorg_pr_automation-0.1.1.dist-info/METADATA +0 -222
- jleechanorg_pr_automation-0.1.1.dist-info/RECORD +0 -23
- {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/WHEEL +0 -0
- {jleechanorg_pr_automation-0.1.1.dist-info → jleechanorg_pr_automation-0.2.45.dist-info}/entry_points.txt +0 -0
- {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
|