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
@@ -8,8 +8,7 @@ rate limiting, and comprehensive request/response logging.
8
8
  import json
9
9
  import logging
10
10
  import time
11
- from dataclasses import dataclass
12
- from functools import wraps
11
+ from dataclasses import dataclass, field
13
12
  from typing import Any, Callable, Dict, List, Optional, Type, Union
14
13
 
15
14
  from playwright.sync_api import APIRequestContext, APIResponse, Playwright
@@ -29,17 +28,18 @@ class RetryConfig:
29
28
  max_retries: Maximum number of retry attempts (default: 3)
30
29
  backoff_factor: Exponential backoff multiplier (default: 1.0)
31
30
  max_backoff: Maximum backoff time in seconds (default: 60)
32
- retry_on: List of HTTP status codes to retry on (default: [502, 503, 504])
31
+ retry_on: List of HTTP status codes to retry on (default: [502, 503, 504, 429])
33
32
  retry_exceptions: List of exception types to retry on
34
33
  """
35
34
 
36
35
  max_retries: int = 3
37
36
  backoff_factor: float = 1.0
38
37
  max_backoff: float = 60.0
39
- retry_on: List[int] = None
40
- retry_exceptions: List[Type[Exception]] = None
38
+ retry_on: Optional[List[int]] = None
39
+ retry_exceptions: Optional[List[Type[Exception]]] = None
41
40
 
42
41
  def __post_init__(self):
42
+ """Initialize default retry configuration values."""
43
43
  if self.retry_on is None:
44
44
  self.retry_on = [502, 503, 504, 429] # Include 429 (rate limit)
45
45
  if self.retry_exceptions is None:
@@ -71,7 +71,7 @@ class RequestLog:
71
71
  method: HTTP method
72
72
  url: Full request URL
73
73
  headers: Request headers (may be filtered)
74
- body: Request body
74
+ body: Request body (JSON string)
75
75
  timestamp: When the request was made
76
76
  duration_ms: Request duration in milliseconds
77
77
  status: Response status code
@@ -92,6 +92,25 @@ class RequestLog:
92
92
  error: Optional[str] = None
93
93
 
94
94
 
95
+ @dataclass
96
+ class ServiceHealth:
97
+ """Service health status.
98
+
99
+ Attributes:
100
+ healthy: Whether the service is healthy
101
+ status_code: HTTP status code from health check
102
+ response_time_ms: Response time in milliseconds
103
+ message: Health check message
104
+ timestamp: When the check was performed
105
+ """
106
+
107
+ healthy: bool
108
+ status_code: Optional[int] = None
109
+ response_time_ms: float = 0.0
110
+ message: str = ""
111
+ timestamp: float = field(default_factory=time.time)
112
+
113
+
95
114
  class BasePageError(Exception):
96
115
  """Enhanced exception with request context."""
97
116
 
@@ -104,6 +123,7 @@ class BasePageError(Exception):
104
123
  response_text: Optional[str] = None,
105
124
  request_log: Optional[RequestLog] = None,
106
125
  ):
126
+ """Initialize exception with request context."""
107
127
  super().__init__(message)
108
128
  self.url = url
109
129
  self.method = method
@@ -112,6 +132,7 @@ class BasePageError(Exception):
112
132
  self.request_log = request_log
113
133
 
114
134
  def __str__(self) -> str:
135
+ """Return formatted error message with context."""
115
136
  parts = [super().__str__()]
116
137
  if self.method and self.url:
117
138
  parts.append(f"Request: {self.method} {self.url}")
@@ -137,6 +158,7 @@ class BasePage:
137
158
  - Request timing and performance metrics
138
159
  - Enhanced error messages with full context
139
160
  - Helper methods for common assertions
161
+ - Service health checking
140
162
 
141
163
  Example:
142
164
  >>> page = BasePage("https://api.example.com")
@@ -179,6 +201,7 @@ class BasePage:
179
201
  rate_limit_config: Optional[RateLimitConfig] = None,
180
202
  enable_request_logging: bool = True,
181
203
  max_log_body_size: int = 10000,
204
+ health_endpoint: str = "/actuator/health",
182
205
  ) -> None:
183
206
  """Initialize the BasePage.
184
207
 
@@ -190,6 +213,7 @@ class BasePage:
190
213
  rate_limit_config: Configuration for rate limiting (default: disabled)
191
214
  enable_request_logging: Whether to log requests and responses
192
215
  max_log_body_size: Maximum size for logged bodies (truncated if larger)
216
+ health_endpoint: Endpoint for health checks (default: /actuator/health)
193
217
  """
194
218
  self.base_url: str = base_url.rstrip("/")
195
219
  self.playwright_manager: Optional[Any] = None
@@ -199,6 +223,8 @@ class BasePage:
199
223
  if default_headers is not None
200
224
  else {**DEFAULT_JSON_HEADERS, **DEFAULT_BROWSER_HEADERS}
201
225
  )
226
+ self.headers = self.default_headers # Alias for easier access in tests
227
+ self.health_endpoint = health_endpoint
202
228
 
203
229
  # Initialize Playwright
204
230
  if playwright:
@@ -210,7 +236,7 @@ class BasePage:
210
236
  self.api_context: Optional[APIRequestContext] = None
211
237
 
212
238
  # Configuration
213
- self.retry_config = retry_config or RetryConfig(max_retries=0) # Disabled by default
239
+ self.retry_config = retry_config or RetryConfig(max_retries=0)
214
240
  self.rate_limit_config = rate_limit_config or RateLimitConfig(enabled=False)
215
241
  self.enable_request_logging = enable_request_logging
216
242
  self.max_log_body_size = max_log_body_size
@@ -223,26 +249,29 @@ class BasePage:
223
249
  self.request_history: List[RequestLog] = []
224
250
  self._max_history_size = 100
225
251
 
252
+ # Response interceptors
253
+ self._response_interceptors: List[Callable[[APIResponse], None]] = []
254
+
226
255
  logger.info(f"BasePage initialized for {self.base_url}")
227
256
 
228
257
  @classmethod
229
258
  def from_config(
230
259
  cls, config: ServiceConfig, playwright: Optional[Playwright] = None, **kwargs
231
260
  ) -> "BasePage":
232
- """Factory method to create a BasePage from a ServiceConfig object.
261
+ """Create a BasePage from a ServiceConfig object.
233
262
 
234
263
  Args:
235
264
  config: Service configuration object
236
265
  playwright: Optional Playwright instance
237
266
  **kwargs: Additional arguments passed to BasePage constructor
238
-
239
267
  Returns:
240
268
  Configured BasePage instance
241
269
  """
242
270
  return cls(
243
271
  base_url=config.base_url,
244
272
  playwright=playwright,
245
- default_headers=config.default_headers or None,
273
+ default_headers=config.default_headers,
274
+ health_endpoint=getattr(config, "health_endpoint", "/actuator/health"),
246
275
  **kwargs,
247
276
  )
248
277
 
@@ -253,6 +282,7 @@ class BasePage:
253
282
  automatically before making requests if not already set up.
254
283
  """
255
284
  if not self.api_context:
285
+ assert self.playwright is not None
256
286
  self.api_context = self.playwright.request.new_context()
257
287
  logger.debug("API context initialized")
258
288
 
@@ -280,7 +310,7 @@ class BasePage:
280
310
  self.setup()
281
311
 
282
312
  def _prepare_headers(self, headers: Optional[Dict[str, str]]) -> Dict[str, str]:
283
- """Combine default headers with request-specific headers.
313
+ """Combine active headers with request-specific headers.
284
314
 
285
315
  Args:
286
316
  headers: Request-specific headers to merge with defaults
@@ -288,7 +318,7 @@ class BasePage:
288
318
  Returns:
289
319
  Merged headers dictionary
290
320
  """
291
- request_headers = self.default_headers.copy()
321
+ request_headers = self.headers.copy()
292
322
  if headers:
293
323
  request_headers.update(headers)
294
324
  return request_headers
@@ -339,7 +369,7 @@ class BasePage:
339
369
  Sleep time in seconds
340
370
  """
341
371
  backoff = self.retry_config.backoff_factor * (2**attempt)
342
- return min(backoff, self.retry_config.max_backoff)
372
+ return float(min(backoff, self.retry_config.max_backoff))
343
373
 
344
374
  def _should_retry(
345
375
  self, response: Optional[APIResponse], exception: Optional[Exception]
@@ -359,11 +389,12 @@ class BasePage:
359
389
  # Check if exception type is in retry list
360
390
  if exception:
361
391
  return any(
362
- isinstance(exception, exc_type) for exc_type in self.retry_config.retry_exceptions
392
+ isinstance(exception, exc_type)
393
+ for exc_type in (self.retry_config.retry_exceptions or [])
363
394
  )
364
395
 
365
396
  # Check if status code is in retry list
366
- if response and response.status in self.retry_config.retry_on:
397
+ if response and response.status in (self.retry_config.retry_on or []):
367
398
  return True
368
399
 
369
400
  return False
@@ -409,6 +440,24 @@ class BasePage:
409
440
  )
410
441
  return body
411
442
 
443
+ def _serialize_body(self, data: Any) -> Optional[str]:
444
+ """Serialize request body for logging.
445
+
446
+ Args:
447
+ data: The data to serialize (dict, list, or string)
448
+
449
+ Returns:
450
+ JSON string representation or None
451
+ """
452
+ if data is None:
453
+ return None
454
+ if isinstance(data, str):
455
+ return data
456
+ try:
457
+ return json.dumps(data, indent=2)
458
+ except (TypeError, ValueError):
459
+ return str(data)
460
+
412
461
  def _make_request(self, method: str, endpoint: str, **kwargs) -> APIResponse:
413
462
  """Make an HTTP request with retry logic and logging.
414
463
 
@@ -422,6 +471,10 @@ class BasePage:
422
471
  method: HTTP method (GET, POST, PUT, DELETE, PATCH)
423
472
  endpoint: API endpoint (e.g., "/users/123")
424
473
  **kwargs: Additional arguments for the Playwright request
474
+ - data: Form data or dict to send
475
+ - json: JSON payload (alternative to data)
476
+ - headers: Request-specific headers
477
+ - params: Query parameters (GET only)
425
478
 
426
479
  Returns:
427
480
  APIResponse object
@@ -430,18 +483,21 @@ class BasePage:
430
483
  BasePageError: If the request fails after all retries
431
484
  """
432
485
  self._ensure_setup()
486
+ assert self.api_context is not None
433
487
  self._apply_rate_limit()
434
488
 
435
489
  full_url = f"{self.base_url}{endpoint}"
436
- last_exception: Optional[Exception] = None
437
490
  last_response: Optional[APIResponse] = None
438
491
 
492
+ # Prepare body - handle both 'data' and 'json' parameters
493
+ request_body = kwargs.get("json") or kwargs.get("data")
494
+
439
495
  # Prepare request log
440
496
  request_log = RequestLog(
441
497
  method=method,
442
498
  url=full_url,
443
499
  headers=self._prepare_headers(kwargs.get("headers")),
444
- body=self._truncate_body(str(kwargs.get("data") or kwargs.get("json"))),
500
+ body=self._truncate_body(self._serialize_body(request_body)),
445
501
  timestamp=time.time(),
446
502
  )
447
503
 
@@ -452,25 +508,48 @@ class BasePage:
452
508
  # Make the request
453
509
  if method == "GET":
454
510
  last_response = self.api_context.get(
455
- full_url, headers=request_log.headers, params=kwargs.get("params")
511
+ full_url,
512
+ headers=request_log.headers,
513
+ params=kwargs.get("params"),
456
514
  )
457
515
  elif method == "POST":
458
516
  last_response = self.api_context.post(
459
- full_url, data=kwargs.get("data"), headers=request_log.headers
517
+ full_url, data=request_body, headers=request_log.headers
460
518
  )
461
519
  elif method == "PUT":
462
520
  last_response = self.api_context.put(
463
- full_url, data=kwargs.get("data"), headers=request_log.headers
521
+ full_url, data=request_body, headers=request_log.headers
464
522
  )
465
523
  elif method == "DELETE":
466
- last_response = self.api_context.delete(full_url, headers=request_log.headers)
524
+ # DELETE with body support
525
+ if request_body:
526
+ # Use fetch with DELETE method for body support
527
+ last_response = self.api_context.fetch(
528
+ full_url,
529
+ method="DELETE",
530
+ data=request_body
531
+ if isinstance(request_body, str)
532
+ else json.dumps(request_body),
533
+ headers=request_log.headers,
534
+ )
535
+ else:
536
+ last_response = self.api_context.delete(
537
+ full_url, headers=request_log.headers
538
+ )
467
539
  elif method == "PATCH":
468
540
  last_response = self.api_context.patch(
469
- full_url, data=kwargs.get("data"), headers=request_log.headers
541
+ full_url, data=request_body, headers=request_log.headers
470
542
  )
471
543
  else:
472
544
  raise ValueError(f"Unsupported HTTP method: {method}")
473
545
 
546
+ # Apply response interceptors
547
+ for interceptor in self._response_interceptors:
548
+ try:
549
+ interceptor(last_response)
550
+ except Exception as e:
551
+ logger.warning(f"Response interceptor failed: {e}")
552
+
474
553
  # Update log with response info
475
554
  request_log.duration_ms = (time.time() - start_time) * 1000
476
555
  request_log.status = last_response.status
@@ -491,7 +570,8 @@ class BasePage:
491
570
  ):
492
571
  backoff = self._calculate_backoff(attempt)
493
572
  logger.warning(
494
- f"Retry {attempt + 1}/{self.retry_config.max_retries} for {method} {endpoint} "
573
+ f"Retry {attempt + 1}/{self.retry_config.max_retries} "
574
+ f"for {method} {endpoint} "
495
575
  f"(status: {last_response.status}, backoff: {backoff:.2f}s)"
496
576
  )
497
577
  time.sleep(backoff)
@@ -502,14 +582,14 @@ class BasePage:
502
582
  return last_response
503
583
 
504
584
  except Exception as e:
505
- last_exception = e
506
585
  request_log.duration_ms = (time.time() - start_time) * 1000
507
586
 
508
587
  # Check if we should retry on exception
509
588
  if attempt < self.retry_config.max_retries and self._should_retry(None, e):
510
589
  backoff = self._calculate_backoff(attempt)
511
590
  logger.warning(
512
- f"Retry {attempt + 1}/{self.retry_config.max_retries} for {method} {endpoint} "
591
+ f"Retry {attempt + 1}/{self.retry_config.max_retries} "
592
+ f"for {method} {endpoint} "
513
593
  f"(error: {e}, backoff: {backoff:.2f}s)"
514
594
  )
515
595
  time.sleep(backoff)
@@ -554,7 +634,7 @@ class BasePage:
554
634
  def post(
555
635
  self,
556
636
  endpoint: str,
557
- data: Optional[Dict[str, Any]] = None,
637
+ data: Optional[Union[Dict[str, Any], str]] = None,
558
638
  json: Optional[Dict[str, Any]] = None,
559
639
  headers: Optional[Dict[str, str]] = None,
560
640
  ) -> APIResponse:
@@ -562,21 +642,20 @@ class BasePage:
562
642
 
563
643
  Args:
564
644
  endpoint: API endpoint (e.g., "/users")
565
- data: Form data (use either data or json, not both)
645
+ data: Form data or dict (use either data or json, not both)
566
646
  json: JSON payload (use either data or json, not both)
567
647
  headers: Optional request-specific headers
568
648
 
569
649
  Returns:
570
650
  APIResponse object
571
651
  """
572
- if json:
573
- return self._make_request("POST", endpoint, json=json, headers=headers)
574
- return self._make_request("POST", endpoint, data=data, headers=headers)
652
+ body = json if json is not None else data
653
+ return self._make_request("POST", endpoint, data=body, headers=headers)
575
654
 
576
655
  def put(
577
656
  self,
578
657
  endpoint: str,
579
- data: Optional[Dict[str, Any]] = None,
658
+ data: Optional[Union[Dict[str, Any], str]] = None,
580
659
  json: Optional[Dict[str, Any]] = None,
581
660
  headers: Optional[Dict[str, str]] = None,
582
661
  ) -> APIResponse:
@@ -584,33 +663,41 @@ class BasePage:
584
663
 
585
664
  Args:
586
665
  endpoint: API endpoint (e.g., "/users/123")
587
- data: Form data (use either data or json, not both)
666
+ data: Form data or dict (use either data or json, not both)
588
667
  json: JSON payload (use either data or json, not both)
589
668
  headers: Optional request-specific headers
590
669
 
591
670
  Returns:
592
671
  APIResponse object
593
672
  """
594
- if json:
595
- return self._make_request("PUT", endpoint, json=json, headers=headers)
596
- return self._make_request("PUT", endpoint, data=data, headers=headers)
673
+ body = json if json is not None else data
674
+ return self._make_request("PUT", endpoint, data=body, headers=headers)
597
675
 
598
- def delete(self, endpoint: str, headers: Optional[Dict[str, str]] = None) -> APIResponse:
676
+ def delete(
677
+ self,
678
+ endpoint: str,
679
+ data: Optional[Union[Dict[str, Any], str]] = None,
680
+ json: Optional[Dict[str, Any]] = None,
681
+ headers: Optional[Dict[str, str]] = None,
682
+ ) -> APIResponse:
599
683
  """Perform a DELETE request.
600
684
 
601
685
  Args:
602
686
  endpoint: API endpoint (e.g., "/users/123")
687
+ data: Request body as dict or string (for non-standard REST APIs)
688
+ json: JSON payload (alternative to data)
603
689
  headers: Optional request-specific headers
604
690
 
605
691
  Returns:
606
692
  APIResponse object
607
693
  """
608
- return self._make_request("DELETE", endpoint, headers=headers)
694
+ body = json if json is not None else data
695
+ return self._make_request("DELETE", endpoint, data=body, headers=headers)
609
696
 
610
697
  def patch(
611
698
  self,
612
699
  endpoint: str,
613
- data: Optional[Dict[str, Any]] = None,
700
+ data: Optional[Union[Dict[str, Any], str]] = None,
614
701
  json: Optional[Dict[str, Any]] = None,
615
702
  headers: Optional[Dict[str, str]] = None,
616
703
  ) -> APIResponse:
@@ -618,18 +705,17 @@ class BasePage:
618
705
 
619
706
  Args:
620
707
  endpoint: API endpoint (e.g., "/users/123")
621
- data: Form data (use either data or json, not both)
708
+ data: Form data or dict (use either data or json, not both)
622
709
  json: JSON payload (use either data or json, not both)
623
710
  headers: Optional request-specific headers
624
711
 
625
712
  Returns:
626
713
  APIResponse object
627
714
  """
628
- if json:
629
- return self._make_request("PATCH", endpoint, json=json, headers=headers)
630
- return self._make_request("PATCH", endpoint, data=data, headers=headers)
715
+ body = json if json is not None else data
716
+ return self._make_request("PATCH", endpoint, data=body, headers=headers)
631
717
 
632
- # Helper methods for assertions
718
+ # Assertion helpers
633
719
 
634
720
  def assert_status(
635
721
  self,
@@ -725,8 +811,11 @@ class BasePage:
725
811
  if isinstance(value, dict) and k in value:
726
812
  value = value[k]
727
813
  else:
814
+ keys_display = list(value.keys()) if isinstance(value, dict) else "N/A"
728
815
  raise BasePageError(
729
- message=f"Key '{key}' not found in response. Available keys: {list(value.keys()) if isinstance(value, dict) else 'N/A'}",
816
+ message=(
817
+ f"Key '{key}' not found in response. " f"Available keys: {keys_display}"
818
+ ),
730
819
  url=response.url,
731
820
  status=response.status,
732
821
  )
@@ -735,7 +824,10 @@ class BasePage:
735
824
  return data
736
825
 
737
826
  def assert_header(
738
- self, response: APIResponse, header_name: str, expected_value: Optional[str] = None
827
+ self,
828
+ response: APIResponse,
829
+ header_name: str,
830
+ expected_value: Optional[str] = None,
739
831
  ) -> str:
740
832
  """Assert that response contains a specific header.
741
833
 
@@ -754,8 +846,9 @@ class BasePage:
754
846
  header_lower = header_name.lower()
755
847
 
756
848
  if header_lower not in headers:
849
+ available = list(response.headers.keys())
757
850
  raise BasePageError(
758
- message=f"Header '{header_name}' not found in response. Available headers: {list(response.headers.keys())}",
851
+ message=f"Header '{header_name}' not found. Available: {available}",
759
852
  url=response.url,
760
853
  status=response.status,
761
854
  )
@@ -764,12 +857,103 @@ class BasePage:
764
857
 
765
858
  if expected_value and value != expected_value:
766
859
  raise BasePageError(
767
- message=f"Header '{header_name}' has value '{value}', expected '{expected_value}'",
860
+ message=(
861
+ f"Header '{header_name}' has value '{value}', " f"expected '{expected_value}'"
862
+ ),
768
863
  url=response.url,
769
864
  status=response.status,
770
865
  )
771
866
 
772
- return value
867
+ return str(value)
868
+
869
+ def assert_schema(
870
+ self,
871
+ response: APIResponse,
872
+ schema: Union[Dict[str, Any], type],
873
+ message: Optional[str] = None,
874
+ ) -> Dict[str, Any]:
875
+ """Assert that response matches a JSON schema or Pydantic model.
876
+
877
+ Args:
878
+ response: The API response to validate
879
+ schema: JSON schema dict or Pydantic model class
880
+ message: Optional custom error message
881
+
882
+ Returns:
883
+ The parsed JSON data
884
+
885
+ Raises:
886
+ BasePageError: If validation fails
887
+ """
888
+ try:
889
+ data = response.json()
890
+ except Exception as e:
891
+ raise BasePageError(
892
+ message=f"Failed to parse JSON response: {e}",
893
+ url=response.url,
894
+ status=response.status,
895
+ ) from e
896
+
897
+ # Check if schema is a Pydantic model
898
+ if isinstance(schema, type) and hasattr(schema, "model_validate"):
899
+ try:
900
+ schema.model_validate(data)
901
+ except Exception as e:
902
+ raise BasePageError(
903
+ message=message or f"Response does not match schema: {e}",
904
+ url=response.url,
905
+ status=response.status,
906
+ response_text=str(data)[:200],
907
+ )
908
+ else:
909
+ # JSON schema validation (basic implementation)
910
+ if isinstance(schema, dict) and "properties" in schema:
911
+ required = schema.get("required", [])
912
+ properties = schema.get("properties", {})
913
+
914
+ for key in required:
915
+ if key not in data:
916
+ raise BasePageError(
917
+ message=message or f"Required field '{key}' missing from response",
918
+ url=response.url,
919
+ status=response.status,
920
+ )
921
+
922
+ for key, prop_schema in properties.items():
923
+ if key in data:
924
+ expected_type = prop_schema.get("type")
925
+ if expected_type and not self._check_type(data[key], expected_type):
926
+ raise BasePageError(
927
+ message=message
928
+ or f"Field '{key}' has wrong type. Expected {expected_type}",
929
+ url=response.url,
930
+ status=response.status,
931
+ )
932
+
933
+ return data
934
+
935
+ def _check_type(self, value: Any, expected_type: str) -> bool:
936
+ """Check if a value matches an expected JSON schema type.
937
+
938
+ Args:
939
+ value: The value to check
940
+ expected_type: Expected type (string, integer, number, boolean, array, object)
941
+
942
+ Returns:
943
+ True if types match
944
+ """
945
+ type_map = {
946
+ "string": str,
947
+ "integer": int,
948
+ "number": (int, float),
949
+ "boolean": bool,
950
+ "array": list,
951
+ "object": dict,
952
+ }
953
+
954
+ if expected_type in type_map:
955
+ return isinstance(value, type_map[expected_type])
956
+ return True
773
957
 
774
958
  # Utility methods
775
959
 
@@ -817,7 +1001,7 @@ class BasePage:
817
1001
  }
818
1002
 
819
1003
  total_duration = sum(r.duration_ms for r in self.request_history)
820
- status_counts = {}
1004
+ status_counts: Dict[int, int] = {}
821
1005
  successful = 0
822
1006
  failed = 0
823
1007
 
@@ -837,3 +1021,138 @@ class BasePage:
837
1021
  "average_duration_ms": total_duration / len(self.request_history),
838
1022
  "status_distribution": status_counts,
839
1023
  }
1024
+
1025
+ def add_response_interceptor(self, interceptor: Callable[[APIResponse], None]) -> None:
1026
+ """Add a response interceptor.
1027
+
1028
+ Interceptors are called after each successful response.
1029
+ They receive the APIResponse object and can be used for:
1030
+ - Logging
1031
+ - Token extraction
1032
+ - Response transformation
1033
+ - Metrics collection
1034
+
1035
+ Args:
1036
+ interceptor: Callable that receives APIResponse
1037
+ """
1038
+ self._response_interceptors.append(interceptor)
1039
+
1040
+ def clear_response_interceptors(self) -> None:
1041
+ """Remove all response interceptors."""
1042
+ self._response_interceptors.clear()
1043
+
1044
+ def check_health(self, timeout: Optional[int] = None) -> ServiceHealth:
1045
+ """Check if the service is healthy.
1046
+
1047
+ Makes a request to the health endpoint and returns status.
1048
+
1049
+ Args:
1050
+ timeout: Request timeout in milliseconds (uses default if not specified)
1051
+
1052
+ Returns:
1053
+ ServiceHealth object with health status
1054
+ """
1055
+ start_time = time.time()
1056
+ try:
1057
+ old_timeout = None
1058
+ if timeout and self.api_context:
1059
+ # Note: Playwright doesn't support per-request timeout easily
1060
+ # This is a simplified implementation
1061
+ pass
1062
+
1063
+ response = self.get(self.health_endpoint)
1064
+ response_time = (time.time() - start_time) * 1000
1065
+
1066
+ is_healthy = 200 <= response.status < 300
1067
+
1068
+ try:
1069
+ body = response.json()
1070
+ message = body.get("status", "OK") if isinstance(body, dict) else "OK"
1071
+ except:
1072
+ message = "OK" if is_healthy else "Unhealthy"
1073
+
1074
+ return ServiceHealth(
1075
+ healthy=is_healthy,
1076
+ status_code=response.status,
1077
+ response_time_ms=response_time,
1078
+ message=message,
1079
+ )
1080
+ except Exception as e:
1081
+ response_time = (time.time() - start_time) * 1000
1082
+ return ServiceHealth(
1083
+ healthy=False,
1084
+ status_code=None,
1085
+ response_time_ms=response_time,
1086
+ message=str(e),
1087
+ )
1088
+
1089
+ def wait_for_healthy(
1090
+ self, timeout: int = 30, interval: float = 1.0, raise_on_timeout: bool = True
1091
+ ) -> bool:
1092
+ """Wait for the service to become healthy.
1093
+
1094
+ Polls the health endpoint until it returns healthy or timeout is reached.
1095
+
1096
+ Args:
1097
+ timeout: Maximum time to wait in seconds
1098
+ interval: Polling interval in seconds
1099
+ raise_on_timeout: Whether to raise exception on timeout
1100
+
1101
+ Returns:
1102
+ True if service became healthy, False if timed out
1103
+
1104
+ Raises:
1105
+ BasePageError: If raise_on_timeout is True and service doesn't become healthy
1106
+ """
1107
+ start_time = time.time()
1108
+ attempts = 0
1109
+
1110
+ while time.time() - start_time < timeout:
1111
+ attempts += 1
1112
+ health = self.check_health()
1113
+
1114
+ if health.healthy:
1115
+ logger.info(f"Service became healthy after {attempts} attempt(s)")
1116
+ return True
1117
+
1118
+ logger.debug(f"Health check failed (attempt {attempts}): {health.message}")
1119
+ time.sleep(interval)
1120
+
1121
+ if raise_on_timeout:
1122
+ raise BasePageError(
1123
+ message=f"Service did not become healthy within {timeout}s",
1124
+ url=f"{self.base_url}{self.health_endpoint}",
1125
+ )
1126
+
1127
+ return False
1128
+
1129
+ @classmethod
1130
+ def wait_for_service(
1131
+ cls,
1132
+ url: str,
1133
+ health_endpoint: str = "/actuator/health",
1134
+ timeout: int = 30,
1135
+ interval: float = 1.0,
1136
+ ) -> bool:
1137
+ """Wait for a service to become healthy without creating a persistent instance.
1138
+
1139
+ This is a class method that creates a temporary instance just for health checking.
1140
+
1141
+ Args:
1142
+ url: Service base URL
1143
+ health_endpoint: Health check endpoint
1144
+ timeout: Maximum wait time in seconds
1145
+ interval: Polling interval
1146
+
1147
+ Returns:
1148
+ True if service became healthy
1149
+
1150
+ Example:
1151
+ >>> BasePage.wait_for_service("http://localhost:8081", timeout=60)
1152
+ """
1153
+ page = cls(base_url=url, health_endpoint=health_endpoint)
1154
+ try:
1155
+ page.setup()
1156
+ return page.wait_for_healthy(timeout=timeout, interval=interval)
1157
+ finally:
1158
+ page.teardown()