plating 0.0.0.dev0__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.
plating/test_runner.py ADDED
@@ -0,0 +1,1005 @@
1
+ #
2
+ # plating/test_runner.py
3
+ #
4
+ """Test runner for plating example files."""
5
+
6
+ from datetime import datetime
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+
14
+ from rich.console import Console
15
+ from rich.table import Table
16
+
17
+ from typing import Any
18
+
19
+ from provide.foundation import logger, pout, perr
20
+ from provide.foundation.process import run_command, ProcessError
21
+
22
+ from plating.config import get_config
23
+ from plating.plating import PlatingBundle, PlatingDiscovery
24
+
25
+ console = Console()
26
+
27
+ # Cache terraform version to avoid repeated subprocess calls
28
+ _terraform_version_cache = None
29
+
30
+
31
+ def _get_terraform_version() -> tuple[str, str]:
32
+ """Get the Terraform/OpenTofu binary and version being used.
33
+
34
+ Returns:
35
+ Tuple of (binary_name, version_string)
36
+ """
37
+ global _terraform_version_cache
38
+
39
+ # Return cached version if available
40
+ if _terraform_version_cache is not None:
41
+ return _terraform_version_cache
42
+
43
+ # Get binary from config
44
+ config = get_config()
45
+ tf_binary = config.terraform_binary
46
+ binary_name = "OpenTofu" if "tofu" in tf_binary else "Terraform"
47
+
48
+ try:
49
+ result = run_command(
50
+ [tf_binary, "-version"], capture_output=True, timeout=5
51
+ )
52
+ version_lines = result.stdout.strip().split("\n")
53
+ if version_lines:
54
+ version_string = version_lines[0]
55
+ else:
56
+ version_string = "Unknown version"
57
+ except ProcessError:
58
+ version_string = "Unable to determine version"
59
+
60
+ _terraform_version_cache = (binary_name, version_string)
61
+ return _terraform_version_cache
62
+
63
+
64
+ def prepare_test_suites_for_stir(
65
+ bundles: list[PlatingBundle], output_dir: Path
66
+ ) -> list[Path]:
67
+ """Prepare test suites from plating bundles for stir execution.
68
+
69
+ Args:
70
+ bundles: List of plating bundles to prepare
71
+ output_dir: Directory to create test suites in
72
+
73
+ Returns:
74
+ List of paths to created test suite directories
75
+ """
76
+ output_dir.mkdir(parents=True, exist_ok=True)
77
+ test_suites = []
78
+
79
+ for bundle in bundles:
80
+ examples = bundle.load_examples()
81
+ if not examples:
82
+ continue
83
+
84
+ suite_dir = _create_test_suite(bundle, examples, output_dir)
85
+ if suite_dir:
86
+ test_suites.append(suite_dir)
87
+
88
+ return test_suites
89
+
90
+
91
+ def run_tests_with_stir(test_dir: Path, parallel: int = 4) -> dict[str, Any]:
92
+ """Run tests using tofusoup stir command.
93
+
94
+ Args:
95
+ test_dir: Directory containing test suites
96
+ parallel: Number of parallel tests to run
97
+
98
+ Returns:
99
+ Dictionary with test results from stir
100
+ """
101
+ import json
102
+ import subprocess
103
+
104
+ # Check if soup command is available
105
+ soup_cmd = shutil.which("soup")
106
+ if not soup_cmd:
107
+ raise RuntimeError(
108
+ "tofusoup is not installed or not in PATH. "
109
+ "Please install tofusoup to use the test command."
110
+ )
111
+
112
+ # Build stir command - ensure absolute path
113
+ test_dir_abs = test_dir.resolve()
114
+ cmd = ["soup", "stir", str(test_dir_abs), "--json"]
115
+
116
+ # Run stir with plugin cache to avoid re-downloading providers
117
+ env = os.environ.copy()
118
+
119
+ # Set up environment from config
120
+ config = get_config()
121
+ env = config.get_terraform_env()
122
+
123
+ # Find a directory with pyproject.toml to run from
124
+ # First, check current directory
125
+ cwd = Path.cwd()
126
+ run_dir = cwd
127
+
128
+ # Look for pyproject.toml in current or parent directories
129
+ if not (run_dir / "pyproject.toml").exists():
130
+ # Try looking up the directory tree
131
+ for parent in cwd.parents:
132
+ if (parent / "pyproject.toml").exists():
133
+ run_dir = parent
134
+ break
135
+ else:
136
+ # If not found, check if tofusoup is in a known location
137
+ tofusoup_dir = Path.home() / "code" / "gh" / "provide-io" / "tofusoup"
138
+ if tofusoup_dir.exists() and (tofusoup_dir / "pyproject.toml").exists():
139
+ run_dir = tofusoup_dir
140
+ else:
141
+ # Fallback: run from test directory (will likely fail but allows graceful fallback)
142
+ run_dir = test_dir
143
+
144
+ try:
145
+ result = run_command(
146
+ cmd,
147
+ capture_output=True,
148
+ env=env,
149
+ cwd=str(run_dir), # Run from directory with pyproject.toml
150
+ )
151
+ except FileNotFoundError as e:
152
+ # Handle case where command is not found
153
+ raise RuntimeError(
154
+ f"TofuSoup not found or not installed. Please install tofusoup to use stir testing. "
155
+ f"Error: {e}"
156
+ ) from e
157
+ except ProcessError as e:
158
+ # Check if this is the pyproject.toml error
159
+ error_msg = str(e)
160
+ if hasattr(e, 'stderr') and e.stderr:
161
+ error_msg += f" {e.stderr}"
162
+ if hasattr(e, 'stdout') and e.stdout:
163
+ error_msg += f" {e.stdout}"
164
+ if "pyproject.toml" in error_msg:
165
+ # This is a known issue with soup tool install - raise RuntimeError to trigger fallback
166
+ raise RuntimeError(
167
+ "soup stir requires pyproject.toml context. "
168
+ "Falling back to simple runner."
169
+ ) from e
170
+
171
+ # Check if this is a command not found error
172
+ if any(phrase in error_msg.lower() for phrase in ["not found", "no such file", "command not found"]):
173
+ raise RuntimeError(
174
+ f"TofuSoup not found or not installed. Please install tofusoup to use stir testing. "
175
+ f"Error: {e}"
176
+ ) from e
177
+
178
+ # For other process errors, log and re-raise
179
+ logger.error("TofuSoup stir execution failed", error=str(e))
180
+ raise RuntimeError(f"Failed to run tofusoup stir: {e}") from e
181
+
182
+ # Parse JSON output
183
+ if result.stdout:
184
+ return json.loads(result.stdout)
185
+ else:
186
+ return {"total": 0, "passed": 0, "failed": 0, "test_details": {}}
187
+
188
+
189
+ def parse_stir_results(
190
+ stir_output: dict[str, any], bundles: list[PlatingBundle] = None
191
+ ) -> dict[str, Any]:
192
+ """Parse and enrich stir results with plating bundle information.
193
+
194
+ Args:
195
+ stir_output: Raw output from stir command
196
+ bundles: Optional list of plating bundles for enrichment
197
+
198
+ Returns:
199
+ Dictionary with plating-formatted test results
200
+ """
201
+ # Start with stir results, ensuring required keys exist
202
+ results = dict(stir_output)
203
+
204
+ # Ensure essential keys exist with defaults
205
+ results.setdefault("total", 0)
206
+ results.setdefault("passed", 0)
207
+ results.setdefault("failed", 0)
208
+ results.setdefault("test_details", {})
209
+
210
+ # Add bundle information if provided
211
+ if bundles:
212
+ results["bundles"] = {}
213
+ for bundle in bundles:
214
+ fixture_count = 0
215
+ if hasattr(bundle.fixtures_dir, "exists") and bundle.fixtures_dir.exists():
216
+ try:
217
+ fixture_count = sum(
218
+ 1 for _ in bundle.fixtures_dir.rglob("*") if _.is_file()
219
+ )
220
+ except (AttributeError, TypeError):
221
+ # Handle mock objects in tests
222
+ fixture_count = 0
223
+
224
+ try:
225
+ examples = bundle.load_examples()
226
+ examples_count = len(examples) if examples else 0
227
+ except (AttributeError, TypeError):
228
+ examples_count = 0
229
+
230
+ try:
231
+ has_fixtures = (
232
+ hasattr(bundle.fixtures_dir, "exists")
233
+ and bundle.fixtures_dir.exists()
234
+ )
235
+ except (AttributeError, TypeError):
236
+ has_fixtures = False
237
+
238
+ results["bundles"][bundle.name] = {
239
+ "component_type": bundle.component_type,
240
+ "examples_count": examples_count,
241
+ "has_fixtures": has_fixtures,
242
+ "fixture_count": fixture_count,
243
+ }
244
+
245
+ # Ensure timestamp is present
246
+ if "timestamp" not in results:
247
+ results["timestamp"] = datetime.now().isoformat()
248
+
249
+ return results
250
+
251
+
252
+ class PlatingTestAdapter:
253
+ """Adapter to run plating tests using tofusoup stir."""
254
+
255
+ def __init__(self, output_dir: Path = None, fallback_to_simple: bool = False):
256
+ """Initialize the test adapter.
257
+
258
+ Args:
259
+ output_dir: Directory for test suites (temp if not specified)
260
+ fallback_to_simple: Whether to fall back to simple runner if stir unavailable
261
+ """
262
+ self.output_dir = output_dir
263
+ self.fallback_to_simple = fallback_to_simple
264
+ self._temp_dir = None
265
+
266
+ def run_tests(
267
+ self,
268
+ component_types: list[str] = None,
269
+ parallel: int = 4,
270
+ output_file: Path = None,
271
+ output_format: str = "json",
272
+ ) -> dict[str, Any]:
273
+ """Run plating tests using stir.
274
+
275
+ Args:
276
+ component_types: Optional list of component types to filter
277
+ parallel: Number of parallel tests
278
+ output_file: Optional file to write report to
279
+ output_format: Format for report (json, markdown, html)
280
+
281
+ Returns:
282
+ Dictionary with test results
283
+ """
284
+ try:
285
+ # Setup output directory
286
+ if self.output_dir is None:
287
+ self._temp_dir = Path(tempfile.mkdtemp(prefix="plating-tests-"))
288
+ self.output_dir = self._temp_dir
289
+ else:
290
+ self.output_dir.mkdir(parents=True, exist_ok=True)
291
+
292
+ # Discover bundles
293
+ bundles = self._discover_bundles(component_types)
294
+
295
+ if not bundles:
296
+ return {
297
+ "total": 0,
298
+ "passed": 0,
299
+ "failed": 0,
300
+ "warnings": 0,
301
+ "skipped": 0,
302
+ "failures": {},
303
+ "test_details": {},
304
+ "timestamp": datetime.now().isoformat(),
305
+ }
306
+
307
+ # Prepare test suites
308
+ test_suites = self._prepare_test_suites(bundles)
309
+
310
+ if not test_suites:
311
+ console.print(
312
+ "[yellow]No test suites created (no components with examples found)[/yellow]"
313
+ )
314
+ return {
315
+ "total": 0,
316
+ "passed": 0,
317
+ "failed": 0,
318
+ "failures": {},
319
+ }
320
+
321
+ # Try to run with stir
322
+ try:
323
+ stir_results = run_tests_with_stir(self.output_dir, parallel)
324
+ results = parse_stir_results(stir_results, bundles)
325
+
326
+ except (RuntimeError, FileNotFoundError):
327
+ if self.fallback_to_simple:
328
+ console.print(
329
+ "[yellow]tofusoup not available, falling back to simple runner[/yellow]"
330
+ )
331
+ results = _run_simple_tests(self.output_dir)
332
+ results = parse_stir_results(results, bundles)
333
+ else:
334
+ raise
335
+
336
+ # Generate report if requested
337
+ if output_file:
338
+ _generate_report(results, output_file, output_format)
339
+
340
+ return results
341
+
342
+ finally:
343
+ # Cleanup temp directory
344
+ if self._temp_dir and self._temp_dir.exists():
345
+ shutil.rmtree(self._temp_dir, ignore_errors=True)
346
+
347
+ def _discover_bundles(
348
+ self, component_types: list[str] = None
349
+ ) -> list[PlatingBundle]:
350
+ """Discover plating bundles."""
351
+ discovery = PlatingDiscovery()
352
+
353
+ if component_types:
354
+ # Collect all bundles for specified types without duplicates
355
+ seen = set()
356
+ bundles = []
357
+ for ct in component_types:
358
+ for bundle in discovery.discover_bundles(component_type=ct):
359
+ if bundle.name not in seen:
360
+ bundles.append(bundle)
361
+ seen.add(bundle.name)
362
+ else:
363
+ bundles = discovery.discover_bundles()
364
+
365
+ console.print(
366
+ f"Found [bold green]{len(bundles)}[/bold green] components with plating bundles"
367
+ )
368
+ return bundles
369
+
370
+ def _prepare_test_suites(self, bundles: list[PlatingBundle]) -> list[Path]:
371
+ """Prepare test suites for stir execution."""
372
+ console.print(
373
+ f"\n[bold yellow]📦 Assembling test suites in:[/bold yellow] {self.output_dir}"
374
+ )
375
+
376
+ test_suites = prepare_test_suites_for_stir(bundles, self.output_dir)
377
+
378
+ # Show summary table
379
+ if test_suites:
380
+ table = Table(title="Test Suite Assembly", box=None)
381
+ table.add_column("Component", style="cyan", no_wrap=True)
382
+ table.add_column("Type", style="magenta")
383
+ table.add_column("Test Directory", style="yellow")
384
+
385
+ for suite_dir in test_suites:
386
+ # Parse suite name to get component info
387
+ parts = suite_dir.name.rsplit("_test", 1)[0].split("_", 1)
388
+ comp_type = parts[0]
389
+ comp_name = parts[1] if len(parts) > 1 else "unknown"
390
+
391
+ table.add_row(comp_name, comp_type, suite_dir.name)
392
+
393
+ console.print(table)
394
+
395
+ return test_suites
396
+
397
+
398
+ def run_plating_tests(
399
+ component_types: list[str] | None = None,
400
+ parallel: int = 4,
401
+ output_dir: Path | None = None,
402
+ output_file: Path | None = None,
403
+ output_format: str = "json",
404
+ ) -> dict[str, Any]:
405
+ """Run all plating example files as Terraform tests.
406
+
407
+ This is a compatibility wrapper that uses PlatingTestAdapter.
408
+
409
+ Args:
410
+ component_types: Optional list of component types to filter by
411
+ parallel: Number of tests to run in parallel
412
+ output_dir: Directory to create test suites in
413
+ output_file: Optional file to write report to
414
+ output_format: Format for report (json, markdown, html)
415
+
416
+ Returns:
417
+ Dictionary with test results including:
418
+ - total: Total number of tests
419
+ - passed: Number of passed tests
420
+ - failed: Number of failed tests
421
+ - failures: Dict mapping test names to error messages
422
+ """
423
+ # Use the new adapter
424
+ adapter = PlatingTestAdapter(output_dir=output_dir, fallback_to_simple=True)
425
+ return adapter.run_tests(
426
+ component_types=component_types,
427
+ parallel=parallel,
428
+ output_file=output_file,
429
+ output_format=output_format,
430
+ )
431
+
432
+
433
+ def _create_test_suite(
434
+ bundle: PlatingBundle, examples: dict[str, str], output_dir: Path
435
+ ) -> Path | None:
436
+ """Create a test suite directory for a plating bundle.
437
+
438
+ Args:
439
+ bundle: The plating bundle
440
+ examples: Dictionary of example files
441
+ output_dir: Base output directory
442
+
443
+ Returns:
444
+ Path to the created test suite directory, or None if creation failed
445
+ """
446
+ # Create directory name based on component type and name
447
+ suite_name = f"{bundle.component_type}_{bundle.name}_test"
448
+ suite_dir = output_dir / suite_name
449
+
450
+ try:
451
+ suite_dir.mkdir(parents=True, exist_ok=True)
452
+
453
+ # Track all files being created to detect collisions
454
+ created_files = set()
455
+
456
+ # Generate provider.tf
457
+ provider_content = _generate_provider_tf()
458
+ (suite_dir / "provider.tf").write_text(provider_content)
459
+ created_files.add("provider.tf")
460
+
461
+ # First, copy fixture files to ../fixtures directory
462
+ fixtures = bundle.load_fixtures()
463
+ if fixtures:
464
+ # Create fixtures directory at parent level
465
+ fixtures_dir = suite_dir.parent / "fixtures"
466
+ fixtures_dir.mkdir(parents=True, exist_ok=True)
467
+
468
+ for fixture_path, content in fixtures.items():
469
+ fixture_file = fixtures_dir / fixture_path
470
+ fixture_file.parent.mkdir(parents=True, exist_ok=True)
471
+ fixture_file.write_text(content)
472
+
473
+ # Copy and rename example files
474
+ for example_name, content in examples.items():
475
+ # Create test-specific filename
476
+ if example_name == "example":
477
+ test_filename = f"{bundle.name}.tf"
478
+ else:
479
+ test_filename = f"{bundle.name}_{example_name}.tf"
480
+
481
+ if test_filename in created_files:
482
+ console.print(
483
+ f"[red]❌ Collision detected: example file '{test_filename}' conflicts with fixture file in {bundle.name}[/red]"
484
+ )
485
+ raise Exception(f"File collision: {test_filename}")
486
+
487
+ (suite_dir / test_filename).write_text(content)
488
+ created_files.add(test_filename)
489
+
490
+ return suite_dir
491
+
492
+ except Exception as e:
493
+ console.print(
494
+ f"[red]⚠️ Failed to create test suite for {bundle.name}: {e}[/red]"
495
+ )
496
+ return None
497
+
498
+
499
+ def _generate_provider_tf() -> str:
500
+ """Generate a standard provider.tf file for tests."""
501
+ return """terraform {
502
+ required_providers {
503
+ pyvider = {
504
+ source = "registry.terraform.io/provide-io/pyvider"
505
+ version = "0.0.3"
506
+ }
507
+ }
508
+ }
509
+
510
+ provider "pyvider" {
511
+ # Provider configuration for tests
512
+ }
513
+ """
514
+
515
+
516
+ def _run_simple_tests(test_dir: Path) -> dict[str, Any]:
517
+ """Run simple terraform tests without stir.
518
+
519
+ Note: This is a simplified version without parallel execution or rich UI.
520
+ For advanced test running with rich UI, use tofusoup.
521
+
522
+ Args:
523
+ test_dir: Directory containing test suites
524
+
525
+ Returns:
526
+ Dictionary with test results
527
+ """
528
+ import subprocess
529
+
530
+ results = {
531
+ "total": 0,
532
+ "passed": 0,
533
+ "failed": 0,
534
+ "warnings": 0,
535
+ "skipped": 0,
536
+ "failures": {},
537
+ "test_details": {},
538
+ "timestamp": datetime.now().isoformat(),
539
+ }
540
+
541
+ # Find all test directories
542
+ test_dirs = [d for d in test_dir.iterdir() if d.is_dir()]
543
+ results["total"] = len(test_dirs)
544
+
545
+ # Get terraform binary from config
546
+ config = get_config()
547
+ tf_binary = config.terraform_binary
548
+
549
+ for suite_dir in test_dirs:
550
+ test_name = suite_dir.name
551
+ console.print(f"Running test: {test_name}")
552
+
553
+ test_info = {
554
+ "name": test_name,
555
+ "success": False,
556
+ "skipped": False,
557
+ "duration": 0,
558
+ "resources": 0,
559
+ "data_sources": 0,
560
+ "functions": 0,
561
+ "outputs": 0,
562
+ "last_log": "",
563
+ "warnings": [],
564
+ }
565
+
566
+ start_time = datetime.now()
567
+
568
+ try:
569
+ # Run terraform init
570
+ try:
571
+ init_result = run_command(
572
+ [tf_binary, "init"],
573
+ cwd=suite_dir,
574
+ capture_output=True,
575
+ timeout=60,
576
+ env=config.get_terraform_env(),
577
+ )
578
+ except ProcessError as e:
579
+ logger.error("Terraform init failed", command=e.cmd, returncode=e.returncode,
580
+ stdout=e.stdout, stderr=e.stderr, suite=suite_dir.name)
581
+ raise
582
+
583
+ # Run terraform apply
584
+ try:
585
+ apply_result = run_command(
586
+ [tf_binary, "apply", "-auto-approve"],
587
+ cwd=suite_dir,
588
+ capture_output=True,
589
+ timeout=config.test_timeout,
590
+ env=config.get_terraform_env(),
591
+ )
592
+ except ProcessError as e:
593
+ logger.error("Terraform apply failed", command=e.cmd, returncode=e.returncode,
594
+ stdout=e.stdout, stderr=e.stderr, suite=suite_dir.name)
595
+ raise
596
+
597
+ # Parse output for resource counts
598
+ output = apply_result.stdout
599
+ if "Apply complete!" in output:
600
+ # Try to extract resource counts
601
+ import re
602
+
603
+ match = re.search(r"(\d+) added", output)
604
+ if match:
605
+ test_info["resources"] = int(match.group(1))
606
+
607
+ # Run terraform destroy
608
+ destroy_result = subprocess.run(
609
+ [tf_binary, "destroy", "-auto-approve"],
610
+ cwd=suite_dir,
611
+ capture_output=True,
612
+ text=True,
613
+ timeout=config.test_timeout,
614
+ env=config.get_terraform_env(),
615
+ )
616
+
617
+ if destroy_result.returncode != 0:
618
+ raise subprocess.CalledProcessError(
619
+ destroy_result.returncode,
620
+ destroy_result.args,
621
+ destroy_result.stdout,
622
+ destroy_result.stderr,
623
+ )
624
+
625
+ test_info["success"] = True
626
+ results["passed"] += 1
627
+ console.print(f" ✅ {test_name}: PASS")
628
+
629
+ except subprocess.CalledProcessError as e:
630
+ test_info["success"] = False
631
+ test_info["last_log"] = str(e.stderr if e.stderr else e.stdout)
632
+ results["failed"] += 1
633
+ results["failures"][test_name] = test_info["last_log"]
634
+ console.print(f" ❌ {test_name}: FAIL")
635
+
636
+ except subprocess.TimeoutExpired:
637
+ test_info["success"] = False
638
+ test_info["last_log"] = "Test timed out"
639
+ results["failed"] += 1
640
+ results["failures"][test_name] = "Test timed out"
641
+ console.print(f" ⏱️ {test_name}: TIMEOUT")
642
+
643
+ except Exception as e:
644
+ test_info["success"] = False
645
+ test_info["last_log"] = str(e)
646
+ results["failed"] += 1
647
+ results["failures"][test_name] = str(e)
648
+ console.print(f" ❌ {test_name}: ERROR")
649
+
650
+ end_time = datetime.now()
651
+ test_info["duration"] = (end_time - start_time).total_seconds()
652
+ results["test_details"][test_name] = test_info
653
+
654
+ return results
655
+
656
+
657
+ def _extract_warnings_from_log(log_file: Path) -> list[dict[str, str]]:
658
+ """Extract warning messages from a Terraform log file."""
659
+ warnings = []
660
+ try:
661
+ with open(log_file) as f:
662
+ for line in f:
663
+ try:
664
+ log_entry = json.loads(line)
665
+ if log_entry.get("@level") == "warn":
666
+ warnings.append(
667
+ {
668
+ "message": log_entry.get("@message", ""),
669
+ "timestamp": log_entry.get("@timestamp", ""),
670
+ }
671
+ )
672
+ except json.JSONDecodeError:
673
+ continue
674
+ except Exception as e:
675
+ console.print(f"[yellow]Warning: Failed to parse log file: {e}[/yellow]")
676
+ return warnings
677
+
678
+
679
+ def _generate_report(results: dict[str, any], output_file: Path, format: str) -> None:
680
+ """Generate a test report in the specified format."""
681
+ if format == "json":
682
+ _generate_json_report(results, output_file)
683
+ elif format == "markdown":
684
+ _generate_markdown_report(results, output_file)
685
+ elif format == "html":
686
+ _generate_html_report(results, output_file)
687
+
688
+
689
+ def _generate_json_report(results: dict[str, any], output_file: Path) -> None:
690
+ """Generate a JSON format test report."""
691
+ with open(output_file, "w") as f:
692
+ json.dump(results, f, indent=2, default=str)
693
+
694
+
695
+ def _generate_markdown_report(results: dict[str, any], output_file: Path) -> None:
696
+ """Generate a Markdown format test report."""
697
+ with open(output_file, "w") as f:
698
+ f.write("# Garnish Test Report\n\n")
699
+ f.write(f"Generated: {results['timestamp']}\n\n")
700
+ f.write(
701
+ f"**Terraform Version**: {results.get('terraform_version', 'Unknown')}\n\n"
702
+ )
703
+
704
+ # Summary
705
+ f.write("## Summary\n\n")
706
+ f.write(f"- **Total Tests**: {results['total']}\n")
707
+ f.write(f"- **Passed**: {results['passed']} ✅\n")
708
+ f.write(f"- **Failed**: {results['failed']} ❌\n")
709
+ f.write(f"- **Warnings**: {results.get('warnings', 0)} ⚠️\n")
710
+ f.write(f"- **Skipped**: {results.get('skipped', 0)}\n\n")
711
+
712
+ # Group tests by component type
713
+ tests_by_type = {}
714
+ bundles = results.get("bundles", {})
715
+ test_details = results.get("test_details", {})
716
+
717
+ for test_name, details in test_details.items():
718
+ # Determine component type from test name prefix
719
+ if test_name.startswith("function_"):
720
+ component_type = "function"
721
+ component_name = test_name.replace("function_", "").replace("_test", "")
722
+ elif test_name.startswith("resource_"):
723
+ component_type = "resource"
724
+ component_name = test_name.replace("resource_", "").replace("_test", "")
725
+ elif test_name.startswith("data_source_"):
726
+ component_type = "data_source"
727
+ component_name = test_name.replace("data_source_", "").replace(
728
+ "_test", ""
729
+ )
730
+ else:
731
+ component_type = "unknown"
732
+ component_name = test_name.replace("_test", "")
733
+
734
+ if component_type not in tests_by_type:
735
+ tests_by_type[component_type] = []
736
+
737
+ tests_by_type[component_type].append(
738
+ {
739
+ "name": component_name,
740
+ "test_name": test_name,
741
+ "details": details,
742
+ "bundle_info": bundles.get(component_name, {}),
743
+ }
744
+ )
745
+
746
+ # Write test results by component type
747
+ for comp_type in ["resource", "data_source", "function"]:
748
+ if comp_type in tests_by_type:
749
+ type_display = comp_type.replace("_", " ").title()
750
+ f.write(f"## {type_display} Tests\n\n")
751
+
752
+ # Determine which columns have data for this component type
753
+ has_resources = any(
754
+ test["details"].get("resources", 0) > 0
755
+ for test in tests_by_type[comp_type]
756
+ )
757
+ has_data_sources = any(
758
+ test["details"].get("data_sources", 0) > 0
759
+ for test in tests_by_type[comp_type]
760
+ )
761
+ has_functions = any(
762
+ test["details"].get("functions", 0) > 0
763
+ for test in tests_by_type[comp_type]
764
+ )
765
+ has_outputs = any(
766
+ test["details"].get("outputs", 0) > 0
767
+ for test in tests_by_type[comp_type]
768
+ )
769
+
770
+ # Build dynamic headers
771
+ headers = ["Component", "Status", "Duration"]
772
+ if has_resources:
773
+ headers.append("Resources")
774
+ if has_data_sources:
775
+ headers.append("Data Sources")
776
+ if has_functions:
777
+ headers.append("Functions")
778
+ if has_outputs:
779
+ headers.append("Outputs")
780
+ headers.extend(["Examples", "Fixtures"])
781
+
782
+ f.write("| " + " | ".join(headers) + " |\n")
783
+ f.write("|" + "|".join(["-" * (len(h) + 2) for h in headers]) + "|\n")
784
+
785
+ # Sort tests by name
786
+ tests_by_type[comp_type].sort(key=lambda x: x["name"])
787
+
788
+ for test in tests_by_type[comp_type]:
789
+ details = test["details"]
790
+ bundle_info = test["bundle_info"]
791
+
792
+ status_icon = (
793
+ "✅"
794
+ if details.get("success", False)
795
+ else "❌"
796
+ if not details.get("skipped", False)
797
+ else "⏭️"
798
+ )
799
+ duration = (
800
+ f"{details.get('duration', 0):.1f}s"
801
+ if details.get("duration", 0) > 0
802
+ else "-"
803
+ )
804
+
805
+ # Build row data
806
+ row = [test["name"], status_icon, duration]
807
+
808
+ if has_resources:
809
+ row.append(
810
+ str(details.get("resources", 0))
811
+ if details.get("resources", 0) > 0
812
+ else "-"
813
+ )
814
+ if has_data_sources:
815
+ row.append(
816
+ str(details.get("data_sources", 0))
817
+ if details.get("data_sources", 0) > 0
818
+ else "-"
819
+ )
820
+ if has_functions:
821
+ row.append(
822
+ str(details.get("functions", 0))
823
+ if details.get("functions", 0) > 0
824
+ else "-"
825
+ )
826
+ if has_outputs:
827
+ row.append(
828
+ str(details.get("outputs", 0))
829
+ if details.get("outputs", 0) > 0
830
+ else "-"
831
+ )
832
+
833
+ examples = bundle_info.get("examples_count", 1)
834
+ fixture_count = bundle_info.get("fixture_count", 0)
835
+ fixtures_display = str(fixture_count) if fixture_count > 0 else "-"
836
+
837
+ row.extend([str(examples), fixtures_display])
838
+
839
+ f.write("| " + " | ".join(row) + " |\n")
840
+
841
+ f.write("\n")
842
+
843
+ # Failed tests details
844
+ if results["failed"] > 0:
845
+ f.write("## Failed Test Details\n\n")
846
+ for test_name, error in results.get("failures", {}).items():
847
+ f.write(f"### ❌ {test_name}\n\n")
848
+ f.write(f"**Error**: {error}\n\n")
849
+
850
+ # Add more details if available
851
+ if test_name in test_details:
852
+ details = test_details[test_name]
853
+ if details.get("warnings"):
854
+ f.write(f"**Warnings** ({len(details['warnings'])}):\n")
855
+ for warning in details["warnings"]:
856
+ f.write(f"- {warning['message']}\n")
857
+ f.write("\n")
858
+
859
+ if details.get("last_log"):
860
+ f.write("**Last Log Entry**:\n")
861
+ f.write(f"```\n{details['last_log']}\n```\n\n")
862
+
863
+ # Tests with warnings
864
+ tests_with_warnings = [
865
+ (name, details)
866
+ for name, details in test_details.items()
867
+ if details.get("warnings") and len(details["warnings"]) > 0
868
+ ]
869
+
870
+ if tests_with_warnings:
871
+ f.write("## Tests with Warnings\n\n")
872
+ for test_name, details in tests_with_warnings:
873
+ f.write(f"### ⚠️ {test_name}\n\n")
874
+ for warning in details["warnings"]:
875
+ f.write(f"- {warning['message']}\n")
876
+ f.write("\n")
877
+
878
+
879
+ def _generate_html_report(results: dict[str, any], output_file: Path) -> None:
880
+ """Generate an HTML format test report."""
881
+ html_content = f"""
882
+ <!DOCTYPE html>
883
+ <html>
884
+ <head>
885
+ <title>Garnish Test Report</title>
886
+ <style>
887
+ body {{ font-family: Arial, sans-serif; margin: 20px; }}
888
+ .summary {{ background-color: #f0f0f0; padding: 15px; border-radius: 5px; }}
889
+ .passed {{ color: green; }}
890
+ .failed {{ color: red; }}
891
+ .warning {{ color: orange; }}
892
+ .test-details {{ margin-top: 20px; }}
893
+ .test-case {{ border: 1px solid #ddd; margin: 10px 0; padding: 10px; }}
894
+ .test-case.success {{ border-left: 5px solid green; }}
895
+ .test-case.failure {{ border-left: 5px solid red; }}
896
+ .test-case.skipped {{ border-left: 5px solid gray; }}
897
+ table {{ border-collapse: collapse; width: 100%; margin-top: 20px; }}
898
+ th, td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
899
+ th {{ background-color: #f2f2f2; }}
900
+ .warning-list {{ background-color: #fff8dc; padding: 10px; margin: 5px 0; }}
901
+ </style>
902
+ </head>
903
+ <body>
904
+ <h1>Garnish Test Report</h1>
905
+ <p>Generated: {results["timestamp"]}</p>
906
+ <p><strong>Terraform Version</strong>: {results.get("terraform_version", "Unknown")}</p>
907
+
908
+ <div class="summary">
909
+ <h2>Summary</h2>
910
+ <ul>
911
+ <li><strong>Total Tests</strong>: {results["total"]}</li>
912
+ <li class="passed"><strong>Passed</strong>: {results["passed"]} ✅</li>
913
+ <li class="failed"><strong>Failed</strong>: {results["failed"]} ❌</li>
914
+ <li class="warning"><strong>Warnings</strong>: {results["warnings"]} ⚠️</li>
915
+ <li><strong>Skipped</strong>: {results["skipped"]}</li>
916
+ </ul>
917
+ </div>
918
+
919
+ <div class="test-details">
920
+ <h2>Test Details</h2>
921
+ """
922
+
923
+ for test_name, details in results.get("test_details", {}).items():
924
+ status_class = (
925
+ "success"
926
+ if details["success"]
927
+ else "failure"
928
+ if not details["skipped"]
929
+ else "skipped"
930
+ )
931
+ status_icon = (
932
+ "✅" if details["success"] else "❌" if not details["skipped"] else "⏭️"
933
+ )
934
+
935
+ html_content += f"""
936
+ <div class="test-case {status_class}">
937
+ <h3>{status_icon} {test_name}</h3>
938
+ <table>
939
+ <tr><td><strong>Duration</strong></td><td>{details["duration"]:.2f}s</td></tr>
940
+ <tr><td><strong>Resources</strong></td><td>{details["resources"]}</td></tr>
941
+ <tr><td><strong>Data Sources</strong></td><td>{details["data_sources"]}</td></tr>
942
+ <tr><td><strong>Functions</strong></td><td>{details["functions"]}</td></tr>
943
+ <tr><td><strong>Outputs</strong></td><td>{details["outputs"]}</td></tr>
944
+ </table>
945
+ """
946
+
947
+ if details["warnings"]:
948
+ html_content += f"""
949
+ <div class="warning-list">
950
+ <h4>Warnings ({len(details["warnings"])})</h4>
951
+ <ul>
952
+ """
953
+ for warning in details["warnings"]:
954
+ html_content += f" <li>{warning['message']}</li>\n"
955
+ html_content += """ </ul>
956
+ </div>
957
+ """
958
+
959
+ if not details["success"] and not details["skipped"]:
960
+ html_content += f"""
961
+ <div style="background-color: #ffeeee; padding: 10px; margin-top: 10px;">
962
+ <h4>Error</h4>
963
+ <pre>{details["last_log"]}</pre>
964
+ </div>
965
+ """
966
+
967
+ html_content += " </div>\n"
968
+
969
+ # Bundle information table
970
+ html_content += """
971
+ <h2>Bundle Information</h2>
972
+ <table>
973
+ <tr>
974
+ <th>Component</th>
975
+ <th>Type</th>
976
+ <th>Examples</th>
977
+ <th>Has Fixtures</th>
978
+ </tr>
979
+ """
980
+
981
+ for bundle_name, bundle_info in results.get("bundles", {}).items():
982
+ has_fixtures = "✓" if bundle_info["has_fixtures"] else "✗"
983
+ html_content += f"""
984
+ <tr>
985
+ <td>{bundle_name}</td>
986
+ <td>{bundle_info["component_type"]}</td>
987
+ <td>{bundle_info["examples_count"]}</td>
988
+ <td>{has_fixtures}</td>
989
+ </tr>
990
+ """
991
+
992
+ html_content += """
993
+ </table>
994
+ </body>
995
+ </html>
996
+ """
997
+
998
+ with open(output_file, "w") as f:
999
+ f.write(html_content)
1000
+
1001
+
1002
+ # 🧪📦🎯
1003
+
1004
+
1005
+ # 🍲🥄🧪🪄