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.
- socialseed_e2e/__init__.py +184 -20
- socialseed_e2e/__version__.py +2 -2
- socialseed_e2e/cli.py +353 -190
- socialseed_e2e/core/base_page.py +368 -49
- socialseed_e2e/core/config_loader.py +15 -3
- socialseed_e2e/core/headers.py +11 -4
- socialseed_e2e/core/loaders.py +6 -4
- socialseed_e2e/core/test_orchestrator.py +2 -0
- socialseed_e2e/core/test_runner.py +487 -0
- socialseed_e2e/templates/agent_docs/AGENT_GUIDE.md.template +412 -0
- socialseed_e2e/templates/agent_docs/EXAMPLE_TEST.md.template +152 -0
- socialseed_e2e/templates/agent_docs/FRAMEWORK_CONTEXT.md.template +55 -0
- socialseed_e2e/templates/agent_docs/WORKFLOW_GENERATION.md.template +182 -0
- socialseed_e2e/templates/data_schema.py.template +111 -70
- socialseed_e2e/templates/e2e.conf.template +19 -0
- socialseed_e2e/templates/service_page.py.template +82 -27
- socialseed_e2e/templates/test_module.py.template +21 -7
- socialseed_e2e/templates/verify_installation.py +192 -0
- socialseed_e2e/utils/__init__.py +29 -0
- socialseed_e2e/utils/ai_generator.py +463 -0
- socialseed_e2e/utils/pydantic_helpers.py +392 -0
- socialseed_e2e/utils/state_management.py +312 -0
- {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/METADATA +64 -27
- socialseed_e2e-0.1.2.dist-info/RECORD +38 -0
- socialseed_e2e-0.1.0.dist-info/RECORD +0 -29
- {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/WHEEL +0 -0
- {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/entry_points.txt +0 -0
- {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {socialseed_e2e-0.1.0.dist-info → socialseed_e2e-0.1.2.dist-info}/top_level.txt +0 -0
socialseed_e2e/core/base_page.py
CHANGED
|
@@ -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)
|
|
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
|
-
"""
|
|
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
|
|
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
|
|
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.
|
|
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)
|
|
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(
|
|
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,
|
|
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=
|
|
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=
|
|
521
|
+
full_url, data=request_body, headers=request_log.headers
|
|
464
522
|
)
|
|
465
523
|
elif method == "DELETE":
|
|
466
|
-
|
|
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=
|
|
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}
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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=
|
|
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,
|
|
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
|
|
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=
|
|
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()
|