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/__init__.py +27 -0
- plating/_version.py +53 -0
- plating/adorner/__init__.py +16 -0
- plating/adorner/adorner.py +115 -0
- plating/adorner/api.py +24 -0
- plating/adorner/finder.py +22 -0
- plating/adorner/templates.py +199 -0
- plating/cli.py +317 -0
- plating/config.py +106 -0
- plating/error_handling.py +125 -0
- plating/errors.py +142 -0
- plating/generator.py +192 -0
- plating/linting.py +235 -0
- plating/models.py +75 -0
- plating/plater.py +487 -0
- plating/plating.py +207 -0
- plating/schema.py +502 -0
- plating/template_filters.py +117 -0
- plating/template_functions.py +216 -0
- plating/templates.py +232 -0
- plating/test_runner.py +1005 -0
- plating/types.py +71 -0
- plating-0.0.0.dev0.dist-info/METADATA +223 -0
- plating-0.0.0.dev0.dist-info/RECORD +28 -0
- plating-0.0.0.dev0.dist-info/WHEEL +5 -0
- plating-0.0.0.dev0.dist-info/entry_points.txt +2 -0
- plating-0.0.0.dev0.dist-info/licenses/LICENSE +201 -0
- plating-0.0.0.dev0.dist-info/top_level.txt +1 -0
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
|
+
# 🍲🥄🧪🪄
|