scythe-ttp 0.13.0__py3-none-any.whl → 0.15.0__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.
scythe/core/headers.py CHANGED
@@ -19,6 +19,51 @@ class HeaderExtractor:
19
19
  def __init__(self):
20
20
  self.logger = logging.getLogger("HeaderExtractor")
21
21
 
22
+ @staticmethod
23
+ def _normalize_url(url: str) -> str:
24
+ """Ensure the URL has a scheme so requests can handle it."""
25
+ if not isinstance(url, str):
26
+ return url
27
+ lower = url.lower().strip()
28
+ if lower.startswith("http://") or lower.startswith("https://"):
29
+ return url
30
+ return f"http://{url}"
31
+
32
+ @staticmethod
33
+ def _is_static_asset(url: str, headers: Optional[Dict[str, Any]] = None) -> bool:
34
+ """Heuristically determine if a URL/log entry is a static asset (css/js/image/font/etc.)."""
35
+ try:
36
+ if not isinstance(url, str):
37
+ return False
38
+ u = url.lower()
39
+ # Common static file extensions
40
+ static_exts = (
41
+ '.css', '.js', '.mjs', '.map', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico',
42
+ '.woff', '.woff2', '.ttf', '.otf', '.eot', '.webp', '.mp4', '.webm', '.mp3', '.wav'
43
+ )
44
+ if any(u.endswith(ext) for ext in static_exts):
45
+ return True
46
+ if '/static/' in u or '/assets/' in u:
47
+ return True
48
+ # Content-Type hint
49
+ if isinstance(headers, dict):
50
+ # case-insensitive lookup
51
+ ctype = None
52
+ for k, v in headers.items():
53
+ if isinstance(k, str) and k.lower() == 'content-type':
54
+ ctype = str(v).lower()
55
+ break
56
+ if ctype and (ctype.startswith('text/css') or
57
+ ctype.startswith('application/javascript') or
58
+ ctype.startswith('text/javascript') or
59
+ ctype.startswith('image/') or
60
+ ctype.startswith('font/')):
61
+ return True
62
+ except Exception:
63
+ # Be safe: if unsure, do not classify as static
64
+ return False
65
+ return False
66
+
22
67
  @staticmethod
23
68
  def enable_logging_for_driver(chrome_options: Options) -> None:
24
69
  """
@@ -50,13 +95,14 @@ class HeaderExtractor:
50
95
  Version string if header found, None otherwise
51
96
  """
52
97
  try:
53
- self.logger.debug(f"Making {method} request to {url} for header extraction")
98
+ norm_url = self._normalize_url(url)
99
+ self.logger.debug(f"Making {method} request to {norm_url} for header extraction")
54
100
 
55
101
  # Use HEAD by default for efficiency, fallback to GET if needed
56
102
  if method.upper() == "HEAD":
57
- response = requests.head(url, timeout=timeout, allow_redirects=True)
103
+ response = requests.head(norm_url, timeout=timeout, allow_redirects=True)
58
104
  else:
59
- response = requests.get(url, timeout=timeout, allow_redirects=True)
105
+ response = requests.get(norm_url, timeout=timeout, allow_redirects=True)
60
106
 
61
107
  # Check if request was successful
62
108
  response.raise_for_status()
@@ -71,7 +117,8 @@ class HeaderExtractor:
71
117
  return None
72
118
 
73
119
  except requests.exceptions.RequestException as e:
74
- self.logger.warning(f"Failed to make {method} request to {url}: {e}")
120
+ hint = " (tip: include http:// or https://)" if isinstance(url, str) and not url.lower().startswith(("http://","https://")) else ""
121
+ self.logger.warning(f"Failed to make {method} request to {url}: {e}{hint}")
75
122
  return None
76
123
  except Exception as e:
77
124
  self.logger.warning(f"Unexpected error during banner grab: {e}")
@@ -90,12 +137,13 @@ class HeaderExtractor:
90
137
  Dictionary of all response headers
91
138
  """
92
139
  try:
93
- self.logger.debug(f"Making {method} request to {url} for all headers")
140
+ norm_url = self._normalize_url(url)
141
+ self.logger.debug(f"Making {method} request to {norm_url} for all headers")
94
142
 
95
143
  if method.upper() == "HEAD":
96
- response = requests.head(url, timeout=timeout, allow_redirects=True)
144
+ response = requests.head(norm_url, timeout=timeout, allow_redirects=True)
97
145
  else:
98
- response = requests.get(url, timeout=timeout, allow_redirects=True)
146
+ response = requests.get(norm_url, timeout=timeout, allow_redirects=True)
99
147
 
100
148
  response.raise_for_status()
101
149
 
@@ -103,7 +151,8 @@ class HeaderExtractor:
103
151
  return {k: str(v) for k, v in response.headers.items()}
104
152
 
105
153
  except requests.exceptions.RequestException as e:
106
- self.logger.warning(f"Failed to get headers from {url}: {e}")
154
+ hint = " (tip: include http:// or https://)" if isinstance(url, str) and not url.lower().startswith(("http://","https://")) else ""
155
+ self.logger.warning(f"Failed to get headers from {url}: {e}{hint}")
107
156
  return {}
108
157
  except Exception as e:
109
158
  self.logger.warning(f"Unexpected error getting headers: {e}")
@@ -179,7 +228,11 @@ class HeaderExtractor:
179
228
  self.logger.debug(f"Successfully extracted version '{version}' via banner grab")
180
229
  return version
181
230
  else:
182
- self.logger.debug("Banner grab failed, falling back to Selenium performance logs")
231
+ self.logger.debug("Banner grab failed")
232
+
233
+ # In API mode (no driver), do not fall back to Selenium to avoid noisy warnings
234
+ if driver is None:
235
+ return None
183
236
 
184
237
  # Fall back to Selenium performance logs
185
238
  self.logger.debug("Using Selenium performance logs method")
@@ -223,6 +276,13 @@ class HeaderExtractor:
223
276
  if target_url and target_url not in response_url:
224
277
  continue
225
278
 
279
+ # Ignore static assets (css/js/images/fonts) to avoid false detections/noise
280
+ try:
281
+ if self._is_static_asset(response_url, headers):
282
+ continue
283
+ except Exception:
284
+ pass
285
+
226
286
  # Look for the version header (case-insensitive)
227
287
  version = self._find_version_header(headers)
228
288
  if version:
@@ -1,4 +1,5 @@
1
1
  import time
2
+ import logging
2
3
  from typing import Dict, Any, Optional
3
4
  from selenium.webdriver.remote.webdriver import WebDriver
4
5
  from selenium.webdriver.common.by import By
@@ -700,70 +701,180 @@ class ApiRequestAction(Action):
700
701
  if self.headers:
701
702
  final_headers.update(self.headers)
702
703
 
704
+ # Simple masking for sensitive headers
705
+ def _mask_headers(headers: Dict[str, Any]) -> Dict[str, Any]:
706
+ masked = {}
707
+ for k, v in (headers or {}).items():
708
+ if k is None:
709
+ continue
710
+ key_lower = str(k).lower()
711
+ if key_lower in {"authorization", "proxy-authorization", "cookie", "set-cookie", "x-api-key", "x-auth-token"}:
712
+ masked[k] = "***"
713
+ else:
714
+ masked[k] = v
715
+ return masked
716
+
703
717
  # Resolve URL: absolute or join with target_url from context
704
718
  from urllib.parse import urljoin
719
+ from ..core.headers import HeaderExtractor
705
720
  base_url = context.get('target_url') or ''
706
- resolved_url = self.url if self.url.lower().startswith('http') else urljoin(base_url, self.url)
721
+ # Ensure base_url has a scheme so urljoin works with relative paths
722
+ if isinstance(base_url, str) and base_url and not base_url.lower().startswith(('http://', 'https://')):
723
+ base_url = HeaderExtractor._normalize_url(base_url)
724
+ if isinstance(self.url, str) and self.url.lower().startswith('http'):
725
+ resolved_url = self.url
726
+ else:
727
+ resolved_url = urljoin(base_url, self.url)
707
728
 
729
+ # Store request details early
730
+ self.store_result('request_method', self.method)
731
+ self.store_result('url', resolved_url)
732
+ if self.params:
733
+ self.store_result('request_params', self.params)
734
+ if self.body_json is not None:
735
+ self.store_result('request_json', self.body_json)
736
+ if self.data is not None:
737
+ self.store_result('request_data', self.data)
738
+ self.store_result('request_headers', _mask_headers(final_headers))
739
+
740
+ logger = logging.getLogger("Journey.ApiRequestAction")
741
+ # Honor any pending rate-limit resume time set by previous actions/steps
708
742
  try:
709
- response = session.request(
710
- self.method,
711
- resolved_url,
712
- params=self.params or None,
713
- json=self.body_json,
714
- data=self.data,
715
- headers=final_headers or None,
716
- timeout=self.timeout,
717
- )
718
-
719
- # Store details
720
- self.store_result('url', resolved_url)
721
- self.store_result('request_headers', final_headers)
722
- self.store_result('status_code', getattr(response, 'status_code', None))
723
- response_headers = dict(getattr(response, 'headers', {}) or {})
724
- self.store_result('response_headers', response_headers)
725
- # Publish to context for downstream version extraction
726
- context['last_response_headers'] = response_headers
727
- context['last_response_url'] = resolved_url
728
- # Try JSON, fallback to text
729
- body = None
730
- parsed_model = None
731
- validation_error = None
732
- try:
733
- body = response.json()
734
- self.store_result('response_json', body)
735
- # If a response_model is provided, attempt validation/parsing
736
- if self.response_model is not None:
743
+ resume_at = context.get('rate_limit_resume_at')
744
+ now = time.time()
745
+ if isinstance(resume_at, (int, float)) and resume_at > now:
746
+ wait_s = min(resume_at - now, 30)
747
+ if wait_s > 0:
748
+ self.store_result('waited_ms_before_request', int(wait_s * 1000))
737
749
  try:
738
- # Pydantic v2 preferred: model_validate
739
- if hasattr(self.response_model, 'model_validate'):
740
- parsed_model = self.response_model.model_validate(body)
741
- else:
742
- # Pydantic v1 fallback
743
- parsed_model = self.response_model.parse_obj(body)
744
- self.store_result('response_model_instance', parsed_model)
745
- # Save into context for downstream actions
746
- key = self.response_model_context_key or 'last_response_model'
747
- context[key] = parsed_model
748
- except Exception as ve:
749
- validation_error = str(ve)
750
- self.store_result('response_validation_error', validation_error)
751
- except Exception:
752
- text = getattr(response, 'text', '')
753
- # Limit stored text to keep logs light
754
- self.store_result('response_text', text if text is None or len(text) <= 2000 else text[:2000])
755
-
756
- # Determine success (status-based by default)
757
- if self.expected_status is not None:
758
- http_ok = (getattr(response, 'status_code', None) == self.expected_status)
759
- else:
760
- http_ok = bool(getattr(response, 'ok', False))
761
-
762
- # Optionally fail on validation error
763
- if self.response_model is not None and self.fail_on_validation_error and self.get_result('response_validation_error'):
764
- return False if http_ok else False
765
-
766
- return http_ok
767
- except Exception as e:
768
- self.store_result('error', str(e))
769
- return False
750
+ logger.info(f"Delaying {wait_s:.2f}s due to prior rate limit (resume_at)")
751
+ except Exception:
752
+ pass
753
+ time.sleep(wait_s)
754
+ except Exception:
755
+ pass
756
+
757
+ def _h(headers: Dict[str, Any], name: str):
758
+ lname = (name or '').lower()
759
+ for k, v in (headers or {}).items():
760
+ try:
761
+ if isinstance(k, str) and k.lower() == lname:
762
+ return v
763
+ except Exception:
764
+ continue
765
+ return None
766
+
767
+ attempts = 2 # at most one retry on 429 for idempotent methods
768
+ last_exception = None
769
+ for attempt in range(attempts):
770
+ start_ts = time.time()
771
+ try:
772
+ response = session.request(
773
+ self.method,
774
+ resolved_url,
775
+ params=self.params or None,
776
+ json=self.body_json,
777
+ data=self.data,
778
+ headers=final_headers or None,
779
+ timeout=self.timeout,
780
+ )
781
+ duration_ms = int((time.time() - start_ts) * 1000)
782
+ self.store_result('duration_ms', duration_ms)
783
+
784
+ # Store details
785
+ status_code = getattr(response, 'status_code', None)
786
+ self.store_result('status_code', status_code)
787
+ response_headers = dict(getattr(response, 'headers', {}) or {})
788
+ # Mask sensitive response headers
789
+ self.store_result('response_headers', _mask_headers(response_headers))
790
+ # Publish to context for downstream version extraction and rate-limit coordination
791
+ context['last_response_headers'] = response_headers
792
+ context['last_response_url'] = resolved_url
793
+
794
+ # Parse rate-limit headers
795
+ try:
796
+ # Normalize numeric values
797
+ remaining = _h(response_headers, 'X-RateLimit-Remaining')
798
+ if remaining is None:
799
+ remaining = _h(response_headers, 'X-Ratelimit-Remaining')
800
+ reset = _h(response_headers, 'X-RateLimit-Reset')
801
+ if reset is None:
802
+ reset = _h(response_headers, 'X-Ratelimit-Reset')
803
+ retry_after = _h(response_headers, 'Retry-After')
804
+
805
+ # If explicit Retry-After or 429, set resume time and optionally retry
806
+ if status_code == 429:
807
+ wait_s = 0
808
+ try:
809
+ wait_s = int(str(retry_after).strip()) if retry_after is not None else 1
810
+ except Exception:
811
+ wait_s = 1
812
+ wait_s = max(1, min(wait_s, 30))
813
+ context['rate_limit_resume_at'] = time.time() + wait_s
814
+ self.store_result('rate_limit_wait_s', wait_s)
815
+ if attempt == 0 and self.method in {'GET', 'HEAD', 'OPTIONS'}:
816
+ try:
817
+ logger.info(f"Hit 429 Too Many Requests; backing off {wait_s}s and retrying once")
818
+ except Exception:
819
+ pass
820
+ time.sleep(wait_s)
821
+ continue # retry once
822
+ else:
823
+ # If remaining == 0 and reset provided, set resume time
824
+ try:
825
+ if remaining is not None and str(remaining).strip() == '0' and reset is not None:
826
+ wait_s2 = int(str(reset).strip())
827
+ if wait_s2 > 0:
828
+ context['rate_limit_resume_at'] = time.time() + min(wait_s2, 30)
829
+ except Exception:
830
+ pass
831
+ except Exception:
832
+ pass
833
+
834
+ # Try JSON, fallback to text
835
+ parsed_model = None
836
+ try:
837
+ body = response.json()
838
+ self.store_result('response_json', body)
839
+ # If a response_model is provided, attempt validation/parsing
840
+ if self.response_model is not None:
841
+ try:
842
+ # Pydantic v2 preferred: model_validate
843
+ if hasattr(self.response_model, 'model_validate'):
844
+ parsed_model = self.response_model.model_validate(body)
845
+ else:
846
+ # Pydantic v1 fallback
847
+ parsed_model = self.response_model.parse_obj(body)
848
+ self.store_result('response_model_instance', parsed_model)
849
+ # Save into context for downstream actions
850
+ key = self.response_model_context_key or 'last_response_model'
851
+ context[key] = parsed_model
852
+ except Exception as ve:
853
+ self.store_result('response_validation_error', str(ve))
854
+ except Exception:
855
+ text = getattr(response, 'text', '')
856
+ # Limit stored text to keep logs light
857
+ if text is not None and isinstance(text, str):
858
+ self.store_result('response_text', text if len(text) <= 2000 else text[:2000])
859
+ else:
860
+ self.store_result('response_text', text)
861
+
862
+ # Determine success (status-based by default)
863
+ if self.expected_status is not None:
864
+ http_ok = (getattr(response, 'status_code', None) == self.expected_status)
865
+ else:
866
+ http_ok = bool(getattr(response, 'ok', False))
867
+
868
+ # Optionally fail on validation error
869
+ if self.response_model is not None and self.fail_on_validation_error and self.get_result('response_validation_error'):
870
+ return False
871
+
872
+ return http_ok
873
+ except Exception as e:
874
+ last_exception = e
875
+ self.store_result('duration_ms', int((time.time() - start_ts) * 1000))
876
+ self.store_result('error', str(e))
877
+ break
878
+
879
+ # If we got here and had an exception or no return, fail
880
+ return False
scythe/journeys/base.py CHANGED
@@ -102,7 +102,7 @@ class Step:
102
102
  """Add an action to this step."""
103
103
  self.actions.append(action)
104
104
 
105
- def execute(self, driver: WebDriver, context: Dict[str, Any]) -> bool:
105
+ def execute(self, driver: WebDriver|None, context: Dict[str, Any]) -> bool:
106
106
  """
107
107
  Execute all actions in this step.
108
108
 
@@ -111,7 +111,7 @@ class Step:
111
111
  context: Shared context data
112
112
 
113
113
  Returns:
114
- True if step succeeded, False otherwise
114
+ True if a step succeeded, False otherwise
115
115
  """
116
116
  logger = logging.getLogger(f"Journey.Step.{self.name}")
117
117
  logger.info(f"Executing step: {self.name}")
@@ -136,12 +136,14 @@ class Step:
136
136
  result = action.execute(driver, context)
137
137
 
138
138
  # Store result
139
+ details = getattr(action, 'execution_data', {})
139
140
  action_result = {
140
141
  'action_name': action.name,
141
142
  'action_description': action.description,
142
143
  'expected': action.expected_result,
143
144
  'actual': result,
144
- 'timestamp': time.time()
145
+ 'timestamp': time.time(),
146
+ 'details': details.copy() if isinstance(details, dict) else {}
145
147
  }
146
148
  self.execution_results.append(action_result)
147
149
 
@@ -156,6 +158,47 @@ class Step:
156
158
  else:
157
159
  if action.expected_result:
158
160
  logger.error(f"✗ Action failed: {action.name}")
161
+ # Emit diagnostic details when available (e.g., for API requests)
162
+ try:
163
+ ad = action_result.get('details', {}) or {}
164
+ method = ad.get('request_method') or getattr(action, 'method', None)
165
+ url = ad.get('url') or getattr(action, 'url', None)
166
+ status = ad.get('status_code')
167
+ dur = ad.get('duration_ms')
168
+ if method or url or status is not None:
169
+ parts = []
170
+ if method:
171
+ parts.append(f"method={method}")
172
+ if url:
173
+ parts.append(f"url={url}")
174
+ if status is not None:
175
+ parts.append(f"status={status}")
176
+ if dur is not None:
177
+ parts.append(f"duration_ms={dur}")
178
+ logger.error(" Details: " + ", ".join(parts))
179
+ req_headers = ad.get('request_headers')
180
+ if req_headers:
181
+ logger.error(f" Request headers: {req_headers}")
182
+ req_params = ad.get('request_params')
183
+ if req_params:
184
+ logger.error(f" Request params: {req_params}")
185
+ req_json = ad.get('request_json')
186
+ if req_json is not None:
187
+ logger.error(f" Request JSON: {req_json}")
188
+ req_data = ad.get('request_data')
189
+ if req_data is not None:
190
+ logger.error(f" Request data: {req_data}")
191
+ resp_headers = ad.get('response_headers')
192
+ if resp_headers:
193
+ logger.error(f" Response headers: {resp_headers}")
194
+ if 'response_json' in ad:
195
+ logger.error(f" Response JSON: {ad.get('response_json')}")
196
+ elif 'response_text' in ad:
197
+ logger.error(f" Response text: {ad.get('response_text')}")
198
+ if ad.get('error'):
199
+ logger.error(f" Error: {ad.get('error')}")
200
+ except Exception:
201
+ pass
159
202
  failure_count += 1
160
203
  if not self.continue_on_failure:
161
204
  return False
@@ -165,6 +208,35 @@ class Step:
165
208
 
166
209
  except Exception as e:
167
210
  logger.error(f"Exception in action {action.name}: {str(e)}")
211
+ # Emit any available diagnostics even on exceptions
212
+ try:
213
+ details = getattr(action, 'execution_data', {}) or {}
214
+ if details:
215
+ method = details.get('request_method') or getattr(action, 'method', None)
216
+ url = details.get('url') or getattr(action, 'url', None)
217
+ status = details.get('status_code')
218
+ dur = details.get('duration_ms')
219
+ parts = []
220
+ if method:
221
+ parts.append(f"method={method}")
222
+ if url:
223
+ parts.append(f"url={url}")
224
+ if status is not None:
225
+ parts.append(f"status={status}")
226
+ if dur is not None:
227
+ parts.append(f"duration_ms={dur}")
228
+ if parts:
229
+ logger.error(" Details: " + ", ".join(parts))
230
+ if details.get('request_headers'):
231
+ logger.error(f" Request headers: {details.get('request_headers')}")
232
+ if details.get('response_headers'):
233
+ logger.error(f" Response headers: {details.get('response_headers')}")
234
+ if 'response_json' in details:
235
+ logger.error(f" Response JSON: {details.get('response_json')}")
236
+ elif 'response_text' in details:
237
+ logger.error(f" Response text: {details.get('response_text')}")
238
+ except Exception:
239
+ pass
168
240
  failure_count += 1
169
241
  if not self.continue_on_failure:
170
242
  return False
@@ -260,7 +332,7 @@ class Journey:
260
332
  logger.error(f"Authentication failed: {str(e)}")
261
333
  return False
262
334
 
263
- def execute(self, driver: WebDriver, target_url: str) -> Dict[str, Any]:
335
+ def execute(self, driver: WebDriver|None, target_url: str) -> Dict[str, Any]:
264
336
  """
265
337
  Execute the complete journey.
266
338
 
@@ -283,7 +355,9 @@ class Journey:
283
355
  start_time = time.time()
284
356
 
285
357
  # Set initial context
286
- self.set_context('target_url', target_url)
358
+ # Normalize target_url to include scheme when missing (e.g., 'localhost:8080' -> 'http://localhost:8080')
359
+ normalized_target_url = HeaderExtractor._normalize_url(target_url) if isinstance(target_url, str) else target_url
360
+ self.set_context('target_url', normalized_target_url)
287
361
  self.set_context('journey_name', self.name)
288
362
  self.set_context('start_time', start_time)
289
363
 
@@ -391,6 +465,48 @@ class Journey:
391
465
  'target_version': target_version
392
466
  }
393
467
  results['step_results'].append(step_result)
468
+
469
+ # If the previous step exhausted the rate limit, pause before starting the next one
470
+ try:
471
+ # Prefer an explicit resume time set by actions
472
+ resume_at = self.context.get('rate_limit_resume_at')
473
+ now = time.time()
474
+ if isinstance(resume_at, (int, float)) and resume_at > now:
475
+ wait_s = min(resume_at - now, 30)
476
+ if wait_s > 0:
477
+ logger.info(f"Rate limit backoff in effect; waiting {wait_s:.2f}s before next step")
478
+ time.sleep(wait_s)
479
+ else:
480
+ last_headers = (self.context.get('last_response_headers') or {})
481
+ if isinstance(last_headers, dict) and last_headers:
482
+ def _h(name: str):
483
+ name = (name or '').lower()
484
+ for k, v in last_headers.items():
485
+ if isinstance(k, str) and k.lower() == name:
486
+ return v
487
+ return None
488
+ retry_after = _h('retry-after')
489
+ if retry_after is not None:
490
+ try:
491
+ wait_s = int(str(retry_after).strip())
492
+ if wait_s > 0:
493
+ logger.info(f"Rate-limited by server (Retry-After={wait_s}s); pausing before next step")
494
+ time.sleep(min(wait_s, 30))
495
+ except Exception:
496
+ pass
497
+ else:
498
+ remaining = _h('x-ratelimit-remaining')
499
+ reset = _h('x-ratelimit-reset')
500
+ if remaining is not None and str(remaining).strip() == '0' and reset is not None:
501
+ try:
502
+ wait_s = int(str(reset).strip())
503
+ if wait_s > 0:
504
+ logger.info(f"Rate limit reached (remaining=0). Waiting {wait_s}s for reset before next step")
505
+ time.sleep(min(wait_s, 30))
506
+ except Exception:
507
+ pass
508
+ except Exception:
509
+ pass
394
510
 
395
511
  except Exception as e:
396
512
  logger.error(f"Exception in step {step.name}: {str(e)}")
@@ -147,7 +147,7 @@ class JourneyExecutor:
147
147
  self.journey.set_context('auth_headers', auth_headers)
148
148
  self.journey.set_context('auth_cookies', auth_cookies)
149
149
 
150
- # Execute journey with a None driver (API actions ignore driver)
150
+ # Execute a journey with a None driver (API actions ignore a driver)
151
151
  self.execution_results = self.journey.execute(None, self.target_url)
152
152
  else:
153
153
  # UI mode (default)
@@ -352,6 +352,45 @@ class JourneyExecutor:
352
352
 
353
353
  self.logger.info(f" {status} Step {i}: {step_name} - {result_text} ({expected_text}){version_info}")
354
354
  self.logger.info(f" Actions: {len([a for a in actions if a.get('actual', False)])}/{len(actions)} succeeded")
355
+ # Print diagnostic details only for unexpected outcomes
356
+ for a in actions:
357
+ actual = a.get('actual', False)
358
+ expected = a.get('expected', True)
359
+ if actual != expected:
360
+ prefix = "✗ Action failed" if expected else "✗ Action unexpectedly succeeded"
361
+ self.logger.error(f" {prefix}: {a.get('action_name')}")
362
+ ad = a.get('details', {}) or {}
363
+ method = ad.get('request_method')
364
+ url = ad.get('url')
365
+ status_code = ad.get('status_code')
366
+ dur = ad.get('duration_ms')
367
+ parts = []
368
+ if method:
369
+ parts.append(f"method={method}")
370
+ if url:
371
+ parts.append(f"url={url}")
372
+ if status_code is not None:
373
+ parts.append(f"status={status_code}")
374
+ if dur is not None:
375
+ parts.append(f"duration_ms={dur}")
376
+ if parts:
377
+ self.logger.error(" Details: " + ", ".join(parts))
378
+ if ad.get('request_headers'):
379
+ self.logger.error(f" Request headers: {ad.get('request_headers')}")
380
+ if ad.get('request_params'):
381
+ self.logger.error(f" Request params: {ad.get('request_params')}")
382
+ if ad.get('request_json') is not None:
383
+ self.logger.error(f" Request JSON: {ad.get('request_json')}")
384
+ if ad.get('request_data') is not None:
385
+ self.logger.error(f" Request data: {ad.get('request_data')}")
386
+ if ad.get('response_headers'):
387
+ self.logger.error(f" Response headers: {ad.get('response_headers')}")
388
+ if 'response_json' in ad:
389
+ self.logger.error(f" Response JSON: {ad.get('response_json')}")
390
+ elif 'response_text' in ad:
391
+ self.logger.error(f" Response text: {ad.get('response_text')}")
392
+ if ad.get('error'):
393
+ self.logger.error(f" Error: {ad.get('error')}")
355
394
 
356
395
  # Version summary
357
396
  target_versions = self.execution_results.get('target_versions', [])