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.
- kctl_react/__init__.py +3 -0
- kctl_react/__main__.py +5 -0
- kctl_react/cli.py +201 -0
- kctl_react/commands/__init__.py +0 -0
- kctl_react/commands/a11y.py +78 -0
- kctl_react/commands/affected.py +170 -0
- kctl_react/commands/apps.py +353 -0
- kctl_react/commands/build.py +376 -0
- kctl_react/commands/bundle_cmd.py +217 -0
- kctl_react/commands/cap.py +1465 -0
- kctl_react/commands/clean.py +76 -0
- kctl_react/commands/codegen.py +491 -0
- kctl_react/commands/compliance.py +587 -0
- kctl_react/commands/config_cmd.py +368 -0
- kctl_react/commands/dashboard.py +163 -0
- kctl_react/commands/deploy.py +318 -0
- kctl_react/commands/deps.py +792 -0
- kctl_react/commands/dev.py +96 -0
- kctl_react/commands/docker_cmd.py +73 -0
- kctl_react/commands/doctor.py +170 -0
- kctl_react/commands/e2e.py +343 -0
- kctl_react/commands/env.py +155 -0
- kctl_react/commands/i18n.py +310 -0
- kctl_react/commands/lint.py +306 -0
- kctl_react/commands/maintenance.py +308 -0
- kctl_react/commands/monitor_cmd.py +50 -0
- kctl_react/commands/observe.py +34 -0
- kctl_react/commands/packages.py +129 -0
- kctl_react/commands/perf.py +762 -0
- kctl_react/commands/pipeline.py +289 -0
- kctl_react/commands/pwa.py +193 -0
- kctl_react/commands/scaffold.py +323 -0
- kctl_react/commands/security.py +660 -0
- kctl_react/commands/skill_cmd.py +54 -0
- kctl_react/commands/state.py +254 -0
- kctl_react/commands/test_cmd.py +418 -0
- kctl_react/commands/ui_audit.py +889 -0
- kctl_react/core/__init__.py +0 -0
- kctl_react/core/analyzers.py +200 -0
- kctl_react/core/callbacks.py +70 -0
- kctl_react/core/compliance/__init__.py +3 -0
- kctl_react/core/compliance/api_check/__init__.py +3 -0
- kctl_react/core/compliance/api_check/checks/__init__.py +18 -0
- kctl_react/core/compliance/api_check/checks/endpoints.py +53 -0
- kctl_react/core/compliance/api_check/checks/envelope.py +44 -0
- kctl_react/core/compliance/api_check/checks/naming.py +60 -0
- kctl_react/core/compliance/api_check/checks/params.py +44 -0
- kctl_react/core/compliance/api_check/checks/requests.py +57 -0
- kctl_react/core/compliance/api_check/checks/types.py +55 -0
- kctl_react/core/compliance/api_check/hooks.py +133 -0
- kctl_react/core/compliance/api_check/matcher.py +55 -0
- kctl_react/core/compliance/api_check/schema.py +151 -0
- kctl_react/core/compliance/api_health/__init__.py +35 -0
- kctl_react/core/compliance/api_health/checks/__init__.py +9 -0
- kctl_react/core/compliance/api_health/checks/auth.py +72 -0
- kctl_react/core/compliance/api_health/checks/reachable.py +44 -0
- kctl_react/core/compliance/api_health/checks/response.py +55 -0
- kctl_react/core/compliance/api_health/checks/timing.py +38 -0
- kctl_react/core/compliance/api_health/client.py +99 -0
- kctl_react/core/compliance/api_health/sampler.py +16 -0
- kctl_react/core/compliance/checks/__init__.py +47 -0
- kctl_react/core/compliance/checks/api.py +101 -0
- kctl_react/core/compliance/checks/codegen.py +94 -0
- kctl_react/core/compliance/checks/darkmode.py +57 -0
- kctl_react/core/compliance/checks/errors.py +68 -0
- kctl_react/core/compliance/checks/features.py +66 -0
- kctl_react/core/compliance/checks/i18n_check.py +105 -0
- kctl_react/core/compliance/checks/imports.py +86 -0
- kctl_react/core/compliance/checks/navigation.py +62 -0
- kctl_react/core/compliance/checks/practices.py +122 -0
- kctl_react/core/compliance/checks/providers.py +85 -0
- kctl_react/core/compliance/checks/pwa.py +101 -0
- kctl_react/core/compliance/checks/responsive.py +47 -0
- kctl_react/core/compliance/checks/scripts.py +85 -0
- kctl_react/core/compliance/checks/shadcn.py +51 -0
- kctl_react/core/compliance/checks/structure.py +76 -0
- kctl_react/core/compliance/checks/testing.py +83 -0
- kctl_react/core/compliance/checks/theme.py +92 -0
- kctl_react/core/compliance/checks/ui_standard.py +185 -0
- kctl_react/core/compliance/checks/vite.py +83 -0
- kctl_react/core/compliance/engine.py +87 -0
- kctl_react/core/compliance/exceptions_map.py +15 -0
- kctl_react/core/compliance/fixes/__init__.py +33 -0
- kctl_react/core/compliance/fixes/codegen_fix.py +28 -0
- kctl_react/core/compliance/fixes/i18n_fix.py +61 -0
- kctl_react/core/compliance/fixes/imports_fix.py +36 -0
- kctl_react/core/compliance/fixes/structure_fix.py +20 -0
- kctl_react/core/compliance/fixes/theme_fix.py +29 -0
- kctl_react/core/compliance/models.py +106 -0
- kctl_react/core/config.py +201 -0
- kctl_react/core/discovery.py +185 -0
- kctl_react/core/exceptions.py +17 -0
- kctl_react/core/git.py +146 -0
- kctl_react/core/history.py +121 -0
- kctl_react/core/output.py +5 -0
- kctl_react/core/plugins.py +13 -0
- kctl_react/core/runner.py +34 -0
- kctl_react/py.typed +0 -0
- kctl_react-0.6.2.dist-info/METADATA +17 -0
- kctl_react-0.6.2.dist-info/RECORD +102 -0
- kctl_react-0.6.2.dist-info/WHEEL +4 -0
- 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/")
|