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,96 @@
1
+ """Dev server management commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import signal
6
+ import subprocess
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from kctl_react.core.callbacks import AppContext
12
+
13
+ app = typer.Typer(help="Dev server management.")
14
+
15
+
16
+ @app.command()
17
+ def start(
18
+ ctx: typer.Context,
19
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all apps)")] = None,
20
+ ) -> None:
21
+ """Start dev server(s) via Turbo.
22
+
23
+ Examples:
24
+ kctl-react dev start sfa # Start SFA only
25
+ kctl-react dev start # Start all apps
26
+ """
27
+ actx: AppContext = ctx.obj
28
+ out = actx.output
29
+ root = actx.project_root
30
+
31
+ if app_name:
32
+ actx.validate_app(app_name)
33
+ info = actx.apps[app_name]
34
+ out.info(f"Starting {app_name} on port {info['port']}...")
35
+ cmd = ["pnpm", "turbo", "run", "dev", "--filter", f"@kodemeio/{app_name}"]
36
+ else:
37
+ out.info("Starting all apps...")
38
+ cmd = ["pnpm", "turbo", "run", "dev"]
39
+
40
+ try:
41
+ proc = subprocess.Popen(cmd, cwd=root)
42
+ proc.wait()
43
+ except KeyboardInterrupt:
44
+ out.info("Stopping dev server(s)...")
45
+ proc.send_signal(signal.SIGINT)
46
+ proc.wait()
47
+
48
+
49
+ @app.command()
50
+ def logs(
51
+ ctx: typer.Context,
52
+ app_name: Annotated[str, typer.Argument(help="App name")],
53
+ ) -> None:
54
+ """Show build output / dev logs for an app (re-runs dev with output)."""
55
+ actx: AppContext = ctx.obj
56
+ out = actx.output
57
+ root = actx.project_root
58
+
59
+ actx.validate_app(app_name)
60
+ info = actx.apps[app_name]
61
+ out.info(f"Tailing dev output for {app_name} (port {info['port']})...")
62
+
63
+ try:
64
+ proc = subprocess.Popen(
65
+ ["pnpm", "turbo", "run", "dev", "--filter", f"@kodemeio/{app_name}"],
66
+ cwd=root,
67
+ )
68
+ proc.wait()
69
+ except KeyboardInterrupt:
70
+ proc.send_signal(signal.SIGINT)
71
+ proc.wait()
72
+
73
+
74
+ @app.command("list")
75
+ def list_(ctx: typer.Context) -> None:
76
+ """List available apps and their dev ports."""
77
+ actx: AppContext = ctx.obj
78
+ out = actx.output
79
+
80
+ rows: list[list[str]] = []
81
+ for name in actx.app_names:
82
+ info = actx.apps[name]
83
+ rows.append(
84
+ [
85
+ name,
86
+ str(info["port"]),
87
+ f"pnpm dev:{name}",
88
+ f"http://localhost:{info['port']}",
89
+ ]
90
+ )
91
+
92
+ out.table(
93
+ "Dev Servers",
94
+ [("App", "cyan"), ("Port", "green"), ("Command", "dim"), ("URL", "")],
95
+ rows,
96
+ )
@@ -0,0 +1,73 @@
1
+ """Docker container management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+ from kctl_lib.docker import DockerManager
9
+
10
+ from kctl_react.core.callbacks import AppContext
11
+
12
+ app = typer.Typer(help="Docker container management.")
13
+
14
+
15
+ def _get_manager(actx: AppContext, app_name: str | None = None) -> DockerManager:
16
+ compose = (
17
+ actx.project_root / f"docker-compose.prod.{app_name}.yml"
18
+ if app_name
19
+ else actx.project_root / "docker-compose.yml"
20
+ )
21
+ if not compose.exists():
22
+ compose = actx.project_root / "docker-compose.prod.yml"
23
+ return DockerManager(compose_file=compose, project_name=app_name or "kodemeio-react")
24
+
25
+
26
+ @app.command()
27
+ def ps(ctx: typer.Context) -> None:
28
+ """List running containers."""
29
+ actx: AppContext = ctx.obj
30
+ out = actx.output
31
+ mgr = _get_manager(actx)
32
+ containers = mgr.ps()
33
+ if not containers:
34
+ out.info("No running containers")
35
+ return
36
+ rows = [[c.get("Name", ""), c.get("State", ""), c.get("Status", "")] for c in containers]
37
+ out.table("Containers", [("Name", "cyan"), ("State", "green"), ("Status", "dim")], rows, data_for_json=containers)
38
+
39
+
40
+ @app.command()
41
+ def logs(
42
+ ctx: typer.Context,
43
+ service: Annotated[str | None, typer.Argument(help="Service name")] = None,
44
+ tail: Annotated[int, typer.Option("--tail", help="Lines")] = 100,
45
+ ) -> None:
46
+ """Stream container logs."""
47
+ actx: AppContext = ctx.obj
48
+ mgr = _get_manager(actx)
49
+ mgr.logs(service=service, tail=tail)
50
+
51
+
52
+ @app.command()
53
+ def restart(ctx: typer.Context, service: Annotated[str, typer.Argument(help="Service name")]) -> None:
54
+ """Restart a container."""
55
+ actx: AppContext = ctx.obj
56
+ out = actx.output
57
+ mgr = _get_manager(actx)
58
+ mgr.restart(service)
59
+ out.success(f"Restarted {service}")
60
+
61
+
62
+ @app.command("image-size")
63
+ def image_size(ctx: typer.Context) -> None:
64
+ """Show Docker image sizes."""
65
+ actx: AppContext = ctx.obj
66
+ out = actx.output
67
+ mgr = _get_manager(actx)
68
+ images = mgr.image_size()
69
+ if not images:
70
+ out.info("No images found")
71
+ return
72
+ rows = [[i.get("Repository", ""), i.get("Tag", ""), i.get("Size", "")] for i in images]
73
+ out.table("Images", [("Repository", "cyan"), ("Tag", "dim"), ("Size", "")], rows, data_for_json=images)
@@ -0,0 +1,170 @@
1
+ """Top-level doctor command group."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+ import typer
9
+
10
+ from kctl_react.core.callbacks import AppContext
11
+ from kctl_react.core.discovery import get_app_dir
12
+
13
+ app = typer.Typer(help="Monorepo health checks.")
14
+
15
+
16
+ def _check_cmd(name: str) -> tuple[bool, str]:
17
+ """Check if a command exists and return its version."""
18
+ path = shutil.which(name)
19
+ if not path:
20
+ return False, ""
21
+ try:
22
+ result = subprocess.run([name, "--version"], capture_output=True, text=True, timeout=5)
23
+ version = result.stdout.strip().split("\n")[0]
24
+ return True, version
25
+ except Exception:
26
+ return True, "(version unknown)"
27
+
28
+
29
+ def run_doctor(actx: AppContext) -> None:
30
+ """Run comprehensive monorepo health checks.
31
+
32
+ Checks: node, pnpm, turbo, git, docker, all apps, packages, env files,
33
+ codegen config, and dependency installation.
34
+ """
35
+ out = actx.output
36
+ root = actx.project_root
37
+
38
+ issues = 0
39
+ checks = 0
40
+
41
+ def ok(msg: str) -> None:
42
+ nonlocal checks
43
+ checks += 1
44
+ out.success(msg)
45
+
46
+ def fail(msg: str) -> None:
47
+ nonlocal checks, issues
48
+ checks += 1
49
+ issues += 1
50
+ out.error(msg)
51
+
52
+ def warn(msg: str) -> None:
53
+ nonlocal checks, issues
54
+ checks += 1
55
+ issues += 1
56
+ out.warn(msg)
57
+
58
+ out.header("System Tools")
59
+
60
+ found, ver = _check_cmd("node")
61
+ if found:
62
+ ok(f"node: {ver}")
63
+ else:
64
+ fail("node: not found")
65
+
66
+ found, ver = _check_cmd("pnpm")
67
+ if found:
68
+ ok(f"pnpm: {ver}")
69
+ else:
70
+ fail("pnpm: not found")
71
+
72
+ found, ver = _check_cmd("turbo")
73
+ if found:
74
+ ok(f"turbo: {ver}")
75
+ else:
76
+ warn("turbo: not found (optional, runs via pnpm)")
77
+
78
+ found, ver = _check_cmd("git")
79
+ if found:
80
+ ok(f"git: {ver}")
81
+ else:
82
+ fail("git: not found")
83
+
84
+ found, ver = _check_cmd("docker")
85
+ if found:
86
+ ok(f"docker: {ver}")
87
+ else:
88
+ warn("docker: not found (needed for deploy commands)")
89
+
90
+ out.header("Monorepo Structure")
91
+
92
+ if (root / "turbo.json").exists():
93
+ ok(f"turbo.json found at {root}")
94
+ else:
95
+ fail(f"turbo.json NOT found at {root}")
96
+
97
+ if (root / "package.json").exists():
98
+ ok("Root package.json exists")
99
+ else:
100
+ fail("Root package.json missing")
101
+
102
+ if (root / "pnpm-lock.yaml").exists():
103
+ ok("pnpm-lock.yaml exists")
104
+ else:
105
+ fail("pnpm-lock.yaml missing — run `pnpm install`")
106
+
107
+ if (root / "node_modules").is_dir():
108
+ ok("node_modules installed")
109
+ else:
110
+ fail("node_modules missing — run `pnpm install`")
111
+
112
+ out.header("Apps")
113
+
114
+ for name in actx.app_names:
115
+ app_dir = get_app_dir(root, name)
116
+ if not app_dir.is_dir():
117
+ fail(f"{name}: directory missing")
118
+ continue
119
+
120
+ problems: list[str] = []
121
+ if not (app_dir / "package.json").exists():
122
+ problems.append("no package.json")
123
+ if not (app_dir / "src").is_dir():
124
+ problems.append("no src/")
125
+ if not (app_dir / "openapi-ts.config.ts").exists():
126
+ problems.append("no openapi-ts.config.ts")
127
+
128
+ has_env = (app_dir / ".env").exists() or (app_dir / ".env.local").exists()
129
+ if not has_env:
130
+ problems.append("no .env file")
131
+
132
+ if problems:
133
+ warn(f"{name}: {', '.join(problems)}")
134
+ else:
135
+ ok(f"{name}: OK (port {actx.apps[name]['port']})")
136
+
137
+ out.header("Shared Packages")
138
+
139
+ for pkg_name in actx.packages:
140
+ pkg_dir = root / "packages" / pkg_name
141
+ if not (pkg_dir / "package.json").exists():
142
+ fail(f"@kodemeio/{pkg_name}: package.json missing")
143
+ elif not (pkg_dir / "src").is_dir():
144
+ warn(f"@kodemeio/{pkg_name}: no src/ directory")
145
+ else:
146
+ ok(f"@kodemeio/{pkg_name}: OK")
147
+
148
+ out.header("Summary")
149
+
150
+ if issues == 0:
151
+ out.success(f"All {checks} checks passed — monorepo is healthy!")
152
+ else:
153
+ out.warn(f"{issues} issue(s) found out of {checks} checks")
154
+
155
+ if out.json_mode:
156
+ out.raw_json({"checks": checks, "issues": issues, "healthy": issues == 0})
157
+
158
+ if issues > 0:
159
+ raise typer.Exit(1) from None
160
+
161
+
162
+ @app.command("check")
163
+ def check(ctx: typer.Context) -> None:
164
+ """Run comprehensive monorepo health checks.
165
+
166
+ Checks: node, pnpm, turbo, git, docker, all apps, packages, env files,
167
+ codegen config, and dependency installation.
168
+ """
169
+ actx: AppContext = ctx.obj
170
+ run_doctor(actx)
@@ -0,0 +1,343 @@
1
+ """Playwright E2E testing and screenshot commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import signal
6
+ import subprocess
7
+ from typing import Annotated
8
+
9
+ import typer
10
+
11
+ from kctl_react.core.callbacks import AppContext
12
+ from kctl_react.core.runner import run
13
+
14
+ app = typer.Typer(help="E2E testing and screenshots via Playwright.")
15
+
16
+
17
+ @app.command()
18
+ def test(
19
+ ctx: typer.Context,
20
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
21
+ headed: Annotated[bool, typer.Option("--headed", help="Run with visible browser.")] = False,
22
+ ui: Annotated[bool, typer.Option("--ui", help="Open Playwright UI mode.")] = False,
23
+ shared_only: Annotated[bool, typer.Option("--shared", help="Run only shared tests.")] = False,
24
+ debug: Annotated[bool, typer.Option("--debug", help="Run with Playwright debug mode.")] = False,
25
+ screenshots: Annotated[bool, typer.Option("--screenshots", help="Capture screenshots for every test.")] = False,
26
+ video: Annotated[bool, typer.Option("--video", help="Record video for every test.")] = False,
27
+ mobile: Annotated[bool, typer.Option("--mobile", help="Run with mobile viewport (iPhone 14).")] = False,
28
+ grep: Annotated[str | None, typer.Option("--grep", "-g", help="Filter tests by title pattern.")] = None,
29
+ api_only: Annotated[bool, typer.Option("--api", help="Run only API CRUD tests.")] = False,
30
+ pages_only: Annotated[bool, typer.Option("--pages", help="Run only page screenshot tests.")] = False,
31
+ ) -> None:
32
+ """Run Playwright E2E tests.
33
+
34
+ Uses multi-project config with --project flag for per-app targeting.
35
+
36
+ Examples:
37
+ kctl-react e2e test # Run all E2E tests
38
+ kctl-react e2e test sfa # Run SFA tests (shared + app-specific)
39
+ kctl-react e2e test sfa --headed # Run SFA tests with visible browser
40
+ kctl-react e2e test sfa --shared # Run only shared tests for SFA
41
+ kctl-react e2e test hrm --screenshots # Run with screenshots
42
+ kctl-react e2e test hrm --video # Run with video recording
43
+ kctl-react e2e test hrm --mobile # Run with mobile viewport
44
+ kctl-react e2e test hrm --mobile --screenshots # Mobile + screenshots
45
+ kctl-react e2e test hrm --api # Run only API CRUD tests
46
+ kctl-react e2e test hrm --pages # Run only page screenshot tests
47
+ kctl-react e2e test hrm --grep "Leave" # Run tests matching "Leave"
48
+ kctl-react e2e test --ui # Open Playwright UI mode
49
+ """
50
+ actx: AppContext = ctx.obj
51
+ out = actx.output
52
+ root = actx.project_root
53
+
54
+ if app_name:
55
+ actx.validate_app(app_name)
56
+
57
+ e2e_dir = root / "e2e"
58
+ if not e2e_dir.is_dir():
59
+ out.error("e2e/ directory not found")
60
+ raise typer.Exit(1) from None
61
+
62
+ cmd = ["npx", "playwright", "test"]
63
+
64
+ # File filter must come before --project
65
+ if api_only and app_name:
66
+ cmd.append(f"tests/apps/{app_name}/api-crud.spec.ts")
67
+ elif pages_only and app_name:
68
+ app_pages = e2e_dir / "tests" / "apps" / app_name / "all-pages.spec.ts"
69
+ if app_pages.exists():
70
+ cmd.append(f"tests/apps/{app_name}/all-pages.spec.ts")
71
+ else:
72
+ # Use generic factory (auto-generates from app-registry)
73
+ cmd.append("factories/all-pages.factory.ts")
74
+ elif shared_only:
75
+ cmd.append("tests/shared/")
76
+
77
+ # Build project name: "hrm" or "hrm-mobile"
78
+ project_name = app_name
79
+ if app_name and mobile:
80
+ project_name = f"{app_name}-mobile"
81
+ elif mobile and not app_name:
82
+ out.error("--mobile requires an app name (e.g. kctl-react e2e test hrm --mobile)")
83
+ raise typer.Exit(1) from None
84
+
85
+ if project_name:
86
+ cmd.extend(["--project", project_name])
87
+
88
+ if headed:
89
+ cmd.append("--headed")
90
+ if ui:
91
+ cmd.append("--ui")
92
+ if debug:
93
+ cmd.append("--debug")
94
+ if grep:
95
+ cmd.extend(["--grep", grep])
96
+
97
+ env: dict[str, str] = {}
98
+ if app_name:
99
+ env["E2E_APPS"] = app_name
100
+ if screenshots:
101
+ env["E2E_SCREENSHOTS"] = "on"
102
+ if video:
103
+ env["E2E_VIDEO"] = "on"
104
+
105
+ viewport = "mobile" if mobile else "desktop"
106
+ flags = []
107
+ if screenshots:
108
+ flags.append("screenshots")
109
+ if video:
110
+ flags.append("video")
111
+ if api_only:
112
+ flags.append("API only")
113
+ if pages_only:
114
+ flags.append("pages only")
115
+ if grep:
116
+ flags.append(f'grep="{grep}"')
117
+ flags_str = f" ({', '.join(flags)})" if flags else ""
118
+ out.info(f"Running E2E tests{f' for {app_name}' if app_name else ' (all apps)'} [{viewport}]{flags_str}...")
119
+
120
+ try:
121
+ import os
122
+
123
+ run_env = {**os.environ, **(env or {})}
124
+ proc = subprocess.Popen(cmd, cwd=e2e_dir, env=run_env)
125
+ proc.wait()
126
+ if proc.returncode == 0:
127
+ out.success("E2E tests passed")
128
+ if screenshots or video:
129
+ results_dir = root / "e2e" / "screenshots"
130
+ if results_dir.is_dir():
131
+ parts = []
132
+ if screenshots:
133
+ pngs = list(results_dir.rglob("*.png"))
134
+ parts.append(f"Screenshots: {len(pngs)} file(s)")
135
+ if video:
136
+ videos = list(results_dir.rglob("*.webm"))
137
+ parts.append(f"Videos: {len(videos)} file(s)")
138
+ out.info(f"{', '.join(parts)} in e2e/screenshots/")
139
+ else:
140
+ out.error("E2E tests failed")
141
+ raise typer.Exit(1)
142
+ except KeyboardInterrupt:
143
+ proc.send_signal(signal.SIGINT)
144
+ proc.wait()
145
+
146
+
147
+ @app.command(name="list")
148
+ def list_tests(
149
+ ctx: typer.Context,
150
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
151
+ ) -> None:
152
+ """List all discovered E2E tests.
153
+
154
+ Examples:
155
+ kctl-react e2e list # List all tests
156
+ kctl-react e2e list sfa # List SFA tests only
157
+ """
158
+ actx: AppContext = ctx.obj
159
+ out = actx.output
160
+ root = actx.project_root
161
+
162
+ if app_name:
163
+ actx.validate_app(app_name)
164
+
165
+ e2e_dir = root / "e2e"
166
+ if not e2e_dir.is_dir():
167
+ out.error("e2e/ directory not found")
168
+ raise typer.Exit(1) from None
169
+
170
+ cmd = ["npx", "playwright", "test", "--list"]
171
+ env: dict[str, str] | None = None
172
+
173
+ if app_name:
174
+ cmd.extend(["--project", app_name])
175
+ env = {"E2E_APPS": app_name}
176
+
177
+ try:
178
+ run(cmd, cwd=e2e_dir, capture=False, timeout=30, env=env)
179
+ except Exception as e:
180
+ out.error(f"Failed to list tests: {e}")
181
+ raise typer.Exit(1) from None
182
+
183
+
184
+ @app.command()
185
+ def report(ctx: typer.Context) -> None:
186
+ """Open Playwright HTML test report."""
187
+ actx: AppContext = ctx.obj
188
+ out = actx.output
189
+ root = actx.project_root
190
+
191
+ e2e_dir = root / "e2e"
192
+ if not e2e_dir.is_dir():
193
+ out.error("e2e/ directory not found")
194
+ raise typer.Exit(1) from None
195
+
196
+ out.info("Opening Playwright report...")
197
+ try:
198
+ proc = subprocess.Popen(
199
+ ["npx", "playwright", "show-report"],
200
+ cwd=e2e_dir,
201
+ )
202
+ proc.wait()
203
+ except KeyboardInterrupt:
204
+ proc.send_signal(signal.SIGINT)
205
+ proc.wait()
206
+ except Exception as e:
207
+ out.error(f"Failed to open report: {e}")
208
+
209
+
210
+ @app.command()
211
+ def discover(
212
+ ctx: typer.Context,
213
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Preview without writing.")] = False,
214
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON.")] = False,
215
+ ) -> None:
216
+ """Auto-discover app configs and regenerate e2e/app-registry.ts.
217
+
218
+ Scans apps/spa/*/src/ for ports, token keys, routes, and nav items.
219
+ Run this after adding new pages, routes, or nav items.
220
+
221
+ Examples:
222
+ kctl-react e2e discover # Regenerate registry
223
+ kctl-react e2e discover --dry-run # Preview changes
224
+ kctl-react e2e discover --json # Output as JSON
225
+ """
226
+ actx: AppContext = ctx.obj
227
+ out = actx.output
228
+ root = actx.project_root
229
+
230
+ script = root / "e2e" / "scripts" / "discover.mjs"
231
+ if not script.exists():
232
+ out.error("e2e/scripts/discover.mjs not found")
233
+ raise typer.Exit(1) from None
234
+
235
+ cmd = ["node", str(script)]
236
+ if dry_run:
237
+ cmd.append("--dry-run")
238
+ if json_output:
239
+ cmd.append("--json")
240
+
241
+ out.info("Discovering app configs from source files...")
242
+ try:
243
+ run(cmd, cwd=root, capture=False, timeout=30)
244
+ except Exception as e:
245
+ out.error(f"Discovery failed: {e}")
246
+ raise typer.Exit(1) from None
247
+
248
+
249
+ @app.command()
250
+ def install(ctx: typer.Context) -> None:
251
+ """Install Playwright browsers (chromium).
252
+
253
+ Examples:
254
+ kctl-react e2e install
255
+ """
256
+ actx: AppContext = ctx.obj
257
+ out = actx.output
258
+ root = actx.project_root
259
+
260
+ e2e_dir = root / "e2e"
261
+ if not e2e_dir.is_dir():
262
+ out.error("e2e/ directory not found")
263
+ raise typer.Exit(1) from None
264
+
265
+ out.info("Installing Playwright browsers...")
266
+ try:
267
+ run(
268
+ ["npx", "playwright", "install", "--with-deps", "chromium"],
269
+ cwd=e2e_dir,
270
+ capture=False,
271
+ timeout=120,
272
+ )
273
+ out.success("Playwright browsers installed")
274
+ except Exception as e:
275
+ out.error(f"Failed to install browsers: {e}")
276
+ raise typer.Exit(1) from None
277
+
278
+
279
+ @app.command()
280
+ def screenshots(
281
+ ctx: typer.Context,
282
+ app_name: Annotated[str | None, typer.Argument(help="App name (omit for all)")] = None,
283
+ mobile: Annotated[bool, typer.Option("--mobile", help="Use mobile viewport (default).")] = True,
284
+ desktop: Annotated[bool, typer.Option("--desktop", help="Use desktop viewport.")] = False,
285
+ ) -> None:
286
+ """Capture screenshots of all app pages via Playwright.
287
+
288
+ Runs the all-pages.factory.ts test which saves timestamped screenshots
289
+ to e2e/screenshots/{app}/{viewport}/{YYYYMMDD-HHMMSS}/.
290
+
291
+ Examples:
292
+ kctl-react e2e screenshots # All apps, mobile viewport
293
+ kctl-react e2e screenshots sfa # SFA only
294
+ kctl-react e2e screenshots --desktop # Desktop viewport
295
+ """
296
+ actx: AppContext = ctx.obj
297
+ out = actx.output
298
+ root = actx.project_root
299
+
300
+ if app_name:
301
+ actx.validate_app(app_name)
302
+
303
+ e2e_dir = root / "e2e"
304
+ if not e2e_dir.is_dir():
305
+ out.error("e2e/ directory not found")
306
+ raise typer.Exit(1) from None
307
+
308
+ # Determine viewport: --desktop overrides default mobile
309
+ use_mobile = not desktop
310
+
311
+ # Build playwright command — delegates to all-pages.factory.ts
312
+ cmd = ["npx", "playwright", "test", "factories/all-pages.factory.ts"]
313
+
314
+ if app_name:
315
+ project_name = f"{app_name}-mobile" if use_mobile else app_name
316
+ cmd.extend(["--project", project_name])
317
+
318
+ viewport_label = "mobile" if use_mobile else "desktop"
319
+ out.info(f"Capturing screenshots{f' for {app_name}' if app_name else ' (all apps)'} [{viewport_label}]...")
320
+
321
+ import os
322
+
323
+ env = {**os.environ}
324
+ if app_name:
325
+ env["E2E_APPS"] = app_name
326
+
327
+ try:
328
+ proc = subprocess.Popen(cmd, cwd=e2e_dir, env=env)
329
+ proc.wait()
330
+ if proc.returncode == 0:
331
+ out.success("Screenshots captured")
332
+ else:
333
+ out.error("Screenshot capture failed (Playwright exited with errors)")
334
+ raise typer.Exit(1)
335
+ except KeyboardInterrupt:
336
+ proc.send_signal(signal.SIGINT)
337
+ proc.wait()
338
+
339
+ # Show screenshot directory
340
+ screenshots_dir = root / "e2e" / "screenshots"
341
+ if screenshots_dir.is_dir():
342
+ count = len(list(screenshots_dir.rglob("*.png")))
343
+ out.info(f"Screenshots: {count} file(s) in e2e/screenshots/")