调整技能的位置

This commit is contained in:
马宝龙
2026-07-28 13:35:21 +08:00
parent 654e04ace1
commit 5c3b306aae
6 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,67 @@
---
name: fullstack-start-verify
description: 启动本地项目的前端和后端服务并通过进程状态、HTTP 健康检查、接口检查和可用时的浏览器冒烟测试验证整体功能。用于 Codex 需要运行全栈项目、验证前后端联调、排查启动失败,或确认用户流程是否端到端可用的场景。
---
# 全栈启动与验证
当用户要求启动前后端、验证整体功能、跑通本地联调或确认项目是否可用时使用本技能。目标是得到可复现的验证结论,而不是只确认某个进程曾经启动过。
## 工作流程
1. 在启动前检查项目。
- 阅读适用的 `AGENTS.md``README.md`、根目录环境变量示例,以及前后端的项目清单文件。
- 确认实际使用的包管理器和启动命令。优先使用项目文档和项目自带包装器中的命令,例如 `npm``pnpm``yarn``mvnw``gradlew``uv` 等。
- 找到后端健康检查地址、前端地址、API 基础地址、所需的数据库或缓存服务,以及文档中提供的测试账号。
- 不要为了让检查通过而虚构凭据、覆盖 `.env`、执行迁移或重置数据。
2. 执行启动前检查。
- 确认所需运行时和依赖目录可用。
- 检查计划使用的端口。只有在进程和健康响应明确属于当前项目时才复用服务,绝不要结束未知进程。
- 仅在项目文档说明了启动方式且用户已将其纳入范围时,启动 MySQL 或 Redis 等基础设施。
- 如果缺少必要的密钥、数据库或账号,应报告阻塞原因,不要降低验证标准。
3. 使用 `scripts/start_and_verify.py` 启动两个应用服务。
- 从项目根目录运行脚本。
- 传入明确的工作目录、启动命令和地址。对于健康检查之外的重要 API 路由,使用 `--check-url label=url` 添加检查。
- 后端应使用项目专用的健康检查地址,而不是只检查 TCP 端口是否打开;前端应检查开发服务器根路径或已知路由。
- 脚本只管理和清理自己启动的进程。只有用户明确要求服务继续运行时,才使用 `--keep-running`
- 排查失败时使用 `--keep-logs` 保留日志。避免把密钥放在命令行参数中。
Example:
```powershell
python skills/fullstack-start-verify/scripts/start_and_verify.py `
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
--backend-dir . `
--backend-url http://127.0.0.1:8080/api/health `
--frontend-cmd "npm run dev -- --host 127.0.0.1" `
--frontend-dir frontend `
--frontend-url http://127.0.0.1:5173/ `
--check-url health=http://127.0.0.1:8080/api/health
```
4. 按递进层级验证应用。
- 确认两个健康检查地址都返回可接受的 HTTP 状态,并确认对应进程仍在运行。
- 针对主要用户流程执行聚焦的后端或 API 检查。除非用户明确要求写入流程且测试数据安全,否则优先执行只读检查。
- 在相关且不会重复更权威检查的情况下,执行前端构建或项目已有测试命令。
- 如果有 Playwright 或其他浏览器工具,打开前端地址并执行主要用户路径:加载页面、完成最小有意义的操作,同时验证页面可见结果和网络/API 结果。
- 如果没有浏览器工具,应明确说明未验证 UI 交互;仅凭 HTTP 检查不能声称完成了端到端验证。
5. 汇报并清理环境。
- 汇报具体命令、地址、执行的检查、通过或失败状态,以及第一个可执行的失败原因。
- 分开汇报基础设施、后端、前端和浏览器结果,明确哪些部分已通过。
- 失败时给出日志位置或相关日志尾部,同时隐藏密码、令牌和连接字符串。
- 除非用户要求服务持续运行,否则在结束前确认脚本启动的进程已经停止。
## 失败处理
- 服务在地址就绪前退出,属于启动失败。重试前先检查捕获的日志。
- 超时不算通过。检查依赖是否可用、端口是否冲突、环境变量是否加载、代理配置和服务日志。
- 后端健康但前端 API 调用失败,属于集成失败。检查前端代理或基础地址,以及 CORS 配置。
- 前端页面能加载但浏览器操作失败,属于用户流程失败。记录准确路由、操作、响应状态和控制台错误。
- 验证期间不要修复无关代码、修改生产配置或删除数据,除非用户明确扩大任务范围。
## 内置脚本
`scripts/start_and_verify.py` 是一个不依赖第三方库的进程运行器,负责本流程中的启动和 HTTP 检查。使用不常见选项前先阅读 `--help` 输出。脚本默认创建临时日志并在清理后删除;排查问题时传入 `--keep-logs` 保留日志。

View File

@@ -0,0 +1,4 @@
interface:
display_name: "全栈启动验证"
short_description: "自动启动前后端服务并完成健康检查与端到端功能验证"
default_prompt: "使用 $fullstack-start-verify 启动项目的前后端,执行健康检查和端到端验证并汇报结果。"

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""启动两个本地服务,等待 HTTP 就绪后执行检查并清理进程。"""
from __future__ import annotations
import argparse
import os
import shutil
import signal
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
@dataclass
class Service:
name: str
command: str
cwd: Path
url: str
process: subprocess.Popen[bytes] | None = None
log_path: Path | None = None
log_file: object | None = None
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="启动前后端命令,检查 HTTP 地址并清理进程。"
)
parser.add_argument("--backend-cmd", required=True, help="后端启动命令")
parser.add_argument("--backend-dir", default=".", help="后端工作目录")
parser.add_argument("--backend-url", required=True, help="后端健康检查地址")
parser.add_argument("--frontend-cmd", required=True, help="前端启动命令")
parser.add_argument("--frontend-dir", default=".", help="前端工作目录")
parser.add_argument("--frontend-url", required=True, help="前端就绪检查地址")
parser.add_argument(
"--check-url",
action="append",
default=[],
metavar="LABEL=URL",
help="额外的 GET 检查,可重复传入",
)
parser.add_argument(
"--timeout",
type=float,
default=60.0,
help="每个服务的就绪超时时间,单位为秒,默认 60",
)
parser.add_argument(
"--interval",
type=float,
default=1.0,
help="每次就绪检查的间隔秒数,默认 1",
)
parser.add_argument(
"--log-dir",
type=Path,
help="日志目录,运行结束后保留",
)
parser.add_argument(
"--keep-logs",
action="store_true",
help="运行结束后保留临时日志",
)
parser.add_argument(
"--keep-running",
action="store_true",
help="验证结束后保持已启动的服务运行",
)
return parser.parse_args()
def resolve_dir(value: str) -> Path:
path = Path(value).resolve()
if not path.is_dir():
raise RuntimeError(f"工作目录不存在:{path}")
return path
def start_service(service: Service, log_dir: Path) -> None:
service.log_path = log_dir / f"{service.name}.log"
service.log_file = service.log_path.open("w", encoding="utf-8", buffering=1)
creationflags = 0
start_new_session = False
if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
else:
start_new_session = True
try:
service.process = subprocess.Popen(
service.command,
cwd=service.cwd,
shell=True,
stdout=service.log_file,
stderr=subprocess.STDOUT,
creationflags=creationflags,
start_new_session=start_new_session,
)
except Exception:
service.log_file.close()
service.log_file = None
raise
print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}")
def request(url: str, timeout: float) -> tuple[bool, str]:
request_obj = urllib.request.Request(url, headers={"User-Agent": "fullstack-start-verify/1.0"})
try:
with urllib.request.urlopen(request_obj, timeout=timeout) as response:
status = response.status
return 200 <= status < 400, str(status)
except urllib.error.HTTPError as error:
return 200 <= error.code < 400, str(error.code)
except (urllib.error.URLError, TimeoutError, OSError) as error:
reason = getattr(error, "reason", error)
return False, type(reason).__name__
def wait_for_service(service: Service, timeout: float, interval: float) -> None:
if service.process is None:
raise RuntimeError(f"{service.name} 未启动")
deadline = time.monotonic() + timeout
last_reason = "not checked"
while time.monotonic() < deadline:
exit_code = service.process.poll()
if exit_code is not None:
raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}")
ok, reason = request(service.url, min(interval, 5.0))
last_reason = reason
if ok:
print(f"通过 {service.name}{service.url} [{reason}]")
return
time.sleep(max(interval, 0.05))
raise RuntimeError(f"{service.name}{timeout:g} 秒内未就绪 [{last_reason}]")
def parse_extra_checks(values: list[str]) -> list[tuple[str, str]]:
checks: list[tuple[str, str]] = []
for value in values:
if "=" in value:
label, url = value.split("=", 1)
else:
label, url = value, value
label = label.strip() or url.strip()
url = url.strip()
if not url.startswith(("http://", "https://")):
raise RuntimeError(f"检查地址无效:{url}")
checks.append((label, url))
return checks
def run_extra_checks(checks: list[tuple[str, str]]) -> None:
for label, url in checks:
ok, reason = request(url, 10.0)
if not ok:
raise RuntimeError(f"检查失败:{label} {url} [{reason}]")
print(f"通过 {label}{url} [{reason}]")
def stop_service(service: Service) -> None:
process = service.process
if process is None or process.poll() is not None:
return
if os.name == "nt":
subprocess.run(
["taskkill", "/PID", str(process.pid), "/T", "/F"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=False,
)
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=5)
except OSError:
# taskkill 已经结束进程,但 Windows 可能同时使句柄失效。
process.returncode = -1
else:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
return
try:
process.wait(timeout=5)
except subprocess.TimeoutExpired:
os.killpg(process.pid, signal.SIGKILL)
def tail(path: Path | None, lines: int = 20) -> list[str]:
if path is None or not path.exists():
return []
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
def main() -> int:
args = parse_args()
temp_log_dir: Path | None = None
log_dir = args.log_dir
if log_dir is None:
temp_log_dir = Path(tempfile.mkdtemp(prefix="fullstack-start-verify-"))
log_dir = temp_log_dir
log_dir = log_dir.resolve()
log_dir.mkdir(parents=True, exist_ok=True)
services = [
Service("backend", args.backend_cmd, resolve_dir(args.backend_dir), args.backend_url),
Service("frontend", args.frontend_cmd, resolve_dir(args.frontend_dir), args.frontend_url),
]
failure: Exception | None = None
try:
for service in services:
start_service(service, log_dir)
for service in services:
wait_for_service(service, args.timeout, args.interval)
run_extra_checks(parse_extra_checks(args.check_url))
print("全栈验证通过FULLSTACK_VERIFY=PASS")
return 0
except (OSError, RuntimeError, ValueError) as error:
failure = error
print(f"全栈验证失败:{error}", file=sys.stderr)
for service in services:
for line in tail(service.log_path):
print(f"日志 {service.name}{line}", file=sys.stderr)
return 1
finally:
if not args.keep_running:
for service in reversed(services):
stop_service(service)
for service in services:
if service.log_file is not None:
service.log_file.close()
if args.keep_running:
print(f"服务仍在运行,日志目录:{log_dir}")
elif temp_log_dir is not None and not args.keep_logs:
shutil.rmtree(temp_log_dir, ignore_errors=True)
elif temp_log_dir is not None:
print(f"日志已保留:{temp_log_dir}")
elif failure is not None:
print(f"日志已保留:{log_dir}")
if __name__ == "__main__":
raise SystemExit(main())