schemathesis 4.0.0a1__py3-none-any.whl → 4.0.0a3__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.
- schemathesis/checks.py +6 -4
- schemathesis/cli/__init__.py +12 -1
- schemathesis/cli/commands/run/__init__.py +4 -4
- schemathesis/cli/commands/run/events.py +19 -4
- schemathesis/cli/commands/run/executor.py +9 -3
- schemathesis/cli/commands/run/filters.py +27 -19
- schemathesis/cli/commands/run/handlers/base.py +1 -1
- schemathesis/cli/commands/run/handlers/cassettes.py +1 -3
- schemathesis/cli/commands/run/handlers/output.py +860 -201
- schemathesis/cli/commands/run/validation.py +1 -1
- schemathesis/cli/ext/options.py +4 -1
- schemathesis/core/errors.py +8 -0
- schemathesis/core/failures.py +54 -24
- schemathesis/engine/core.py +1 -1
- schemathesis/engine/errors.py +11 -5
- schemathesis/engine/events.py +3 -97
- schemathesis/engine/phases/stateful/__init__.py +2 -0
- schemathesis/engine/phases/stateful/_executor.py +22 -50
- schemathesis/engine/phases/unit/__init__.py +1 -0
- schemathesis/engine/phases/unit/_executor.py +2 -1
- schemathesis/engine/phases/unit/_pool.py +1 -1
- schemathesis/engine/recorder.py +29 -23
- schemathesis/errors.py +19 -13
- schemathesis/generation/coverage.py +4 -4
- schemathesis/generation/hypothesis/builder.py +15 -12
- schemathesis/generation/stateful/state_machine.py +61 -45
- schemathesis/graphql/checks.py +3 -9
- schemathesis/openapi/checks.py +8 -33
- schemathesis/schemas.py +34 -14
- schemathesis/specs/graphql/schemas.py +16 -15
- schemathesis/specs/openapi/checks.py +50 -27
- schemathesis/specs/openapi/expressions/__init__.py +11 -15
- schemathesis/specs/openapi/expressions/nodes.py +20 -20
- schemathesis/specs/openapi/links.py +139 -118
- schemathesis/specs/openapi/patterns.py +170 -2
- schemathesis/specs/openapi/schemas.py +60 -36
- schemathesis/specs/openapi/stateful/__init__.py +185 -113
- schemathesis/specs/openapi/stateful/control.py +87 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/METADATA +1 -1
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/RECORD +43 -43
- schemathesis/specs/openapi/expressions/context.py +0 -14
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/WHEEL +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/entry_points.txt +0 -0
- {schemathesis-4.0.0a1.dist-info → schemathesis-4.0.0a3.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,7 @@
|
|
1
1
|
from __future__ import annotations
|
2
2
|
|
3
3
|
import os
|
4
|
+
import time
|
4
5
|
from dataclasses import dataclass, field
|
5
6
|
from types import GeneratorType
|
6
7
|
from typing import TYPE_CHECKING, Any, Generator, Iterable
|
@@ -23,11 +24,13 @@ from schemathesis.engine.phases import PhaseName, PhaseSkipReason
|
|
23
24
|
from schemathesis.engine.phases.probes import ProbeOutcome
|
24
25
|
from schemathesis.engine.recorder import Interaction
|
25
26
|
from schemathesis.experimental import GLOBAL_EXPERIMENTS
|
26
|
-
from schemathesis.schemas import
|
27
|
+
from schemathesis.schemas import ApiStatistic
|
27
28
|
|
28
29
|
if TYPE_CHECKING:
|
29
|
-
from rich.console import Console
|
30
|
+
from rich.console import Console, Group
|
31
|
+
from rich.live import Live
|
30
32
|
from rich.progress import Progress, TaskID
|
33
|
+
from rich.text import Text
|
31
34
|
|
32
35
|
IO_ENCODING = os.getenv("PYTHONIOENCODING", "utf-8")
|
33
36
|
DISCORD_LINK = "https://discord.gg/R9ASRAmHnA"
|
@@ -37,13 +40,7 @@ def display_section_name(title: str, separator: str = "=", **kwargs: Any) -> Non
|
|
37
40
|
"""Print section name with separators in terminal with the given title nicely centered."""
|
38
41
|
message = f" {title} ".center(get_terminal_width(), separator)
|
39
42
|
kwargs.setdefault("bold", True)
|
40
|
-
click.
|
41
|
-
|
42
|
-
|
43
|
-
def get_percentage(position: int, length: int) -> str:
|
44
|
-
"""Format completion percentage in square brackets."""
|
45
|
-
percentage_message = f"{position * 100 // length}%".rjust(4)
|
46
|
-
return f"[{percentage_message}]"
|
43
|
+
click.echo(_style(message, **kwargs))
|
47
44
|
|
48
45
|
|
49
46
|
def bold(option: str) -> str:
|
@@ -61,12 +58,14 @@ def display_failures(ctx: ExecutionContext) -> None:
|
|
61
58
|
|
62
59
|
|
63
60
|
if IO_ENCODING != "utf-8":
|
61
|
+
HEADER_SEPARATOR = "-"
|
64
62
|
|
65
63
|
def _style(text: str, **kwargs: Any) -> str:
|
66
64
|
text = text.encode(IO_ENCODING, errors="replace").decode("utf-8")
|
67
65
|
return click.style(text, **kwargs)
|
68
66
|
|
69
67
|
else:
|
68
|
+
HEADER_SEPARATOR = "━"
|
70
69
|
|
71
70
|
def _style(text: str, **kwargs: Any) -> str:
|
72
71
|
return click.style(text, **kwargs)
|
@@ -100,7 +99,7 @@ def display_failures_for_single_test(ctx: ExecutionContext, label: str, checks:
|
|
100
99
|
click.echo()
|
101
100
|
|
102
101
|
|
103
|
-
VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema"
|
102
|
+
VERIFY_URL_SUGGESTION = "Verify that the URL points directly to the Open API schema or GraphQL endpoint"
|
104
103
|
DISABLE_SSL_SUGGESTION = f"Bypass SSL verification with {bold('`--request-tls-verify=false`')}."
|
105
104
|
LOADER_ERROR_SUGGESTIONS = {
|
106
105
|
# SSL-specific connection issue
|
@@ -125,20 +124,14 @@ def _display_extras(extras: list[str]) -> None:
|
|
125
124
|
if extras:
|
126
125
|
click.echo()
|
127
126
|
for extra in extras:
|
128
|
-
click.
|
129
|
-
|
130
|
-
|
131
|
-
def _maybe_display_tip(suggestion: str | None) -> None:
|
132
|
-
# Display suggestion if any
|
133
|
-
if suggestion is not None:
|
134
|
-
click.secho(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}")
|
127
|
+
click.echo(_style(f" {extra}"))
|
135
128
|
|
136
129
|
|
137
130
|
def display_header(version: str) -> None:
|
138
131
|
prefix = "v" if version != "dev" else ""
|
139
132
|
header = f"Schemathesis {prefix}{version}"
|
140
|
-
click.
|
141
|
-
click.
|
133
|
+
click.echo(_style(header, bold=True))
|
134
|
+
click.echo(_style(HEADER_SEPARATOR * len(header), bold=True))
|
142
135
|
click.echo()
|
143
136
|
|
144
137
|
|
@@ -168,22 +161,634 @@ def _default_console() -> Console:
|
|
168
161
|
BLOCK_PADDING = (0, 1, 0, 1)
|
169
162
|
|
170
163
|
|
164
|
+
@dataclass
|
165
|
+
class LoadingProgressManager:
|
166
|
+
console: Console
|
167
|
+
location: str
|
168
|
+
start_time: float
|
169
|
+
progress: Progress
|
170
|
+
progress_task_id: TaskID | None
|
171
|
+
is_interrupted: bool
|
172
|
+
|
173
|
+
__slots__ = ("console", "location", "start_time", "progress", "progress_task_id", "is_interrupted")
|
174
|
+
|
175
|
+
def __init__(self, console: Console, location: str) -> None:
|
176
|
+
from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
|
177
|
+
from rich.style import Style
|
178
|
+
from rich.text import Text
|
179
|
+
|
180
|
+
self.console = console
|
181
|
+
self.location = location
|
182
|
+
self.start_time = time.monotonic()
|
183
|
+
progress_message = Text.assemble(
|
184
|
+
("Loading specification from ", Style(color="white")),
|
185
|
+
(location, Style(color="cyan")),
|
186
|
+
)
|
187
|
+
self.progress = Progress(
|
188
|
+
TextColumn(""),
|
189
|
+
SpinnerColumn("clock"),
|
190
|
+
RenderableColumn(progress_message),
|
191
|
+
console=console,
|
192
|
+
transient=True,
|
193
|
+
)
|
194
|
+
self.progress_task_id = None
|
195
|
+
self.is_interrupted = False
|
196
|
+
|
197
|
+
def start(self) -> None:
|
198
|
+
"""Start loading progress display."""
|
199
|
+
self.progress_task_id = self.progress.add_task("Loading", total=None)
|
200
|
+
self.progress.start()
|
201
|
+
|
202
|
+
def stop(self) -> None:
|
203
|
+
"""Stop loading progress display."""
|
204
|
+
assert self.progress_task_id is not None
|
205
|
+
self.progress.stop_task(self.progress_task_id)
|
206
|
+
self.progress.stop()
|
207
|
+
|
208
|
+
def interrupt(self) -> None:
|
209
|
+
"""Handle interruption during loading."""
|
210
|
+
self.is_interrupted = True
|
211
|
+
self.stop()
|
212
|
+
|
213
|
+
def get_completion_message(self) -> Text:
|
214
|
+
"""Generate completion message including duration."""
|
215
|
+
from rich.style import Style
|
216
|
+
from rich.text import Text
|
217
|
+
|
218
|
+
duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
|
219
|
+
if self.is_interrupted:
|
220
|
+
return Text.assemble(
|
221
|
+
("⚡ ", Style(color="yellow")),
|
222
|
+
(f"Loading interrupted after {duration} while loading from ", Style(color="white")),
|
223
|
+
(self.location, Style(color="cyan")),
|
224
|
+
)
|
225
|
+
return Text.assemble(
|
226
|
+
("✅ ", Style(color="green")),
|
227
|
+
("Loaded specification from ", Style(color="bright_white")),
|
228
|
+
(self.location, Style(color="cyan")),
|
229
|
+
(f" (in {duration})", Style(color="bright_white")),
|
230
|
+
)
|
231
|
+
|
232
|
+
def get_error_message(self, error: LoaderError) -> Group:
|
233
|
+
from rich.console import Group
|
234
|
+
from rich.style import Style
|
235
|
+
from rich.text import Text
|
236
|
+
|
237
|
+
duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
|
238
|
+
|
239
|
+
# Show what was attempted
|
240
|
+
attempted = Text.assemble(
|
241
|
+
("❌ ", Style(color="red")),
|
242
|
+
("Failed to load specification from ", Style(color="white")),
|
243
|
+
(self.location, Style(color="cyan")),
|
244
|
+
(f" after {duration}", Style(color="white")),
|
245
|
+
)
|
246
|
+
|
247
|
+
# Show error details
|
248
|
+
error_title = Text("Schema Loading Error", style=Style(color="red", bold=True))
|
249
|
+
error_message = Text(error.message)
|
250
|
+
|
251
|
+
return Group(
|
252
|
+
attempted,
|
253
|
+
Text(),
|
254
|
+
error_title,
|
255
|
+
Text(),
|
256
|
+
error_message,
|
257
|
+
)
|
258
|
+
|
259
|
+
|
260
|
+
@dataclass
|
261
|
+
class ProbingProgressManager:
|
262
|
+
console: Console
|
263
|
+
start_time: float
|
264
|
+
progress: Progress
|
265
|
+
progress_task_id: TaskID | None
|
266
|
+
is_interrupted: bool
|
267
|
+
|
268
|
+
__slots__ = ("console", "start_time", "progress", "progress_task_id", "is_interrupted")
|
269
|
+
|
270
|
+
def __init__(self, console: Console) -> None:
|
271
|
+
from rich.progress import Progress, RenderableColumn, SpinnerColumn, TextColumn
|
272
|
+
from rich.text import Text
|
273
|
+
|
274
|
+
self.console = console
|
275
|
+
self.start_time = time.monotonic()
|
276
|
+
self.progress = Progress(
|
277
|
+
TextColumn(""),
|
278
|
+
SpinnerColumn("clock"),
|
279
|
+
RenderableColumn(Text("Probing API capabilities", style="bright_white")),
|
280
|
+
transient=True,
|
281
|
+
console=console,
|
282
|
+
)
|
283
|
+
self.progress_task_id = None
|
284
|
+
self.is_interrupted = False
|
285
|
+
|
286
|
+
def start(self) -> None:
|
287
|
+
"""Start probing progress display."""
|
288
|
+
self.progress_task_id = self.progress.add_task("Probing", total=None)
|
289
|
+
self.progress.start()
|
290
|
+
|
291
|
+
def stop(self) -> None:
|
292
|
+
"""Stop probing progress display."""
|
293
|
+
assert self.progress_task_id is not None
|
294
|
+
self.progress.stop_task(self.progress_task_id)
|
295
|
+
self.progress.stop()
|
296
|
+
|
297
|
+
def interrupt(self) -> None:
|
298
|
+
"""Handle interruption during probing."""
|
299
|
+
self.is_interrupted = True
|
300
|
+
self.stop()
|
301
|
+
|
302
|
+
def get_completion_message(self) -> Text:
|
303
|
+
"""Generate completion message including duration."""
|
304
|
+
from rich.style import Style
|
305
|
+
from rich.text import Text
|
306
|
+
|
307
|
+
duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
|
308
|
+
if self.is_interrupted:
|
309
|
+
return Text.assemble(
|
310
|
+
("⚡ ", Style(color="yellow")),
|
311
|
+
(f"API probing interrupted after {duration}", Style(color="white")),
|
312
|
+
)
|
313
|
+
return Text.assemble(
|
314
|
+
("✅ ", Style(color="green")),
|
315
|
+
("API capabilities:", Style(color="white")),
|
316
|
+
)
|
317
|
+
|
318
|
+
|
171
319
|
@dataclass
|
172
320
|
class WarningData:
|
173
321
|
missing_auth: dict[int, list[str]] = field(default_factory=dict)
|
174
322
|
|
175
323
|
|
324
|
+
@dataclass
|
325
|
+
class OperationProgress:
|
326
|
+
"""Tracks individual operation progress."""
|
327
|
+
|
328
|
+
label: str
|
329
|
+
start_time: float
|
330
|
+
task_id: TaskID
|
331
|
+
|
332
|
+
__slots__ = ("label", "start_time", "task_id")
|
333
|
+
|
334
|
+
|
335
|
+
@dataclass
|
336
|
+
class UnitTestProgressManager:
|
337
|
+
"""Manages progress display for unit tests."""
|
338
|
+
|
339
|
+
console: Console
|
340
|
+
title: str
|
341
|
+
current: int
|
342
|
+
total: int
|
343
|
+
start_time: float
|
344
|
+
|
345
|
+
# Progress components
|
346
|
+
title_progress: Progress
|
347
|
+
progress_bar: Progress
|
348
|
+
operations_progress: Progress
|
349
|
+
current_operations: dict[str, OperationProgress]
|
350
|
+
stats: dict[Status, int]
|
351
|
+
stats_progress: Progress
|
352
|
+
live: Live | None
|
353
|
+
|
354
|
+
# Task IDs
|
355
|
+
title_task_id: TaskID | None
|
356
|
+
progress_task_id: TaskID | None
|
357
|
+
stats_task_id: TaskID
|
358
|
+
|
359
|
+
is_interrupted: bool
|
360
|
+
|
361
|
+
__slots__ = (
|
362
|
+
"console",
|
363
|
+
"title",
|
364
|
+
"current",
|
365
|
+
"total",
|
366
|
+
"start_time",
|
367
|
+
"title_progress",
|
368
|
+
"progress_bar",
|
369
|
+
"operations_progress",
|
370
|
+
"current_operations",
|
371
|
+
"stats",
|
372
|
+
"stats_progress",
|
373
|
+
"live",
|
374
|
+
"title_task_id",
|
375
|
+
"progress_task_id",
|
376
|
+
"stats_task_id",
|
377
|
+
"is_interrupted",
|
378
|
+
)
|
379
|
+
|
380
|
+
def __init__(
|
381
|
+
self,
|
382
|
+
console: Console,
|
383
|
+
title: str,
|
384
|
+
total: int,
|
385
|
+
) -> None:
|
386
|
+
from rich.progress import (
|
387
|
+
BarColumn,
|
388
|
+
Progress,
|
389
|
+
SpinnerColumn,
|
390
|
+
TextColumn,
|
391
|
+
TimeElapsedColumn,
|
392
|
+
)
|
393
|
+
from rich.style import Style
|
394
|
+
|
395
|
+
self.console = console
|
396
|
+
self.title = title
|
397
|
+
self.current = 0
|
398
|
+
self.total = total
|
399
|
+
self.start_time = time.monotonic()
|
400
|
+
|
401
|
+
# Initialize progress displays
|
402
|
+
self.title_progress = Progress(
|
403
|
+
TextColumn(""),
|
404
|
+
SpinnerColumn("clock"),
|
405
|
+
TextColumn("{task.description}", style=Style(color="white")),
|
406
|
+
console=self.console,
|
407
|
+
)
|
408
|
+
self.title_task_id = None
|
409
|
+
|
410
|
+
self.progress_bar = Progress(
|
411
|
+
TextColumn(" "),
|
412
|
+
TimeElapsedColumn(),
|
413
|
+
BarColumn(bar_width=None),
|
414
|
+
TextColumn("{task.percentage:.0f}% ({task.completed}/{task.total})"),
|
415
|
+
console=self.console,
|
416
|
+
)
|
417
|
+
self.progress_task_id = None
|
418
|
+
|
419
|
+
self.operations_progress = Progress(
|
420
|
+
TextColumn(" "),
|
421
|
+
SpinnerColumn("dots"),
|
422
|
+
TimeElapsedColumn(),
|
423
|
+
TextColumn(" {task.fields[label]}"),
|
424
|
+
console=self.console,
|
425
|
+
)
|
426
|
+
|
427
|
+
self.current_operations = {}
|
428
|
+
|
429
|
+
self.stats_progress = Progress(
|
430
|
+
TextColumn(" "),
|
431
|
+
TextColumn("{task.description}"),
|
432
|
+
console=self.console,
|
433
|
+
)
|
434
|
+
self.stats_task_id = self.stats_progress.add_task("")
|
435
|
+
self.stats = {
|
436
|
+
Status.SUCCESS: 0,
|
437
|
+
Status.FAILURE: 0,
|
438
|
+
Status.SKIP: 0,
|
439
|
+
Status.ERROR: 0,
|
440
|
+
}
|
441
|
+
self._update_stats_display()
|
442
|
+
|
443
|
+
self.live = None
|
444
|
+
self.is_interrupted = False
|
445
|
+
|
446
|
+
def _get_stats_message(self) -> str:
|
447
|
+
width = len(str(self.total))
|
448
|
+
|
449
|
+
parts = []
|
450
|
+
if self.stats[Status.SUCCESS]:
|
451
|
+
parts.append(f"✅ {self.stats[Status.SUCCESS]:{width}d} passed")
|
452
|
+
if self.stats[Status.FAILURE]:
|
453
|
+
parts.append(f"❌ {self.stats[Status.FAILURE]:{width}d} failed")
|
454
|
+
if self.stats[Status.ERROR]:
|
455
|
+
parts.append(f"🚫 {self.stats[Status.ERROR]:{width}d} errors")
|
456
|
+
if self.stats[Status.SKIP]:
|
457
|
+
parts.append(f"⏭️ {self.stats[Status.SKIP]:{width}d} skipped")
|
458
|
+
return " ".join(parts)
|
459
|
+
|
460
|
+
def _update_stats_display(self) -> None:
|
461
|
+
"""Update the statistics display."""
|
462
|
+
self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
|
463
|
+
|
464
|
+
def start(self) -> None:
|
465
|
+
"""Start progress display."""
|
466
|
+
from rich.console import Group
|
467
|
+
from rich.live import Live
|
468
|
+
from rich.text import Text
|
469
|
+
|
470
|
+
group = Group(
|
471
|
+
self.title_progress,
|
472
|
+
Text(),
|
473
|
+
self.progress_bar,
|
474
|
+
Text(),
|
475
|
+
self.operations_progress,
|
476
|
+
Text(),
|
477
|
+
self.stats_progress,
|
478
|
+
)
|
479
|
+
|
480
|
+
self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
|
481
|
+
self.live.start()
|
482
|
+
|
483
|
+
# Initialize both progress displays
|
484
|
+
self.title_task_id = self.title_progress.add_task(self.title, total=self.total)
|
485
|
+
self.progress_task_id = self.progress_bar.add_task(
|
486
|
+
"", # Empty description as it's shown in title
|
487
|
+
total=self.total,
|
488
|
+
)
|
489
|
+
|
490
|
+
def update_progress(self) -> None:
|
491
|
+
"""Update progress in both displays."""
|
492
|
+
assert self.title_task_id is not None
|
493
|
+
assert self.progress_task_id is not None
|
494
|
+
|
495
|
+
self.current += 1
|
496
|
+
self.title_progress.update(self.title_task_id, completed=self.current)
|
497
|
+
self.progress_bar.update(self.progress_task_id, completed=self.current)
|
498
|
+
|
499
|
+
def start_operation(self, label: str) -> None:
|
500
|
+
"""Start tracking new operation."""
|
501
|
+
task_id = self.operations_progress.add_task("", label=label, start_time=time.monotonic())
|
502
|
+
self.current_operations[label] = OperationProgress(label=label, start_time=time.monotonic(), task_id=task_id)
|
503
|
+
|
504
|
+
def finish_operation(self, label: str) -> None:
|
505
|
+
"""Finish tracking operation."""
|
506
|
+
if operation := self.current_operations.pop(label, None):
|
507
|
+
if not self.current_operations:
|
508
|
+
assert self.title_task_id is not None
|
509
|
+
self.title_progress.update(self.title_task_id)
|
510
|
+
self.operations_progress.update(operation.task_id, visible=False)
|
511
|
+
|
512
|
+
def update_stats(self, status: Status) -> None:
|
513
|
+
"""Update statistics for a finished scenario."""
|
514
|
+
self.stats[status] += 1
|
515
|
+
self._update_stats_display()
|
516
|
+
|
517
|
+
def interrupt(self) -> None:
|
518
|
+
self.is_interrupted = True
|
519
|
+
self.stats[Status.SKIP] += self.total - self.current
|
520
|
+
if self.live:
|
521
|
+
self.stop()
|
522
|
+
|
523
|
+
def stop(self) -> None:
|
524
|
+
"""Stop all progress displays."""
|
525
|
+
if self.live:
|
526
|
+
self.live.stop()
|
527
|
+
|
528
|
+
def _get_status_icon(self, default_icon: str = "🕛") -> str:
|
529
|
+
if self.is_interrupted:
|
530
|
+
icon = "⚡"
|
531
|
+
elif self.stats[Status.ERROR] > 0:
|
532
|
+
icon = "🚫"
|
533
|
+
elif self.stats[Status.FAILURE] > 0:
|
534
|
+
icon = "❌"
|
535
|
+
elif self.stats[Status.SUCCESS] > 0:
|
536
|
+
icon = "✅"
|
537
|
+
elif self.stats[Status.SKIP] > 0:
|
538
|
+
icon = "⏭️"
|
539
|
+
else:
|
540
|
+
icon = default_icon
|
541
|
+
return icon
|
542
|
+
|
543
|
+
def get_completion_message(self, default_icon: str = "🕛") -> str:
|
544
|
+
"""Complete the phase and return status message."""
|
545
|
+
duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
|
546
|
+
icon = self._get_status_icon(default_icon)
|
547
|
+
|
548
|
+
message = self._get_stats_message() or "No tests were run"
|
549
|
+
if self.is_interrupted:
|
550
|
+
duration_message = f"interrupted after {duration}"
|
551
|
+
else:
|
552
|
+
duration_message = f"in {duration}"
|
553
|
+
|
554
|
+
return f"{icon} {self.title} ({duration_message})\n\n {message}"
|
555
|
+
|
556
|
+
|
557
|
+
@dataclass
|
558
|
+
class StatefulProgressManager:
|
559
|
+
"""Manages progress display for stateful testing."""
|
560
|
+
|
561
|
+
console: Console
|
562
|
+
title: str
|
563
|
+
links_selected: int
|
564
|
+
links_total: int
|
565
|
+
start_time: float
|
566
|
+
|
567
|
+
# Progress components
|
568
|
+
title_progress: Progress
|
569
|
+
progress_bar: Progress
|
570
|
+
stats_progress: Progress
|
571
|
+
live: Live | None
|
572
|
+
|
573
|
+
# Task IDs
|
574
|
+
title_task_id: TaskID | None
|
575
|
+
progress_task_id: TaskID | None
|
576
|
+
stats_task_id: TaskID
|
577
|
+
|
578
|
+
# State
|
579
|
+
scenarios: int
|
580
|
+
links_covered: set[str]
|
581
|
+
stats: dict[Status, int]
|
582
|
+
is_interrupted: bool
|
583
|
+
|
584
|
+
__slots__ = (
|
585
|
+
"console",
|
586
|
+
"title",
|
587
|
+
"links_selected",
|
588
|
+
"links_total",
|
589
|
+
"start_time",
|
590
|
+
"title_progress",
|
591
|
+
"progress_bar",
|
592
|
+
"stats_progress",
|
593
|
+
"live",
|
594
|
+
"title_task_id",
|
595
|
+
"progress_task_id",
|
596
|
+
"stats_task_id",
|
597
|
+
"scenarios",
|
598
|
+
"links_covered",
|
599
|
+
"stats",
|
600
|
+
"is_interrupted",
|
601
|
+
)
|
602
|
+
|
603
|
+
def __init__(self, *, console: Console, title: str, links_selected: int, links_total: int) -> None:
|
604
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
605
|
+
from rich.style import Style
|
606
|
+
|
607
|
+
self.console = console
|
608
|
+
self.title = title
|
609
|
+
self.links_selected = links_selected
|
610
|
+
self.links_total = links_total
|
611
|
+
self.start_time = time.monotonic()
|
612
|
+
|
613
|
+
self.title_progress = Progress(
|
614
|
+
TextColumn(""),
|
615
|
+
SpinnerColumn("clock"),
|
616
|
+
TextColumn("{task.description}", style=Style(color="bright_white")),
|
617
|
+
console=self.console,
|
618
|
+
)
|
619
|
+
self.title_task_id = None
|
620
|
+
|
621
|
+
self.progress_bar = Progress(
|
622
|
+
TextColumn(" "),
|
623
|
+
TimeElapsedColumn(),
|
624
|
+
TextColumn("{task.fields[scenarios]:3d} scenarios • {task.fields[links]}"),
|
625
|
+
console=self.console,
|
626
|
+
)
|
627
|
+
self.progress_task_id = None
|
628
|
+
|
629
|
+
# Initialize stats progress
|
630
|
+
self.stats_progress = Progress(
|
631
|
+
TextColumn(" "),
|
632
|
+
TextColumn("{task.description}"),
|
633
|
+
console=self.console,
|
634
|
+
)
|
635
|
+
self.stats_task_id = self.stats_progress.add_task("")
|
636
|
+
|
637
|
+
self.live = None
|
638
|
+
|
639
|
+
# Initialize state
|
640
|
+
self.scenarios = 0
|
641
|
+
self.links_covered = set()
|
642
|
+
self.stats = {
|
643
|
+
Status.SUCCESS: 0,
|
644
|
+
Status.FAILURE: 0,
|
645
|
+
Status.ERROR: 0,
|
646
|
+
Status.SKIP: 0,
|
647
|
+
}
|
648
|
+
self.is_interrupted = False
|
649
|
+
|
650
|
+
def start(self) -> None:
|
651
|
+
"""Start progress display."""
|
652
|
+
from rich.console import Group
|
653
|
+
from rich.live import Live
|
654
|
+
from rich.text import Text
|
655
|
+
|
656
|
+
# Initialize progress displays
|
657
|
+
self.title_task_id = self.title_progress.add_task("Stateful tests")
|
658
|
+
self.progress_task_id = self.progress_bar.add_task(
|
659
|
+
"", scenarios=0, links=f"0 covered / {self.links_selected} selected / {self.links_total} total links"
|
660
|
+
)
|
661
|
+
|
662
|
+
# Create live display
|
663
|
+
group = Group(
|
664
|
+
self.title_progress,
|
665
|
+
Text(),
|
666
|
+
self.progress_bar,
|
667
|
+
Text(),
|
668
|
+
self.stats_progress,
|
669
|
+
)
|
670
|
+
self.live = Live(group, refresh_per_second=10, console=self.console, transient=True)
|
671
|
+
self.live.start()
|
672
|
+
|
673
|
+
def stop(self) -> None:
|
674
|
+
"""Stop progress display."""
|
675
|
+
if self.live:
|
676
|
+
self.live.stop()
|
677
|
+
|
678
|
+
def update(self, links_covered: set[str], status: Status | None = None) -> None:
|
679
|
+
"""Update progress and stats."""
|
680
|
+
self.scenarios += 1
|
681
|
+
self.links_covered.update(links_covered)
|
682
|
+
|
683
|
+
if status is not None:
|
684
|
+
self.stats[status] += 1
|
685
|
+
|
686
|
+
self._update_progress_display()
|
687
|
+
self._update_stats_display()
|
688
|
+
|
689
|
+
def _update_progress_display(self) -> None:
|
690
|
+
"""Update the progress display."""
|
691
|
+
assert self.progress_task_id is not None
|
692
|
+
self.progress_bar.update(
|
693
|
+
self.progress_task_id,
|
694
|
+
scenarios=self.scenarios,
|
695
|
+
links=f"{len(self.links_covered)} covered / {self.links_selected} selected / {self.links_total} total links",
|
696
|
+
)
|
697
|
+
|
698
|
+
def _get_stats_message(self) -> str:
|
699
|
+
"""Get formatted stats message."""
|
700
|
+
parts = []
|
701
|
+
if self.stats[Status.SUCCESS]:
|
702
|
+
parts.append(f"✅ {self.stats[Status.SUCCESS]} passed")
|
703
|
+
if self.stats[Status.FAILURE]:
|
704
|
+
parts.append(f"❌ {self.stats[Status.FAILURE]} failed")
|
705
|
+
if self.stats[Status.ERROR]:
|
706
|
+
parts.append(f"🚫 {self.stats[Status.ERROR]} errors")
|
707
|
+
if self.stats[Status.SKIP]:
|
708
|
+
parts.append(f"⏭️ {self.stats[Status.SKIP]} skipped")
|
709
|
+
return " ".join(parts)
|
710
|
+
|
711
|
+
def _update_stats_display(self) -> None:
|
712
|
+
"""Update the statistics display."""
|
713
|
+
self.stats_progress.update(self.stats_task_id, description=self._get_stats_message())
|
714
|
+
|
715
|
+
def _get_status_icon(self, default_icon: str = "🕛") -> str:
|
716
|
+
if self.is_interrupted:
|
717
|
+
icon = "⚡"
|
718
|
+
elif self.stats[Status.ERROR] > 0:
|
719
|
+
icon = "🚫"
|
720
|
+
elif self.stats[Status.FAILURE] > 0:
|
721
|
+
icon = "❌"
|
722
|
+
elif self.stats[Status.SUCCESS] > 0:
|
723
|
+
icon = "✅"
|
724
|
+
elif self.stats[Status.SKIP] > 0:
|
725
|
+
icon = "⏭️"
|
726
|
+
else:
|
727
|
+
icon = default_icon
|
728
|
+
return icon
|
729
|
+
|
730
|
+
def interrupt(self) -> None:
|
731
|
+
"""Handle interruption."""
|
732
|
+
self.is_interrupted = True
|
733
|
+
if self.live:
|
734
|
+
self.stop()
|
735
|
+
|
736
|
+
def get_completion_message(self, icon: str | None = None) -> tuple[str, str]:
|
737
|
+
"""Complete the phase and return status message."""
|
738
|
+
duration = format_duration(int((time.monotonic() - self.start_time) * 1000))
|
739
|
+
icon = icon or self._get_status_icon()
|
740
|
+
|
741
|
+
message = self._get_stats_message() or "No tests were run"
|
742
|
+
if self.is_interrupted:
|
743
|
+
duration_message = f"interrupted after {duration}"
|
744
|
+
else:
|
745
|
+
duration_message = f"in {duration}"
|
746
|
+
|
747
|
+
return f"{icon} {self.title} ({duration_message})", message
|
748
|
+
|
749
|
+
|
750
|
+
def format_duration(duration_ms: int) -> str:
|
751
|
+
"""Format duration in milliseconds to human readable string."""
|
752
|
+
parts = []
|
753
|
+
|
754
|
+
# Convert to components
|
755
|
+
ms = duration_ms % 1000
|
756
|
+
seconds = (duration_ms // 1000) % 60
|
757
|
+
minutes = (duration_ms // (1000 * 60)) % 60
|
758
|
+
hours = duration_ms // (1000 * 60 * 60)
|
759
|
+
|
760
|
+
# Add non-empty components
|
761
|
+
if hours > 0:
|
762
|
+
parts.append(f"{hours} h")
|
763
|
+
if minutes > 0:
|
764
|
+
parts.append(f"{minutes} m")
|
765
|
+
if seconds > 0:
|
766
|
+
parts.append(f"{seconds} s")
|
767
|
+
if ms > 0:
|
768
|
+
parts.append(f"{ms} ms")
|
769
|
+
|
770
|
+
# Handle zero duration
|
771
|
+
if not parts:
|
772
|
+
return "0 ms"
|
773
|
+
|
774
|
+
return " ".join(parts)
|
775
|
+
|
776
|
+
|
176
777
|
@dataclass
|
177
778
|
class OutputHandler(EventHandler):
|
178
779
|
workers_num: int
|
780
|
+
# Seed can't be absent in the deterministic mode
|
781
|
+
seed: int | None
|
179
782
|
rate_limit: str | None
|
180
783
|
wait_for_schema: float | None
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
|
784
|
+
|
785
|
+
loading_manager: LoadingProgressManager | None = None
|
786
|
+
probing_manager: ProbingProgressManager | None = None
|
787
|
+
unit_tests_manager: UnitTestProgressManager | None = None
|
788
|
+
stateful_tests_manager: StatefulProgressManager | None = None
|
789
|
+
|
790
|
+
statistic: ApiStatistic | None = None
|
185
791
|
skip_reasons: list[str] = field(default_factory=list)
|
186
|
-
current_line_length: int = 0
|
187
792
|
cassette_config: CassetteConfig | None = None
|
188
793
|
junit_xml_file: str | None = None
|
189
794
|
warnings: WarningData = field(default_factory=WarningData)
|
@@ -205,9 +810,9 @@ class OutputHandler(EventHandler):
|
|
205
810
|
if isinstance(event, events.EngineFinished):
|
206
811
|
self._on_engine_finished(ctx, event)
|
207
812
|
elif isinstance(event, events.Interrupted):
|
208
|
-
self._on_interrupted()
|
813
|
+
self._on_interrupted(event)
|
209
814
|
elif isinstance(event, events.FatalError):
|
210
|
-
self._on_fatal_error(event)
|
815
|
+
self._on_fatal_error(ctx, event)
|
211
816
|
elif isinstance(event, events.NonFatalError):
|
212
817
|
self.errors.append(event)
|
213
818
|
elif isinstance(event, LoadingStarted):
|
@@ -218,63 +823,36 @@ class OutputHandler(EventHandler):
|
|
218
823
|
def start(self, ctx: ExecutionContext) -> None:
|
219
824
|
display_header(SCHEMATHESIS_VERSION)
|
220
825
|
|
221
|
-
def shutdown(self) -> None:
|
222
|
-
if self.
|
223
|
-
self.
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
self.
|
229
|
-
|
230
|
-
|
231
|
-
def _stop_progress(self) -> None:
|
232
|
-
assert self.progress is not None
|
233
|
-
assert self.progress_task_id is not None
|
234
|
-
self.progress.stop_task(self.progress_task_id)
|
235
|
-
self.progress.stop()
|
236
|
-
self.progress = None
|
237
|
-
self.progress_task_id = None
|
826
|
+
def shutdown(self, ctx: ExecutionContext) -> None:
|
827
|
+
if self.unit_tests_manager is not None:
|
828
|
+
self.unit_tests_manager.stop()
|
829
|
+
if self.stateful_tests_manager is not None:
|
830
|
+
self.stateful_tests_manager.stop()
|
831
|
+
if self.loading_manager is not None:
|
832
|
+
self.loading_manager.stop()
|
833
|
+
if self.probing_manager is not None:
|
834
|
+
self.probing_manager.stop()
|
238
835
|
|
239
836
|
def _on_loading_started(self, event: LoadingStarted) -> None:
|
240
|
-
|
241
|
-
|
242
|
-
from rich.text import Text
|
243
|
-
|
244
|
-
progress_message = Text.assemble(
|
245
|
-
("Loading specification from ", Style(color="white")),
|
246
|
-
(event.location, Style(color="cyan")),
|
247
|
-
)
|
248
|
-
self.progress = Progress(
|
249
|
-
TextColumn(""),
|
250
|
-
SpinnerColumn("clock"),
|
251
|
-
RenderableColumn(progress_message),
|
252
|
-
console=self.console,
|
253
|
-
transient=True,
|
254
|
-
)
|
255
|
-
self._start_progress("Loading")
|
837
|
+
self.loading_manager = LoadingProgressManager(console=self.console, location=event.location)
|
838
|
+
self.loading_manager.start()
|
256
839
|
|
257
840
|
def _on_loading_finished(self, ctx: ExecutionContext, event: LoadingFinished) -> None:
|
258
841
|
from rich.padding import Padding
|
259
842
|
from rich.style import Style
|
260
843
|
from rich.table import Table
|
261
|
-
from rich.text import Text
|
262
844
|
|
263
|
-
self.
|
264
|
-
self.
|
845
|
+
assert self.loading_manager is not None
|
846
|
+
self.loading_manager.stop()
|
265
847
|
|
266
|
-
duration_ms = int(event.duration * 1000)
|
267
848
|
message = Padding(
|
268
|
-
|
269
|
-
("✅ ", Style(color="green")),
|
270
|
-
("Loaded specification from ", Style(color="white")),
|
271
|
-
(event.location, Style(color="cyan")),
|
272
|
-
(f" (in {duration_ms} ms)", Style(color="white")),
|
273
|
-
),
|
849
|
+
self.loading_manager.get_completion_message(),
|
274
850
|
BLOCK_PADDING,
|
275
851
|
)
|
276
852
|
self.console.print(message)
|
277
853
|
self.console.print()
|
854
|
+
self.loading_manager = None
|
855
|
+
self.statistic = event.statistic
|
278
856
|
|
279
857
|
table = Table(
|
280
858
|
show_header=False,
|
@@ -287,7 +865,7 @@ class OutputHandler(EventHandler):
|
|
287
865
|
|
288
866
|
table.add_row("Base URL:", event.base_url)
|
289
867
|
table.add_row("Specification:", event.specification.name)
|
290
|
-
table.add_row("Operations:", str(event.
|
868
|
+
table.add_row("Operations:", str(event.statistic.operations.total))
|
291
869
|
|
292
870
|
message = Padding(table, BLOCK_PADDING)
|
293
871
|
self.console.print(message)
|
@@ -300,22 +878,33 @@ class OutputHandler(EventHandler):
|
|
300
878
|
phase = event.phase
|
301
879
|
if phase.name == PhaseName.PROBING and phase.is_enabled:
|
302
880
|
self._start_probing()
|
881
|
+
elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
|
882
|
+
self._start_unit_tests()
|
303
883
|
elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled and phase.skip_reason is None:
|
304
|
-
|
884
|
+
self._start_stateful_tests()
|
305
885
|
|
306
886
|
def _start_probing(self) -> None:
|
307
|
-
|
308
|
-
|
887
|
+
self.probing_manager = ProbingProgressManager(console=self.console)
|
888
|
+
self.probing_manager.start()
|
309
889
|
|
310
|
-
|
311
|
-
self.
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
890
|
+
def _start_unit_tests(self) -> None:
|
891
|
+
assert self.statistic is not None
|
892
|
+
self.unit_tests_manager = UnitTestProgressManager(
|
893
|
+
console=self.console,
|
894
|
+
title="Unit tests",
|
895
|
+
total=self.statistic.operations.total,
|
896
|
+
)
|
897
|
+
self.unit_tests_manager.start()
|
898
|
+
|
899
|
+
def _start_stateful_tests(self) -> None:
|
900
|
+
assert self.statistic is not None
|
901
|
+
self.stateful_tests_manager = StatefulProgressManager(
|
316
902
|
console=self.console,
|
903
|
+
title="Stateful tests",
|
904
|
+
links_selected=self.statistic.links.selected,
|
905
|
+
links_total=self.statistic.links.total,
|
317
906
|
)
|
318
|
-
self.
|
907
|
+
self.stateful_tests_manager.start()
|
319
908
|
|
320
909
|
def _on_phase_finished(self, event: events.PhaseFinished) -> None:
|
321
910
|
from rich.padding import Padding
|
@@ -327,7 +916,9 @@ class OutputHandler(EventHandler):
|
|
327
916
|
self.phases[phase.name] = (event.status, phase.skip_reason)
|
328
917
|
|
329
918
|
if phase.name == PhaseName.PROBING:
|
330
|
-
self.
|
919
|
+
assert self.probing_manager is not None
|
920
|
+
self.probing_manager.stop()
|
921
|
+
self.probing_manager = None
|
331
922
|
|
332
923
|
if event.status == Status.SUCCESS:
|
333
924
|
assert isinstance(event.payload, Ok)
|
@@ -336,7 +927,7 @@ class OutputHandler(EventHandler):
|
|
336
927
|
Padding(
|
337
928
|
Text.assemble(
|
338
929
|
("✅ ", Style(color="green")),
|
339
|
-
("API capabilities:", Style(color="
|
930
|
+
("API capabilities:", Style(color="bright_white")),
|
340
931
|
),
|
341
932
|
BLOCK_PADDING,
|
342
933
|
)
|
@@ -384,87 +975,141 @@ class OutputHandler(EventHandler):
|
|
384
975
|
self.console.print(message)
|
385
976
|
self.console.print()
|
386
977
|
elif phase.name == PhaseName.STATEFUL_TESTING and phase.is_enabled:
|
387
|
-
|
388
|
-
|
978
|
+
assert self.stateful_tests_manager is not None
|
979
|
+
self.stateful_tests_manager.stop()
|
980
|
+
if event.status == Status.ERROR:
|
981
|
+
title, summary = self.stateful_tests_manager.get_completion_message("🚫")
|
982
|
+
else:
|
983
|
+
title, summary = self.stateful_tests_manager.get_completion_message()
|
984
|
+
|
985
|
+
self.console.print(Padding(Text(title, style="bright_white"), BLOCK_PADDING))
|
986
|
+
|
987
|
+
table = Table(
|
988
|
+
show_header=False,
|
989
|
+
box=None,
|
990
|
+
padding=(0, 4),
|
991
|
+
collapse_padding=True,
|
992
|
+
)
|
993
|
+
table.add_column("Field", style=Style(color="bright_white", bold=True))
|
994
|
+
table.add_column("Value", style="cyan")
|
995
|
+
table.add_row("Scenarios:", f"{self.stateful_tests_manager.scenarios}")
|
996
|
+
table.add_row(
|
997
|
+
"API Links:",
|
998
|
+
f"{len(self.stateful_tests_manager.links_covered)} covered / {self.stateful_tests_manager.links_selected} selected / {self.stateful_tests_manager.links_total} total",
|
999
|
+
)
|
1000
|
+
|
1001
|
+
self.console.print()
|
1002
|
+
self.console.print(Padding(table, BLOCK_PADDING))
|
1003
|
+
self.console.print()
|
1004
|
+
self.console.print(Padding(Text(summary, style="bright_white"), (0, 0, 0, 5)))
|
1005
|
+
self.console.print()
|
1006
|
+
self.stateful_tests_manager = None
|
389
1007
|
elif phase.name == PhaseName.UNIT_TESTING and phase.is_enabled:
|
1008
|
+
assert self.unit_tests_manager is not None
|
1009
|
+
self.unit_tests_manager.stop()
|
1010
|
+
if event.status == Status.ERROR:
|
1011
|
+
message = self.unit_tests_manager.get_completion_message("🚫")
|
1012
|
+
else:
|
1013
|
+
message = self.unit_tests_manager.get_completion_message()
|
1014
|
+
self.console.print(Padding(Text(message, style="white"), BLOCK_PADDING))
|
390
1015
|
if event.status != Status.INTERRUPTED:
|
391
|
-
|
392
|
-
|
393
|
-
click.echo()
|
1016
|
+
self.console.print()
|
1017
|
+
self.unit_tests_manager = None
|
394
1018
|
|
395
1019
|
def _on_scenario_started(self, event: events.ScenarioStarted) -> None:
|
396
|
-
if event.phase == PhaseName.UNIT_TESTING
|
1020
|
+
if event.phase == PhaseName.UNIT_TESTING:
|
397
1021
|
# We should display execution result + percentage in the end. For example:
|
398
1022
|
assert event.label is not None
|
399
|
-
|
400
|
-
|
401
|
-
message = message[:max_length] + (message[max_length:] and "[...]") + " "
|
402
|
-
self.current_line_length = len(message)
|
403
|
-
click.echo(message, nl=False)
|
1023
|
+
assert self.unit_tests_manager is not None
|
1024
|
+
self.unit_tests_manager.start_operation(event.label)
|
404
1025
|
|
405
1026
|
def _on_scenario_finished(self, event: events.ScenarioFinished) -> None:
|
406
|
-
self.operations_processed += 1
|
407
1027
|
if event.phase == PhaseName.UNIT_TESTING:
|
1028
|
+
assert self.unit_tests_manager is not None
|
1029
|
+
if event.label:
|
1030
|
+
self.unit_tests_manager.finish_operation(event.label)
|
1031
|
+
self.unit_tests_manager.update_progress()
|
1032
|
+
self.unit_tests_manager.update_stats(event.status)
|
408
1033
|
if event.status == Status.SKIP and event.skip_reason is not None:
|
409
1034
|
self.skip_reasons.append(event.skip_reason)
|
410
|
-
self._display_execution_result(event.status)
|
411
1035
|
self._check_warnings(event)
|
412
|
-
if self.workers_num == 1:
|
413
|
-
self.display_percentage()
|
414
1036
|
elif (
|
415
1037
|
event.phase == PhaseName.STATEFUL_TESTING
|
416
1038
|
and not event.is_final
|
417
|
-
and event.status
|
418
|
-
and event.status is not None
|
1039
|
+
and event.status not in (Status.INTERRUPTED, Status.SKIP, None)
|
419
1040
|
):
|
420
|
-
self.
|
1041
|
+
assert self.stateful_tests_manager is not None
|
1042
|
+
links_seen = {case.transition.id for case in event.recorder.cases.values() if case.transition is not None}
|
1043
|
+
self.stateful_tests_manager.update(links_seen, event.status)
|
421
1044
|
|
422
1045
|
def _check_warnings(self, event: events.ScenarioFinished) -> None:
|
423
1046
|
for status_code in (401, 403):
|
424
1047
|
if has_too_many_responses_with_status(event.recorder.interactions.values(), status_code):
|
425
1048
|
self.warnings.missing_auth.setdefault(status_code, []).append(event.recorder.label)
|
426
1049
|
|
427
|
-
def
|
428
|
-
|
429
|
-
symbol, color = {
|
430
|
-
Status.SUCCESS: (".", "green"),
|
431
|
-
Status.FAILURE: ("F", "red"),
|
432
|
-
Status.ERROR: ("E", "red"),
|
433
|
-
Status.SKIP: ("S", "yellow"),
|
434
|
-
Status.INTERRUPTED: ("S", "yellow"),
|
435
|
-
}[status]
|
436
|
-
self.current_line_length += len(symbol)
|
437
|
-
click.secho(symbol, nl=False, fg=color)
|
438
|
-
|
439
|
-
def _on_interrupted(self) -> None:
|
440
|
-
click.echo()
|
441
|
-
display_section_name("KeyboardInterrupt", "!", bold=False)
|
442
|
-
click.echo()
|
1050
|
+
def _on_interrupted(self, event: events.Interrupted) -> None:
|
1051
|
+
from rich.padding import Padding
|
443
1052
|
|
444
|
-
|
445
|
-
|
446
|
-
|
447
|
-
|
448
|
-
|
449
|
-
|
450
|
-
|
451
|
-
|
452
|
-
|
453
|
-
traceback = format_exception(event.exception, with_traceback=True)
|
454
|
-
extras = split_traceback(traceback)
|
455
|
-
suggestion = (
|
456
|
-
f"Please consider reporting the traceback above to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
|
1053
|
+
if self.unit_tests_manager is not None:
|
1054
|
+
self.unit_tests_manager.interrupt()
|
1055
|
+
elif self.stateful_tests_manager is not None:
|
1056
|
+
self.stateful_tests_manager.interrupt()
|
1057
|
+
elif self.loading_manager is not None:
|
1058
|
+
self.loading_manager.interrupt()
|
1059
|
+
message = Padding(
|
1060
|
+
self.loading_manager.get_completion_message(),
|
1061
|
+
BLOCK_PADDING,
|
457
1062
|
)
|
458
|
-
|
1063
|
+
self.console.print(message)
|
1064
|
+
self.console.print()
|
1065
|
+
elif self.probing_manager is not None:
|
1066
|
+
self.probing_manager.interrupt()
|
1067
|
+
message = Padding(
|
1068
|
+
self.probing_manager.get_completion_message(),
|
1069
|
+
BLOCK_PADDING,
|
1070
|
+
)
|
1071
|
+
self.console.print(message)
|
1072
|
+
self.console.print()
|
1073
|
+
|
1074
|
+
def _on_fatal_error(self, ctx: ExecutionContext, event: events.FatalError) -> None:
|
1075
|
+
from rich.padding import Padding
|
1076
|
+
from rich.text import Text
|
1077
|
+
|
1078
|
+
self.shutdown(ctx)
|
1079
|
+
|
1080
|
+
if isinstance(event.exception, LoaderError):
|
1081
|
+
assert self.loading_manager is not None
|
1082
|
+
message = Padding(self.loading_manager.get_error_message(event.exception), BLOCK_PADDING)
|
1083
|
+
self.console.print(message)
|
1084
|
+
self.console.print()
|
1085
|
+
self.loading_manager = None
|
1086
|
+
|
1087
|
+
if event.exception.extras:
|
1088
|
+
for extra in event.exception.extras:
|
1089
|
+
self.console.print(Padding(Text(extra), (0, 0, 0, 5)))
|
1090
|
+
self.console.print()
|
1091
|
+
|
1092
|
+
if not (event.exception.kind == LoaderErrorKind.CONNECTION_OTHER and self.wait_for_schema is not None):
|
1093
|
+
suggestion = LOADER_ERROR_SUGGESTIONS.get(event.exception.kind)
|
1094
|
+
if suggestion is not None:
|
1095
|
+
click.echo(_style(f"{click.style('Tip:', bold=True, fg='green')} {suggestion}"))
|
1096
|
+
|
1097
|
+
raise click.Abort
|
1098
|
+
title = "Test Execution Error"
|
1099
|
+
message = DEFAULT_INTERNAL_ERROR_MESSAGE
|
1100
|
+
traceback = format_exception(event.exception, with_traceback=True)
|
1101
|
+
extras = split_traceback(traceback)
|
1102
|
+
suggestion = f"Please consider reporting the traceback above to our issue tracker:\n\n {ISSUE_TRACKER_URL}."
|
1103
|
+
click.echo(_style(title, fg="red", bold=True))
|
459
1104
|
click.echo()
|
460
|
-
click.
|
1105
|
+
click.echo(message)
|
461
1106
|
_display_extras(extras)
|
462
1107
|
if not (
|
463
1108
|
isinstance(event.exception, LoaderError)
|
464
1109
|
and event.exception.kind == LoaderErrorKind.CONNECTION_OTHER
|
465
1110
|
and self.wait_for_schema is not None
|
466
1111
|
):
|
467
|
-
|
1112
|
+
click.echo(_style(f"\n{click.style('Tip:', bold=True, fg='green')} {suggestion}"))
|
468
1113
|
|
469
1114
|
raise click.Abort
|
470
1115
|
|
@@ -472,29 +1117,33 @@ class OutputHandler(EventHandler):
|
|
472
1117
|
display_section_name("WARNINGS")
|
473
1118
|
total = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
|
474
1119
|
suffix = "" if total == 1 else "s"
|
475
|
-
click.
|
476
|
-
|
477
|
-
|
1120
|
+
click.echo(
|
1121
|
+
_style(
|
1122
|
+
f"\nMissing or invalid API credentials: {total} API operation{suffix} returned authentication errors\n",
|
1123
|
+
fg="yellow",
|
1124
|
+
)
|
478
1125
|
)
|
479
1126
|
|
480
1127
|
for status_code, operations in self.warnings.missing_auth.items():
|
481
1128
|
status_text = "Unauthorized" if status_code == 401 else "Forbidden"
|
482
1129
|
count = len(operations)
|
483
1130
|
suffix = "" if count == 1 else "s"
|
484
|
-
click.
|
485
|
-
|
486
|
-
|
1131
|
+
click.echo(
|
1132
|
+
_style(
|
1133
|
+
f"{status_code} {status_text} ({count} operation{suffix}):",
|
1134
|
+
fg="yellow",
|
1135
|
+
)
|
487
1136
|
)
|
488
1137
|
# Show first few API operations
|
489
1138
|
for endpoint in operations[:3]:
|
490
|
-
click.
|
1139
|
+
click.echo(_style(f" - {endpoint}", fg="yellow"))
|
491
1140
|
if len(operations) > 3:
|
492
|
-
click.
|
1141
|
+
click.echo(_style(f" + {len(operations) - 3} more", fg="yellow"))
|
493
1142
|
click.echo()
|
494
|
-
click.
|
495
|
-
click.
|
496
|
-
click.
|
497
|
-
click.
|
1143
|
+
click.echo(_style("Tip: ", bold=True, fg="yellow"), nl=False)
|
1144
|
+
click.echo(_style(f"Use {bold('--auth')} ", fg="yellow"), nl=False)
|
1145
|
+
click.echo(_style(f"or {bold('-H')} ", fg="yellow"), nl=False)
|
1146
|
+
click.echo(_style("to provide authentication credentials", fg="yellow"))
|
498
1147
|
click.echo()
|
499
1148
|
|
500
1149
|
def display_experiments(self) -> None:
|
@@ -502,26 +1151,30 @@ class OutputHandler(EventHandler):
|
|
502
1151
|
|
503
1152
|
click.echo()
|
504
1153
|
for experiment in sorted(GLOBAL_EXPERIMENTS.enabled, key=lambda e: e.name):
|
505
|
-
click.
|
506
|
-
click.
|
507
|
-
click.
|
1154
|
+
click.echo(_style(f"🧪 {experiment.name}: ", bold=True), nl=False)
|
1155
|
+
click.echo(_style(experiment.description))
|
1156
|
+
click.echo(_style(f" Feedback: {experiment.discussion_url}"))
|
508
1157
|
click.echo()
|
509
1158
|
|
510
|
-
click.
|
511
|
-
|
512
|
-
|
513
|
-
|
1159
|
+
click.echo(
|
1160
|
+
_style(
|
1161
|
+
"Your feedback is crucial for experimental features. "
|
1162
|
+
"Please visit the provided URL(s) to share your thoughts.",
|
1163
|
+
dim=True,
|
1164
|
+
)
|
514
1165
|
)
|
515
1166
|
click.echo()
|
516
1167
|
|
517
1168
|
def display_api_operations(self, ctx: ExecutionContext) -> None:
|
518
|
-
assert self.
|
519
|
-
click.
|
520
|
-
click.
|
521
|
-
|
522
|
-
|
1169
|
+
assert self.statistic is not None
|
1170
|
+
click.echo(_style("API Operations:", bold=True))
|
1171
|
+
click.echo(
|
1172
|
+
_style(
|
1173
|
+
f" Selected: {click.style(str(self.statistic.operations.selected), bold=True)}/"
|
1174
|
+
f"{click.style(str(self.statistic.operations.total), bold=True)}"
|
1175
|
+
)
|
523
1176
|
)
|
524
|
-
click.
|
1177
|
+
click.echo(_style(f" Tested: {click.style(str(len(ctx.statistic.tested_operations)), bold=True)}"))
|
525
1178
|
errors = len(
|
526
1179
|
{
|
527
1180
|
err.label
|
@@ -533,48 +1186,48 @@ class OutputHandler(EventHandler):
|
|
533
1186
|
}
|
534
1187
|
)
|
535
1188
|
if errors:
|
536
|
-
click.
|
1189
|
+
click.echo(_style(f" Errored: {click.style(str(errors), bold=True)}"))
|
537
1190
|
|
538
1191
|
# API operations that are skipped due to fail-fast are counted here as well
|
539
|
-
total_skips = self.
|
1192
|
+
total_skips = self.statistic.operations.selected - len(ctx.statistic.tested_operations) - errors
|
540
1193
|
if total_skips:
|
541
|
-
click.
|
1194
|
+
click.echo(_style(f" Skipped: {click.style(str(total_skips), bold=True)}"))
|
542
1195
|
for reason in sorted(set(self.skip_reasons)):
|
543
|
-
click.
|
1196
|
+
click.echo(_style(f" - {reason.rstrip('.')}"))
|
544
1197
|
click.echo()
|
545
1198
|
|
546
1199
|
def display_phases(self) -> None:
|
547
|
-
click.
|
1200
|
+
click.echo(_style("Test Phases:", bold=True))
|
548
1201
|
|
549
1202
|
for phase in PhaseName:
|
550
1203
|
status, skip_reason = self.phases[phase]
|
551
1204
|
|
552
1205
|
if status == Status.SKIP:
|
553
|
-
click.
|
1206
|
+
click.echo(_style(f" ⏭️ {phase.value}", fg="yellow"), nl=False)
|
554
1207
|
if skip_reason:
|
555
|
-
click.
|
1208
|
+
click.echo(_style(f" ({skip_reason.value})", fg="yellow"))
|
556
1209
|
else:
|
557
1210
|
click.echo()
|
558
1211
|
elif status == Status.SUCCESS:
|
559
|
-
click.
|
1212
|
+
click.echo(_style(f" ✅ {phase.value}", fg="green"))
|
560
1213
|
elif status == Status.FAILURE:
|
561
|
-
click.
|
1214
|
+
click.echo(_style(f" ❌ {phase.value}", fg="red"))
|
562
1215
|
elif status == Status.ERROR:
|
563
|
-
click.
|
1216
|
+
click.echo(_style(f" 🚫 {phase.value}", fg="red"))
|
564
1217
|
elif status == Status.INTERRUPTED:
|
565
|
-
click.
|
1218
|
+
click.echo(_style(f" ⚡ {phase.value}", fg="yellow"))
|
566
1219
|
click.echo()
|
567
1220
|
|
568
1221
|
def display_test_cases(self, ctx: ExecutionContext) -> None:
|
569
1222
|
if ctx.statistic.total_cases == 0:
|
570
|
-
click.
|
571
|
-
click.
|
1223
|
+
click.echo(_style("Test cases:", bold=True))
|
1224
|
+
click.echo(" No test cases were generated\n")
|
572
1225
|
return
|
573
1226
|
|
574
1227
|
unique_failures = sum(
|
575
1228
|
len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
|
576
1229
|
)
|
577
|
-
click.
|
1230
|
+
click.echo(_style("Test cases:", bold=True))
|
578
1231
|
|
579
1232
|
parts = [f" {click.style(str(ctx.statistic.total_cases), bold=True)} generated"]
|
580
1233
|
|
@@ -593,7 +1246,7 @@ class OutputHandler(EventHandler):
|
|
593
1246
|
if ctx.statistic.cases_without_checks > 0:
|
594
1247
|
parts.append(f"{click.style(str(ctx.statistic.cases_without_checks), bold=True)} skipped")
|
595
1248
|
|
596
|
-
click.
|
1249
|
+
click.echo(_style(", ".join(parts) + "\n"))
|
597
1250
|
|
598
1251
|
def display_failures_summary(self, ctx: ExecutionContext) -> None:
|
599
1252
|
# Collect all unique failures and their counts by title
|
@@ -604,14 +1257,14 @@ class OutputHandler(EventHandler):
|
|
604
1257
|
data = failure_counts.get(failure.title, (failure.severity, 0))
|
605
1258
|
failure_counts[failure.title] = (failure.severity, data[1] + 1)
|
606
1259
|
|
607
|
-
click.
|
1260
|
+
click.echo(_style("Failures:", bold=True))
|
608
1261
|
|
609
1262
|
# Sort by severity first, then by title
|
610
1263
|
sorted_failures = sorted(failure_counts.items(), key=lambda x: (x[1][0], x[0]))
|
611
1264
|
|
612
1265
|
for title, (_, count) in sorted_failures:
|
613
|
-
click.
|
614
|
-
click.
|
1266
|
+
click.echo(_style(f" ❌ {title}: "), nl=False)
|
1267
|
+
click.echo(_style(str(count), bold=True))
|
615
1268
|
click.echo()
|
616
1269
|
|
617
1270
|
def display_errors_summary(self) -> None:
|
@@ -621,11 +1274,11 @@ class OutputHandler(EventHandler):
|
|
621
1274
|
title = error.info.title
|
622
1275
|
error_counts[title] = error_counts.get(title, 0) + 1
|
623
1276
|
|
624
|
-
click.
|
1277
|
+
click.echo(_style("Errors:", bold=True))
|
625
1278
|
|
626
1279
|
for title in sorted(error_counts):
|
627
|
-
click.
|
628
|
-
click.
|
1280
|
+
click.echo(_style(f" 🚫 {title}: "), nl=False)
|
1281
|
+
click.echo(_style(str(error_counts[title]), bold=True))
|
629
1282
|
click.echo()
|
630
1283
|
|
631
1284
|
def display_final_line(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
|
@@ -635,14 +1288,17 @@ class OutputHandler(EventHandler):
|
|
635
1288
|
len(group.failures) for grouped in ctx.statistic.failures.values() for group in grouped.values()
|
636
1289
|
)
|
637
1290
|
if unique_failures:
|
638
|
-
|
1291
|
+
suffix = "s" if unique_failures > 1 else ""
|
1292
|
+
parts.append(f"{unique_failures} failure{suffix}")
|
639
1293
|
|
640
1294
|
if self.errors:
|
641
|
-
|
1295
|
+
suffix = "s" if len(self.errors) > 1 else ""
|
1296
|
+
parts.append(f"{len(self.errors)} error{suffix}")
|
642
1297
|
|
643
1298
|
total_warnings = sum(len(endpoints) for endpoints in self.warnings.missing_auth.values())
|
644
1299
|
if total_warnings:
|
645
|
-
|
1300
|
+
suffix = "s" if total_warnings > 1 else ""
|
1301
|
+
parts.append(f"{total_warnings} warning{suffix}")
|
646
1302
|
|
647
1303
|
if parts:
|
648
1304
|
message = f"{', '.join(parts)} in {event.running_time:.2f}s"
|
@@ -665,21 +1321,35 @@ class OutputHandler(EventHandler):
|
|
665
1321
|
reports.append(("JUnit XML", self.junit_xml_file))
|
666
1322
|
|
667
1323
|
if reports:
|
668
|
-
click.
|
1324
|
+
click.echo(_style("Reports:", bold=True))
|
669
1325
|
for report_type, path in reports:
|
670
|
-
click.
|
1326
|
+
click.echo(_style(f" - {report_type}: {path}"))
|
671
1327
|
click.echo()
|
672
1328
|
|
1329
|
+
def display_seed(self) -> None:
|
1330
|
+
click.echo(_style("Seed: ", bold=True), nl=False)
|
1331
|
+
if self.seed is None:
|
1332
|
+
click.echo("not used in the deterministic mode")
|
1333
|
+
else:
|
1334
|
+
click.echo(str(self.seed))
|
1335
|
+
click.echo()
|
1336
|
+
|
673
1337
|
def _on_engine_finished(self, ctx: ExecutionContext, event: events.EngineFinished) -> None:
|
1338
|
+
assert self.loading_manager is None
|
1339
|
+
assert self.probing_manager is None
|
1340
|
+
assert self.unit_tests_manager is None
|
1341
|
+
assert self.stateful_tests_manager is None
|
674
1342
|
if self.errors:
|
675
1343
|
display_section_name("ERRORS")
|
676
1344
|
errors = sorted(self.errors, key=lambda r: (r.phase.value, r.label))
|
677
1345
|
for error in errors:
|
678
1346
|
display_section_name(error.label, "_", fg="red")
|
679
1347
|
click.echo(error.info.format(bold=lambda x: click.style(x, bold=True)))
|
680
|
-
click.
|
681
|
-
|
682
|
-
|
1348
|
+
click.echo(
|
1349
|
+
_style(
|
1350
|
+
f"\nNeed more help?\n Join our Discord server: {DISCORD_LINK}",
|
1351
|
+
fg="red",
|
1352
|
+
)
|
683
1353
|
)
|
684
1354
|
display_failures(ctx)
|
685
1355
|
if self.warnings.missing_auth:
|
@@ -689,7 +1359,7 @@ class OutputHandler(EventHandler):
|
|
689
1359
|
display_section_name("SUMMARY")
|
690
1360
|
click.echo()
|
691
1361
|
|
692
|
-
if self.
|
1362
|
+
if self.statistic:
|
693
1363
|
self.display_api_operations(ctx)
|
694
1364
|
|
695
1365
|
self.display_phases()
|
@@ -702,8 +1372,8 @@ class OutputHandler(EventHandler):
|
|
702
1372
|
|
703
1373
|
if self.warnings.missing_auth:
|
704
1374
|
affected = sum(len(operations) for operations in self.warnings.missing_auth.values())
|
705
|
-
click.
|
706
|
-
click.
|
1375
|
+
click.echo(_style("Warnings:", bold=True))
|
1376
|
+
click.echo(_style(f" ⚠️ Missing authentication: {bold(str(affected))}", fg="yellow"))
|
707
1377
|
click.echo()
|
708
1378
|
|
709
1379
|
if ctx.summary_lines:
|
@@ -712,20 +1382,9 @@ class OutputHandler(EventHandler):
|
|
712
1382
|
|
713
1383
|
self.display_test_cases(ctx)
|
714
1384
|
self.display_reports()
|
1385
|
+
self.display_seed()
|
715
1386
|
self.display_final_line(ctx, event)
|
716
1387
|
|
717
|
-
def display_percentage(self) -> None:
|
718
|
-
"""Add the current progress in % to the right side of the current line."""
|
719
|
-
assert self.operations_count is not None
|
720
|
-
selected = self.operations_count.selected
|
721
|
-
current_percentage = get_percentage(self.operations_processed, selected)
|
722
|
-
styled = click.style(current_percentage, fg="cyan")
|
723
|
-
# Total length of the message, so it will fill to the right border of the terminal.
|
724
|
-
# Padding is already taken into account in `ctx.current_line_length`
|
725
|
-
length = max(get_terminal_width() - self.current_line_length + len(styled) - len(current_percentage), 1)
|
726
|
-
template = f"{{:>{length}}}"
|
727
|
-
click.echo(template.format(styled))
|
728
|
-
|
729
1388
|
|
730
1389
|
TOO_MANY_RESPONSES_WARNING_TEMPLATE = (
|
731
1390
|
"Most of the responses from {} have a {} status code. Did you specify proper API credentials?"
|