kctl-react 0.6.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. kctl_react/__init__.py +3 -0
  2. kctl_react/__main__.py +5 -0
  3. kctl_react/cli.py +201 -0
  4. kctl_react/commands/__init__.py +0 -0
  5. kctl_react/commands/a11y.py +78 -0
  6. kctl_react/commands/affected.py +170 -0
  7. kctl_react/commands/apps.py +353 -0
  8. kctl_react/commands/build.py +376 -0
  9. kctl_react/commands/bundle_cmd.py +217 -0
  10. kctl_react/commands/cap.py +1465 -0
  11. kctl_react/commands/clean.py +76 -0
  12. kctl_react/commands/codegen.py +491 -0
  13. kctl_react/commands/compliance.py +587 -0
  14. kctl_react/commands/config_cmd.py +368 -0
  15. kctl_react/commands/dashboard.py +163 -0
  16. kctl_react/commands/deploy.py +318 -0
  17. kctl_react/commands/deps.py +792 -0
  18. kctl_react/commands/dev.py +96 -0
  19. kctl_react/commands/docker_cmd.py +73 -0
  20. kctl_react/commands/doctor.py +170 -0
  21. kctl_react/commands/e2e.py +343 -0
  22. kctl_react/commands/env.py +155 -0
  23. kctl_react/commands/i18n.py +310 -0
  24. kctl_react/commands/lint.py +306 -0
  25. kctl_react/commands/maintenance.py +308 -0
  26. kctl_react/commands/monitor_cmd.py +50 -0
  27. kctl_react/commands/observe.py +34 -0
  28. kctl_react/commands/packages.py +129 -0
  29. kctl_react/commands/perf.py +762 -0
  30. kctl_react/commands/pipeline.py +289 -0
  31. kctl_react/commands/pwa.py +193 -0
  32. kctl_react/commands/scaffold.py +323 -0
  33. kctl_react/commands/security.py +660 -0
  34. kctl_react/commands/skill_cmd.py +54 -0
  35. kctl_react/commands/state.py +254 -0
  36. kctl_react/commands/test_cmd.py +418 -0
  37. kctl_react/commands/ui_audit.py +889 -0
  38. kctl_react/core/__init__.py +0 -0
  39. kctl_react/core/analyzers.py +200 -0
  40. kctl_react/core/callbacks.py +70 -0
  41. kctl_react/core/compliance/__init__.py +3 -0
  42. kctl_react/core/compliance/api_check/__init__.py +3 -0
  43. kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
  44. kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
  45. kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
  46. kctl_react/core/compliance/api_check/checks/naming.py +60 -0
  47. kctl_react/core/compliance/api_check/checks/params.py +44 -0
  48. kctl_react/core/compliance/api_check/checks/requests.py +57 -0
  49. kctl_react/core/compliance/api_check/checks/types.py +55 -0
  50. kctl_react/core/compliance/api_check/hooks.py +133 -0
  51. kctl_react/core/compliance/api_check/matcher.py +55 -0
  52. kctl_react/core/compliance/api_check/schema.py +151 -0
  53. kctl_react/core/compliance/api_health/__init__.py +35 -0
  54. kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
  55. kctl_react/core/compliance/api_health/checks/auth.py +72 -0
  56. kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
  57. kctl_react/core/compliance/api_health/checks/response.py +55 -0
  58. kctl_react/core/compliance/api_health/checks/timing.py +38 -0
  59. kctl_react/core/compliance/api_health/client.py +99 -0
  60. kctl_react/core/compliance/api_health/sampler.py +16 -0
  61. kctl_react/core/compliance/checks/__init__.py +47 -0
  62. kctl_react/core/compliance/checks/api.py +101 -0
  63. kctl_react/core/compliance/checks/codegen.py +94 -0
  64. kctl_react/core/compliance/checks/darkmode.py +57 -0
  65. kctl_react/core/compliance/checks/errors.py +68 -0
  66. kctl_react/core/compliance/checks/features.py +66 -0
  67. kctl_react/core/compliance/checks/i18n_check.py +105 -0
  68. kctl_react/core/compliance/checks/imports.py +86 -0
  69. kctl_react/core/compliance/checks/navigation.py +62 -0
  70. kctl_react/core/compliance/checks/practices.py +122 -0
  71. kctl_react/core/compliance/checks/providers.py +85 -0
  72. kctl_react/core/compliance/checks/pwa.py +101 -0
  73. kctl_react/core/compliance/checks/responsive.py +47 -0
  74. kctl_react/core/compliance/checks/scripts.py +85 -0
  75. kctl_react/core/compliance/checks/shadcn.py +51 -0
  76. kctl_react/core/compliance/checks/structure.py +76 -0
  77. kctl_react/core/compliance/checks/testing.py +83 -0
  78. kctl_react/core/compliance/checks/theme.py +92 -0
  79. kctl_react/core/compliance/checks/ui_standard.py +185 -0
  80. kctl_react/core/compliance/checks/vite.py +83 -0
  81. kctl_react/core/compliance/engine.py +87 -0
  82. kctl_react/core/compliance/exceptions_map.py +15 -0
  83. kctl_react/core/compliance/fixes/__init__.py +33 -0
  84. kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
  85. kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
  86. kctl_react/core/compliance/fixes/imports_fix.py +36 -0
  87. kctl_react/core/compliance/fixes/structure_fix.py +20 -0
  88. kctl_react/core/compliance/fixes/theme_fix.py +29 -0
  89. kctl_react/core/compliance/models.py +106 -0
  90. kctl_react/core/config.py +201 -0
  91. kctl_react/core/discovery.py +185 -0
  92. kctl_react/core/exceptions.py +17 -0
  93. kctl_react/core/git.py +146 -0
  94. kctl_react/core/history.py +121 -0
  95. kctl_react/core/output.py +5 -0
  96. kctl_react/core/plugins.py +13 -0
  97. kctl_react/core/runner.py +34 -0
  98. kctl_react/py.typed +0 -0
  99. kctl_react-0.6.2.dist-info/METADATA +17 -0
  100. kctl_react-0.6.2.dist-info/RECORD +102 -0
  101. kctl_react-0.6.2.dist-info/WHEEL +4 -0
  102. kctl_react-0.6.2.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,418 @@
1
+ """Test commands with deep Vitest integration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import json
7
+ import signal
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from kctl_react.core.callbacks import AppContext
15
+ from kctl_react.core.discovery import get_app_dir
16
+ from kctl_react.core.runner import run, run_turbo
17
+
18
+ app = typer.Typer(help="Run tests across the monorepo.")
19
+
20
+ _SUBCOMMANDS = {"count", "summary", "coverage", "naming", "threshold", "snapshots"}
21
+
22
+
23
+ def _parse_vitest_json(json_path: Path) -> dict | None:
24
+ """Parse vitest JSON reporter output."""
25
+ if not json_path.exists():
26
+ return None
27
+ try:
28
+ return json.loads(json_path.read_text())
29
+ except Exception:
30
+ return None
31
+
32
+
33
+ def _run_vitest_json(app_dir: Path, timeout: int = 300) -> dict | None:
34
+ """Run vitest with JSON reporter and return parsed results."""
35
+ json_output = app_dir / ".vitest-results.json"
36
+ json_output.unlink(missing_ok=True)
37
+
38
+ with contextlib.suppress(Exception):
39
+ run(
40
+ ["pnpm", "vitest", "run", "--reporter=json", f"--outputFile={json_output}"],
41
+ cwd=app_dir,
42
+ capture=True,
43
+ timeout=timeout,
44
+ )
45
+
46
+ return _parse_vitest_json(json_output)
47
+
48
+
49
+ @app.callback(invoke_without_command=True)
50
+ def test(
51
+ ctx: typer.Context,
52
+ app_name: Annotated[str | None, typer.Option("--app", "-a", help="App name (omit for all apps)")] = None,
53
+ coverage_flag: Annotated[bool, typer.Option("--coverage", "-c", help="Run with coverage.")] = False,
54
+ watch: Annotated[bool, typer.Option("--watch", "-w", help="Run in watch mode.")] = False,
55
+ ) -> None:
56
+ """Run vitest for app(s).
57
+
58
+ Examples:
59
+ kctl-react test --app sfa # Test SFA
60
+ kctl-react test --coverage # All apps with coverage
61
+ kctl-react test --app sfa --watch # Watch mode for SFA
62
+ """
63
+ if ctx.invoked_subcommand is not None:
64
+ return
65
+
66
+ actx: AppContext = ctx.obj
67
+ out = actx.output
68
+ root = actx.project_root
69
+
70
+ if app_name:
71
+ actx.validate_app(app_name)
72
+
73
+ if watch and not app_name:
74
+ out.warn("--watch requires an app name (e.g. kctl-react test sfa --watch)")
75
+ raise typer.Exit(1) from None
76
+
77
+ task = "test:ci" if coverage_flag else "test"
78
+ target = app_name or "all apps"
79
+ out.info(f"Running tests for {target}{' (coverage)' if coverage_flag else ''}...")
80
+
81
+ if watch and app_name:
82
+ app_dir = get_app_dir(root, app_name)
83
+ try:
84
+ proc = subprocess.Popen(["pnpm", "vitest", "--watch"], cwd=app_dir)
85
+ proc.wait()
86
+ except KeyboardInterrupt:
87
+ proc.send_signal(signal.SIGINT)
88
+ proc.wait()
89
+ return
90
+
91
+ try:
92
+ run_turbo(task, root, filter_app=app_name, capture=False, timeout=600)
93
+ out.success(f"Tests passed: {target}")
94
+ except Exception as e:
95
+ out.error(f"Tests failed: {e}")
96
+ raise typer.Exit(1) from None
97
+
98
+
99
+ @app.command()
100
+ def count(ctx: typer.Context) -> None:
101
+ """Count test files per app."""
102
+ actx: AppContext = ctx.obj
103
+ out = actx.output
104
+ root = actx.project_root
105
+
106
+ rows: list[list[str]] = []
107
+ json_data: list[dict] = []
108
+ total = 0
109
+
110
+ for name in actx.app_names:
111
+ app_dir = get_app_dir(root, name) / "src"
112
+ if not app_dir.is_dir():
113
+ rows.append([name, "0", "[dim]no src[/dim]"])
114
+ continue
115
+
116
+ test_files = list(app_dir.rglob("*.test.ts")) + list(app_dir.rglob("*.test.tsx"))
117
+ file_count = len(test_files)
118
+ total += file_count
119
+
120
+ rows.append([name, str(file_count), "[green]OK[/green]" if file_count > 0 else "[yellow]no tests[/yellow]"])
121
+ json_data.append({"app": name, "test_files": file_count})
122
+
123
+ rows.append(["[bold]Total[/bold]", f"[bold]{total}[/bold]", ""])
124
+
125
+ out.table(
126
+ "Test Files",
127
+ [("App", "cyan"), ("Files", "green"), ("Status", "")],
128
+ rows,
129
+ data_for_json=json_data,
130
+ )
131
+
132
+
133
+ @app.command()
134
+ def summary(
135
+ ctx: typer.Context,
136
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
137
+ ) -> None:
138
+ """Run tests with JSON reporter and show parsed summary.
139
+
140
+ Shows pass/fail/skip counts per app by parsing vitest JSON output.
141
+ """
142
+ actx: AppContext = ctx.obj
143
+ out = actx.output
144
+ root = actx.project_root
145
+
146
+ if app_name:
147
+ actx.validate_app(app_name)
148
+
149
+ apps = [app_name] if app_name else actx.app_names
150
+
151
+ rows: list[list[str]] = []
152
+ json_data: list[dict] = []
153
+ total_passed = 0
154
+ total_failed = 0
155
+ total_skipped = 0
156
+
157
+ for name in apps:
158
+ app_dir = get_app_dir(root, name)
159
+ if not (app_dir / "src").is_dir():
160
+ continue
161
+
162
+ out.info(f"Testing {name}...")
163
+ results = _run_vitest_json(app_dir)
164
+
165
+ if results is None:
166
+ rows.append([name, "[dim]--[/dim]", "[dim]--[/dim]", "[dim]--[/dim]", "[yellow]no output[/yellow]"])
167
+ json_data.append({"app": name, "passed": 0, "failed": 0, "skipped": 0, "error": True})
168
+ continue
169
+
170
+ passed = results.get("numPassedTests", 0)
171
+ failed = results.get("numFailedTests", 0)
172
+ skipped = results.get("numPendingTests", 0)
173
+ total_passed += passed
174
+ total_failed += failed
175
+ total_skipped += skipped
176
+
177
+ status = "[green]PASS[/green]" if failed == 0 else "[red]FAIL[/red]"
178
+ rows.append(
179
+ [
180
+ name,
181
+ f"[green]{passed}[/green]",
182
+ f"[red]{failed}[/red]" if failed else "[dim]0[/dim]",
183
+ f"[yellow]{skipped}[/yellow]" if skipped else "[dim]0[/dim]",
184
+ status,
185
+ ]
186
+ )
187
+ json_data.append({"app": name, "passed": passed, "failed": failed, "skipped": skipped})
188
+
189
+ # Show failure details
190
+ if failed > 0:
191
+ for suite in results.get("testResults", []):
192
+ for test_case in suite.get("assertionResults", []):
193
+ if test_case.get("status") == "failed":
194
+ test_name = " > ".join(test_case.get("ancestorTitles", []) + [test_case.get("title", "")])
195
+ out.error(f" {name}: {test_name}")
196
+
197
+ rows.append(
198
+ [
199
+ "[bold]Total[/bold]",
200
+ f"[bold green]{total_passed}[/bold green]",
201
+ f"[bold red]{total_failed}[/bold red]" if total_failed else "[bold dim]0[/bold dim]",
202
+ f"[bold yellow]{total_skipped}[/bold yellow]" if total_skipped else "[bold dim]0[/bold dim]",
203
+ "",
204
+ ]
205
+ )
206
+
207
+ out.table(
208
+ "Test Summary",
209
+ [("App", "cyan"), ("Passed", "green"), ("Failed", "red"), ("Skipped", "yellow"), ("Status", "")],
210
+ rows,
211
+ data_for_json=json_data,
212
+ )
213
+
214
+
215
+ @app.command()
216
+ def coverage(ctx: typer.Context) -> None:
217
+ """Show aggregated test coverage across all apps.
218
+
219
+ Reads coverage-summary.json from each app's coverage/ directory.
220
+ Run `kctl-react test --coverage` first to generate coverage data.
221
+ """
222
+ actx: AppContext = ctx.obj
223
+ out = actx.output
224
+ root = actx.project_root
225
+
226
+ rows: list[list[str]] = []
227
+ json_data: list[dict] = []
228
+
229
+ for name in actx.app_names:
230
+ cov_file = get_app_dir(root, name) / "coverage" / "coverage-summary.json"
231
+ if not cov_file.exists():
232
+ rows.append(
233
+ [name, "[dim]--[/dim]", "[dim]--[/dim]", "[dim]--[/dim]", "[dim]--[/dim]", "[dim]no data[/dim]"]
234
+ )
235
+ json_data.append({"app": name, "lines": None, "branches": None, "functions": None, "statements": None})
236
+ continue
237
+
238
+ try:
239
+ data = json.loads(cov_file.read_text())
240
+ total = data.get("total", {})
241
+ lines = total.get("lines", {}).get("pct", 0)
242
+ branches = total.get("branches", {}).get("pct", 0)
243
+ functions = total.get("functions", {}).get("pct", 0)
244
+ statements = total.get("statements", {}).get("pct", 0)
245
+
246
+ def _color_pct(pct: float) -> str:
247
+ if pct >= 80:
248
+ return f"[green]{pct:.1f}%[/green]"
249
+ elif pct >= 60:
250
+ return f"[yellow]{pct:.1f}%[/yellow]"
251
+ return f"[red]{pct:.1f}%[/red]"
252
+
253
+ rows.append(
254
+ [name, _color_pct(lines), _color_pct(branches), _color_pct(functions), _color_pct(statements), ""]
255
+ )
256
+ json_data.append(
257
+ {"app": name, "lines": lines, "branches": branches, "functions": functions, "statements": statements}
258
+ )
259
+ except Exception:
260
+ rows.append([name, "[red]error[/red]", "", "", "", ""])
261
+ json_data.append({"app": name, "error": True})
262
+
263
+ out.table(
264
+ "Test Coverage",
265
+ [("App", "cyan"), ("Lines", ""), ("Branches", ""), ("Functions", ""), ("Stmts", ""), ("", "")],
266
+ rows,
267
+ data_for_json=json_data,
268
+ )
269
+ out.info("Run `kctl-react test --coverage` first to generate coverage data")
270
+
271
+
272
+ @app.command()
273
+ def naming(ctx: typer.Context) -> None:
274
+ """Check test file naming conventions across all apps.
275
+
276
+ Flags .spec.* files as wrong convention — all tests should use .test.* suffix.
277
+ """
278
+ actx: AppContext = ctx.obj
279
+ out = actx.output
280
+ root = actx.project_root
281
+
282
+ rows: list[list[str]] = []
283
+ json_data: list[dict] = []
284
+ issues: list[str] = []
285
+
286
+ for name in actx.app_names:
287
+ src_dir = get_app_dir(root, name) / "src"
288
+ if not src_dir.is_dir():
289
+ rows.append([name, "[dim]--[/dim]", "[dim]--[/dim]", "[dim]no src[/dim]"])
290
+ json_data.append({"app": name, "test_count": 0, "spec_count": 0})
291
+ continue
292
+
293
+ test_files = list(src_dir.rglob("*.test.ts")) + list(src_dir.rglob("*.test.tsx"))
294
+ spec_files = list(src_dir.rglob("*.spec.ts")) + list(src_dir.rglob("*.spec.tsx"))
295
+
296
+ test_count = len(test_files)
297
+ spec_count = len(spec_files)
298
+
299
+ for sf in spec_files:
300
+ issues.append(f" {name}: {sf.relative_to(root)} (rename to .test{sf.suffix})")
301
+
302
+ status = "[green]OK[/green]" if spec_count == 0 else f"[red]{spec_count} wrong[/red]"
303
+ rows.append([name, str(test_count), str(spec_count), status])
304
+ json_data.append({"app": name, "test_count": test_count, "spec_count": spec_count})
305
+
306
+ out.table(
307
+ "Test File Naming Conventions",
308
+ [("App", "cyan"), (".test.*", "green"), (".spec.*", "red"), ("Status", "")],
309
+ rows,
310
+ data_for_json=json_data,
311
+ )
312
+
313
+ if issues:
314
+ out.warn("Wrong convention (.spec should be .test):")
315
+ for issue in issues:
316
+ out.info(issue)
317
+ else:
318
+ out.success("All test files follow .test.* naming convention")
319
+
320
+
321
+ @app.command()
322
+ def threshold(
323
+ ctx: typer.Context,
324
+ min_pct: Annotated[int, typer.Option("--min", help="Minimum line coverage percentage.")] = 70,
325
+ ) -> None:
326
+ """Enforce minimum coverage threshold across all apps.
327
+
328
+ Reads coverage-summary.json from each app's coverage/ directory.
329
+ Exits 1 if any app is below the threshold.
330
+ """
331
+ actx: AppContext = ctx.obj
332
+ out = actx.output
333
+ root = actx.project_root
334
+
335
+ rows: list[list[str]] = []
336
+ json_data: list[dict] = []
337
+ any_fail = False
338
+
339
+ for name in actx.app_names:
340
+ cov_file = get_app_dir(root, name) / "coverage" / "coverage-summary.json"
341
+ if not cov_file.exists():
342
+ rows.append([name, "[dim]no data[/dim]", f"{min_pct}%", "[dim]SKIP[/dim]"])
343
+ json_data.append({"app": name, "lines_pct": None, "min_pct": min_pct, "status": "skip"})
344
+ continue
345
+
346
+ try:
347
+ data = json.loads(cov_file.read_text())
348
+ lines_pct = data.get("total", {}).get("lines", {}).get("pct", 0)
349
+ passed = lines_pct >= min_pct
350
+ if not passed:
351
+ any_fail = True
352
+
353
+ status = "[green]PASS[/green]" if passed else "[red]FAIL[/red]"
354
+ color = "green" if passed else "red"
355
+ rows.append([name, f"[{color}]{lines_pct:.1f}%[/{color}]", f"{min_pct}%", status])
356
+ json_data.append(
357
+ {"app": name, "lines_pct": lines_pct, "min_pct": min_pct, "status": "pass" if passed else "fail"}
358
+ )
359
+ except Exception:
360
+ rows.append([name, "[red]error[/red]", f"{min_pct}%", "[red]ERROR[/red]"])
361
+ json_data.append({"app": name, "lines_pct": None, "min_pct": min_pct, "status": "error"})
362
+ any_fail = True
363
+
364
+ out.table(
365
+ f"Coverage Threshold (min {min_pct}%)",
366
+ [("App", "cyan"), ("Lines %", ""), ("Min %", ""), ("Status", "")],
367
+ rows,
368
+ data_for_json=json_data,
369
+ )
370
+
371
+ if any_fail:
372
+ out.error("One or more apps are below the coverage threshold")
373
+ raise typer.Exit(1) from None
374
+
375
+
376
+ @app.command()
377
+ def snapshots(
378
+ ctx: typer.Context,
379
+ app_name: Annotated[str, typer.Argument(help="App name to inspect snapshots for")],
380
+ ) -> None:
381
+ """List vitest snapshot files for an app.
382
+
383
+ Shows all .snap files in the app's src/ directory with their sizes.
384
+ """
385
+ actx: AppContext = ctx.obj
386
+ out = actx.output
387
+ root = actx.project_root
388
+
389
+ actx.validate_app(app_name)
390
+
391
+ src_dir = get_app_dir(root, app_name) / "src"
392
+ if not src_dir.is_dir():
393
+ out.warn(f"No src/ directory found for {app_name}")
394
+ return
395
+
396
+ snap_files = sorted(src_dir.rglob("*.snap"))
397
+
398
+ rows: list[list[str]] = []
399
+ json_data: list[dict] = []
400
+
401
+ for snap in snap_files:
402
+ size = snap.stat().st_size
403
+ size_str = f"{size / 1024:.1f} KB" if size >= 1024 else f"{size} B"
404
+ rel_path = str(snap.relative_to(root))
405
+ rows.append([rel_path, size_str])
406
+ json_data.append({"file": rel_path, "size_bytes": size})
407
+
408
+ if not snap_files:
409
+ out.info(f"No snapshot files found in {app_name}/src/")
410
+ else:
411
+ out.table(
412
+ f"Snapshot Files: {app_name}",
413
+ [("File", "cyan"), ("Size", "")],
414
+ rows,
415
+ data_for_json=json_data,
416
+ )
417
+
418
+ out.info(f"To update snapshots: pnpm --filter @kodemeio/{app_name} vitest run --update-snapshots")