socialseed-e2e 0.1.0__py3-none-any.whl → 0.1.2__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 (29) hide show
  1. socialseed_e2e/__init__.py +184 -20
  2. socialseed_e2e/__version__.py +2 -2
  3. socialseed_e2e/cli.py +353 -190
  4. socialseed_e2e/core/base_page.py +368 -49
  5. socialseed_e2e/core/config_loader.py +15 -3
  6. socialseed_e2e/core/headers.py +11 -4
  7. socialseed_e2e/core/loaders.py +6 -4
  8. socialseed_e2e/core/test_orchestrator.py +2 -0
  9. socialseed_e2e/core/test_runner.py +487 -0
  10. socialseed_e2e/templates/agent_docs/AGENT_GUIDE.md.template +412 -0
  11. socialseed_e2e/templates/agent_docs/EXAMPLE_TEST.md.template +152 -0
  12. socialseed_e2e/templates/agent_docs/FRAMEWORK_CONTEXT.md.template +55 -0
  13. socialseed_e2e/templates/agent_docs/WORKFLOW_GENERATION.md.template +182 -0
  14. socialseed_e2e/templates/data_schema.py.template +111 -70
  15. socialseed_e2e/templates/e2e.conf.template +19 -0
  16. socialseed_e2e/templates/service_page.py.template +82 -27
  17. socialseed_e2e/templates/test_module.py.template +21 -7
  18. socialseed_e2e/templates/verify_installation.py +192 -0
  19. socialseed_e2e/utils/__init__.py +29 -0
  20. socialseed_e2e/utils/ai_generator.py +463 -0
  21. socialseed_e2e/utils/pydantic_helpers.py +392 -0
  22. socialseed_e2e/utils/state_management.py +312 -0
  23. {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/METADATA +64 -27
  24. socialseed_e2e-0.1.2.dist-info/RECORD +38 -0
  25. socialseed_e2e-0.1.0.dist-info/RECORD +0 -29
  26. {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/WHEEL +0 -0
  27. {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/entry_points.txt +0 -0
  28. {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/licenses/LICENSE +0 -0
  29. {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/top_level.txt +0 -0
@@ -265,6 +265,16 @@ class ApiConfigLoader:
265
265
  if path.exists():
266
266
  return str(path)
267
267
 
268
+ # Priority 4.5: Search in any immediate subdirectory (useful for monorepos)
269
+ try:
270
+ for sub_item in Path.cwd().iterdir():
271
+ if sub_item.is_dir() and not sub_item.name.startswith("."):
272
+ sub_config = sub_item / "e2e.conf"
273
+ if sub_config.exists():
274
+ return str(sub_config)
275
+ except Exception:
276
+ pass # Avoid crashes during directory iteration
277
+
268
278
  # Priority 5: Global config in home directory
269
279
  home_config = Path.home() / ".config" / "socialseed-e2e" / "default.conf"
270
280
  if home_config.exists():
@@ -291,9 +301,11 @@ class ApiConfigLoader:
291
301
  " 2. ./e2e.conf\n"
292
302
  " 3. ./config/e2e.conf\n"
293
303
  " 4. ./tests/e2e.conf\n"
294
- " 5. ~/.config/socialseed-e2e/default.conf\n"
295
- " 6. verify_services/api.conf (legacy)\n"
296
- " 7. ./api.conf (legacy)\n\n"
304
+ " 5. Immediate subdirectories (e.g., ./otrotest/e2e.conf)\n"
305
+ " 6. ~/.config/socialseed-e2e/default.conf\n"
306
+ " 7. verify_services/api.conf (legacy)\n"
307
+ " 8. ./api.conf (legacy)\n\n"
308
+ f"Current directory: {os.getcwd()}\n\n"
297
309
  "To create a default configuration, run:\n"
298
310
  " e2e init\n\n"
299
311
  "Or set the E2E_CONFIG_PATH environment variable:\n"
@@ -1,4 +1,6 @@
1
- from typing import Dict
1
+ """Header definitions for API requests."""
2
+
3
+ from typing import Dict, Optional
2
4
 
3
5
  DEFAULT_JSON_HEADERS: Dict[str, str] = {
4
6
  "Content-Type": "application/json",
@@ -6,14 +8,19 @@ DEFAULT_JSON_HEADERS: Dict[str, str] = {
6
8
  }
7
9
 
8
10
  DEFAULT_BROWSER_HEADERS: Dict[str, str] = {
9
- "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
11
+ "User-Agent": (
12
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
13
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
14
+ ),
10
15
  "Accept-Language": "en-US,en;q=0.9",
11
16
  "Connection": "keep-alive",
12
17
  }
13
18
 
14
19
 
15
- def get_combined_headers(custom_headers: Dict[str, str] = None) -> Dict[str, str]:
16
- """Helper to combine default headers with custom ones."""
20
+ def get_combined_headers(
21
+ custom_headers: Optional[Dict[str, str]] = None,
22
+ ) -> Dict[str, str]:
23
+ """Combine default headers with custom ones."""
17
24
  headers = {**DEFAULT_JSON_HEADERS, **DEFAULT_BROWSER_HEADERS}
18
25
  if custom_headers:
19
26
  headers.update(custom_headers)
@@ -1,7 +1,9 @@
1
+ """Module loaders for dynamic test discovery."""
2
+
1
3
  import importlib.util
2
4
  import sys
3
5
  from pathlib import Path
4
- from typing import Callable, List, Optional
6
+ from typing import Callable, List, Optional, cast
5
7
 
6
8
 
7
9
  class ModuleLoader:
@@ -9,7 +11,7 @@ class ModuleLoader:
9
11
 
10
12
  @staticmethod
11
13
  def load_runnable_from_file(file_path: Path, function_name: str = "run") -> Optional[Callable]:
12
- """Loads a specific function from a python file."""
14
+ """Load a specific function from a python file."""
13
15
  if not file_path.exists() or file_path.suffix != ".py":
14
16
  return None
15
17
 
@@ -25,7 +27,7 @@ class ModuleLoader:
25
27
 
26
28
  func = getattr(module, function_name, None)
27
29
  if callable(func):
28
- return func
30
+ return cast(Callable, func)
29
31
  except Exception as e:
30
32
  print(f"Error loading {file_path}: {e}")
31
33
 
@@ -33,7 +35,7 @@ class ModuleLoader:
33
35
 
34
36
  def discover_runnables(self, root_path: Path, pattern: str = "*.py") -> List[Callable]:
35
37
  """Discovers all runnable modules in a directory matching a pattern."""
36
- runnables = []
38
+ runnables: List[Callable] = []
37
39
  if not root_path.exists() or not root_path.is_dir():
38
40
  return runnables
39
41
 
@@ -19,6 +19,8 @@ class TestOrchestrator:
19
19
  Auto-discovers modules in services/*/modules/ and runs them.
20
20
  """
21
21
 
22
+ __test__ = False
23
+
22
24
  def __init__(
23
25
  self, root_dir: Optional[str] = None, services_path: str = "verify_services/e2e/services"
24
26
  ):
@@ -0,0 +1,487 @@
1
+ """Test runner implementation for socialseed-e2e framework.
2
+
3
+ This module provides the actual test execution logic for the e2e run command.
4
+ """
5
+
6
+ import importlib
7
+ import importlib.util
8
+ import sys
9
+ import time
10
+ import traceback
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, List, Optional, Tuple, Type
14
+
15
+ from playwright.sync_api import sync_playwright
16
+ from rich.console import Console
17
+ from rich.panel import Panel
18
+ from rich.progress import Progress, SpinnerColumn, TextColumn
19
+ from rich.table import Table
20
+
21
+ from socialseed_e2e import BasePage
22
+ from socialseed_e2e.core.config_loader import ApiConfigLoader, ServiceConfig, get_service_config
23
+
24
+ console = Console()
25
+
26
+
27
+ @dataclass
28
+ class TestResult:
29
+ """Result of a single test execution."""
30
+
31
+ name: str
32
+ service: str
33
+ status: str # "passed", "failed", "skipped", "error"
34
+ duration_ms: float = 0.0
35
+ error_message: str = ""
36
+ error_traceback: str = ""
37
+
38
+
39
+ @dataclass
40
+ class TestSuiteResult:
41
+ """Result of a complete test suite execution."""
42
+
43
+ total: int = 0
44
+ passed: int = 0
45
+ failed: int = 0
46
+ skipped: int = 0
47
+ errors: int = 0
48
+ total_duration_ms: float = 0.0
49
+ results: List[TestResult] = field(default_factory=list)
50
+
51
+ @property
52
+ def success_rate(self) -> float:
53
+ if self.total == 0:
54
+ return 0.0
55
+ return (self.passed / self.total) * 100
56
+
57
+
58
+ class TestDiscoveryError(Exception):
59
+ """Error during test discovery."""
60
+
61
+ pass
62
+
63
+
64
+ class TestExecutionError(Exception):
65
+ """Error during test execution."""
66
+
67
+ pass
68
+
69
+
70
+ def discover_services(services_path: Path = Path("services")) -> List[str]:
71
+ """Discover all services with test modules.
72
+
73
+ Args:
74
+ services_path: Path to services directory
75
+
76
+ Returns:
77
+ List of service names
78
+ """
79
+ if not services_path.exists():
80
+ return []
81
+
82
+ services = []
83
+ for item in services_path.iterdir():
84
+ if item.is_dir() and not item.name.startswith("__"):
85
+ # Check if it has a modules directory
86
+ modules_path = item / "modules"
87
+ if modules_path.exists():
88
+ services.append(item.name)
89
+
90
+ return sorted(services)
91
+
92
+
93
+ def discover_test_modules(service_path: Path) -> List[Path]:
94
+ """Discover all test modules in a service.
95
+
96
+ Args:
97
+ service_path: Path to service directory
98
+
99
+ Returns:
100
+ List of test module paths, sorted by filename
101
+ """
102
+ modules_path = service_path / "modules"
103
+ if not modules_path.exists():
104
+ return []
105
+
106
+ # Find modules with pattern _XX_*.py or XX_*.py
107
+ modules = []
108
+ for item in modules_path.iterdir():
109
+ if item.is_file() and item.suffix == ".py" and not item.name.startswith("__"):
110
+ # Check if it matches test module pattern
111
+ name = item.name
112
+ if name.startswith("_") and len(name) > 3 and name[1:3].isdigit():
113
+ modules.append(item)
114
+ elif len(name) > 2 and name[:2].isdigit():
115
+ modules.append(item)
116
+
117
+ # Sort by filename
118
+ return sorted(modules, key=lambda p: p.name)
119
+
120
+
121
+ def load_test_module(module_path: Path) -> Optional[Callable]:
122
+ """Load a test module and return its run function.
123
+
124
+ Args:
125
+ module_path: Path to test module
126
+
127
+ Returns:
128
+ The run function from the module, or None if not found
129
+ """
130
+ try:
131
+ # Create a module spec
132
+ module_name = f"test_module_{module_path.stem}"
133
+ spec = importlib.util.spec_from_file_location(module_name, module_path)
134
+
135
+ if spec is None or spec.loader is None:
136
+ return None
137
+
138
+ # Load the module
139
+ module = importlib.util.module_from_spec(spec)
140
+ sys.modules[module_name] = module
141
+ spec.loader.exec_module(module)
142
+
143
+ # Get the run function
144
+ if hasattr(module, "run"):
145
+ return module.run
146
+
147
+ return None
148
+ except Exception as e:
149
+ console.print(f"[red]Error loading module {module_path}: {e}[/red]")
150
+ return None
151
+
152
+
153
+ def create_page_class(service_name: str, service_path: Path) -> Optional[Type[BasePage]]:
154
+ """Create or find the Page class for a service.
155
+
156
+ Args:
157
+ service_name: Name of the service
158
+ service_path: Path to service directory
159
+
160
+ Returns:
161
+ Page class, or None if not found
162
+ """
163
+ # Add project root to sys.path for imports
164
+ project_root = service_path.parent.parent.absolute()
165
+ if str(project_root) not in sys.path:
166
+ sys.path.insert(0, str(project_root))
167
+
168
+ # Try to find page file
169
+ page_files = list(service_path.glob("*_page.py"))
170
+ if not page_files:
171
+ # Try with service name
172
+ page_file = service_path / f"{service_name}_page.py"
173
+ if not page_file.exists():
174
+ return None
175
+ page_files = [page_file]
176
+
177
+ page_file = page_files[0]
178
+
179
+ try:
180
+ # Load the page module
181
+ module_name = f"page_{service_name}"
182
+ spec = importlib.util.spec_from_file_location(module_name, page_file)
183
+
184
+ if spec is None or spec.loader is None:
185
+ return None
186
+
187
+ module = importlib.util.module_from_spec(spec)
188
+ sys.modules[module_name] = module
189
+ spec.loader.exec_module(module)
190
+
191
+ # Find the Page class (should be the one inheriting from BasePage)
192
+ for attr_name in dir(module):
193
+ attr = getattr(module, attr_name)
194
+ if isinstance(attr, type) and issubclass(attr, BasePage) and attr is not BasePage:
195
+ return attr
196
+
197
+ return None
198
+ except Exception as e:
199
+ console.print(f"[red]Error loading page class from {page_file}: {e}[/red]")
200
+ return None
201
+
202
+
203
+ def execute_single_test(module_path: Path, page: BasePage, service_name: str) -> TestResult:
204
+ """Execute a single test module.
205
+
206
+ Args:
207
+ module_path: Path to test module
208
+ page: Page instance to pass to test
209
+ service_name: Name of the service
210
+
211
+ Returns:
212
+ TestResult with execution details
213
+ """
214
+ start_time = time.time()
215
+ test_name = module_path.stem
216
+
217
+ try:
218
+ # Load the test module
219
+ run_func = load_test_module(module_path)
220
+
221
+ if run_func is None:
222
+ return TestResult(
223
+ name=test_name,
224
+ service=service_name,
225
+ status="error",
226
+ error_message="No 'run' function found in module",
227
+ )
228
+
229
+ # Execute the test
230
+ run_func(page)
231
+
232
+ duration = (time.time() - start_time) * 1000
233
+ return TestResult(
234
+ name=test_name, service=service_name, status="passed", duration_ms=duration
235
+ )
236
+
237
+ except AssertionError as e:
238
+ duration = (time.time() - start_time) * 1000
239
+ return TestResult(
240
+ name=test_name,
241
+ service=service_name,
242
+ status="failed",
243
+ duration_ms=duration,
244
+ error_message=str(e),
245
+ )
246
+ except Exception as e:
247
+ duration = (time.time() - start_time) * 1000
248
+ return TestResult(
249
+ name=test_name,
250
+ service=service_name,
251
+ status="error",
252
+ duration_ms=duration,
253
+ error_message=str(e),
254
+ error_traceback=traceback.format_exc(),
255
+ )
256
+
257
+
258
+ def run_service_tests(
259
+ service_name: str,
260
+ service_config: Optional[Any],
261
+ services_path: Path = Path("services"),
262
+ specific_module: Optional[str] = None,
263
+ verbose: bool = False,
264
+ ) -> TestSuiteResult:
265
+ """Run all tests for a service.
266
+
267
+ Args:
268
+ service_name: Name of the service
269
+ service_config: Service configuration
270
+ services_path: Path to services directory
271
+ specific_module: If specified, only run this module
272
+ verbose: Whether to show verbose output
273
+
274
+ Returns:
275
+ TestSuiteResult with all test results
276
+ """
277
+ service_path = services_path / service_name
278
+ suite_result = TestSuiteResult()
279
+
280
+ # Discover test modules
281
+ if specific_module:
282
+ # Find specific module
283
+ modules_path = service_path / "modules"
284
+ module_path = modules_path / specific_module
285
+ if not module_path.exists():
286
+ module_path = modules_path / f"_{specific_module}.py"
287
+ if not module_path.exists():
288
+ console.print(f"[red]Module '{specific_module}' not found[/red]")
289
+ return suite_result
290
+ test_modules = [module_path]
291
+ else:
292
+ test_modules = discover_test_modules(service_path)
293
+
294
+ if not test_modules:
295
+ console.print(f"[yellow]No test modules found for service '{service_name}'[/yellow]")
296
+ return suite_result
297
+
298
+ # Get base URL
299
+ base_url = service_config.base_url if service_config else f"http://localhost:8080"
300
+
301
+ # Create page class
302
+ PageClass = create_page_class(service_name, service_path)
303
+ if PageClass is None:
304
+ console.print(f"[yellow]No Page class found for '{service_name}', using BasePage[/yellow]")
305
+ PageClass = BasePage
306
+
307
+ console.print(f"\n[bold cyan]Running tests for service: {service_name}[/bold cyan]")
308
+ console.print(f"[dim]Base URL: {base_url}[/dim]")
309
+ console.print(f"[dim]Test modules: {len(test_modules)}[/dim]\n")
310
+
311
+ # Execute tests with Playwright
312
+ with sync_playwright() as p:
313
+ # Create page instance
314
+ page = PageClass(base_url=base_url, playwright=p)
315
+ page.setup()
316
+
317
+ try:
318
+ for module_path in test_modules:
319
+ result = execute_single_test(module_path, page, service_name)
320
+ suite_result.results.append(result)
321
+ suite_result.total += 1
322
+
323
+ if result.status == "passed":
324
+ suite_result.passed += 1
325
+ console.print(f" [green]✓[/green] {result.name} ({result.duration_ms:.0f}ms)")
326
+ elif result.status == "failed":
327
+ suite_result.failed += 1
328
+ console.print(f" [red]✗[/red] {result.name} - {result.error_message[:50]}")
329
+ if verbose and result.error_message:
330
+ console.print(f" [dim]{result.error_message}[/dim]")
331
+ elif result.status == "error":
332
+ suite_result.errors += 1
333
+ console.print(
334
+ f" [red]⚠[/red] {result.name} - Error: {result.error_message[:50]}"
335
+ )
336
+ if verbose:
337
+ console.print(f" [dim]{result.error_traceback[:200]}[/dim]")
338
+ finally:
339
+ page.teardown()
340
+
341
+ return suite_result
342
+
343
+
344
+ def run_all_tests(
345
+ services_path: Path = Path("services"),
346
+ specific_service: Optional[str] = None,
347
+ specific_module: Optional[str] = None,
348
+ verbose: bool = False,
349
+ ) -> Dict[str, TestSuiteResult]:
350
+ """Run all tests for all services or a specific service.
351
+
352
+ Args:
353
+ services_path: Path to services directory
354
+ specific_service: If specified, only run tests for this service
355
+ specific_module: If specified, only run this module
356
+ verbose: Whether to show verbose output
357
+
358
+ Returns:
359
+ Dictionary mapping service names to their TestSuiteResults
360
+ """
361
+ results: Dict[str, TestSuiteResult] = {}
362
+
363
+ # Add project root to sys.path for imports
364
+ project_root = services_path.parent.absolute()
365
+ if str(project_root) not in sys.path:
366
+ sys.path.insert(0, str(project_root))
367
+
368
+ # Discover services
369
+ if specific_service:
370
+ services = [specific_service]
371
+ else:
372
+ services = discover_services(services_path)
373
+
374
+ if not services:
375
+ console.print("[yellow]No services found with test modules[/yellow]")
376
+ return results
377
+
378
+ # Load configuration
379
+ try:
380
+ loader = ApiConfigLoader()
381
+ config = loader.load()
382
+ except Exception:
383
+ config = None
384
+
385
+ # Run tests for each service
386
+ for service_name in services:
387
+ # Get service configuration
388
+ service_config = None
389
+ if config and service_name in config.services:
390
+ service_config = config.services[service_name]
391
+
392
+ suite_result = run_service_tests(
393
+ service_name=service_name,
394
+ service_config=service_config,
395
+ services_path=services_path,
396
+ specific_module=specific_module,
397
+ verbose=verbose,
398
+ )
399
+
400
+ results[service_name] = suite_result
401
+
402
+ return results
403
+
404
+ # Load configuration
405
+ try:
406
+ loader = ApiConfigLoader()
407
+ config = loader.load()
408
+ except Exception:
409
+ config = None
410
+
411
+ # Run tests for each service
412
+ for service_name in services:
413
+ # Get service configuration
414
+ service_config = None
415
+ if config and service_name in config.services:
416
+ service_config = config.services[service_name]
417
+
418
+ suite_result = run_service_tests(
419
+ service_name=service_name,
420
+ service_config=service_config,
421
+ services_path=services_path,
422
+ specific_module=specific_module,
423
+ verbose=verbose,
424
+ )
425
+
426
+ results[service_name] = suite_result
427
+
428
+ return results
429
+
430
+
431
+ def print_summary(results: Dict[str, TestSuiteResult]) -> bool:
432
+ """Print summary of all test results.
433
+
434
+ Args:
435
+ results: Dictionary of service results
436
+
437
+ Returns:
438
+ True if all tests passed, False otherwise
439
+ """
440
+ console.print("\n" + "═" * 60)
441
+ console.print("[bold]Test Execution Summary[/bold]")
442
+ console.print("═" * 60)
443
+
444
+ total_tests = 0
445
+ total_passed = 0
446
+ total_failed = 0
447
+ total_errors = 0
448
+
449
+ for service_name, suite_result in results.items():
450
+ total_tests += suite_result.total
451
+ total_passed += suite_result.passed
452
+ total_failed += suite_result.failed
453
+ total_errors += suite_result.errors
454
+
455
+ status_color = "green" if suite_result.failed == 0 and suite_result.errors == 0 else "red"
456
+ console.print(
457
+ f"\n[{status_color}]{service_name}[/{status_color}]: "
458
+ f"{suite_result.passed}/{suite_result.total} passed "
459
+ f"({suite_result.success_rate:.1f}%)"
460
+ )
461
+
462
+ # Show failed tests
463
+ for result in suite_result.results:
464
+ if result.status in ("failed", "error"):
465
+ console.print(f" [red] - {result.name}[/red]")
466
+
467
+ console.print("\n" + "─" * 60)
468
+
469
+ if total_tests == 0:
470
+ console.print("[yellow]No tests were executed[/yellow]")
471
+ return False
472
+
473
+ overall_success = total_failed == 0 and total_errors == 0
474
+
475
+ if overall_success:
476
+ console.print(f"[bold green]✓ All {total_tests} tests passed![/bold green]")
477
+ else:
478
+ console.print(
479
+ f"[bold red]✗ {total_failed + total_errors} of {total_tests} tests failed[/bold red]"
480
+ )
481
+ console.print(f" [green]Passed: {total_passed}[/green]")
482
+ console.print(f" [red]Failed: {total_failed}[/red]")
483
+ console.print(f" [red]Errors: {total_errors}[/red]")
484
+
485
+ console.print("═" * 60)
486
+
487
+ return overall_success