调整skill位置
This commit is contained in:
@@ -16,14 +16,14 @@ description: 启动本地项目的前端和后端服务,并通过进程状态
|
|||||||
- 不要为了让检查通过而虚构凭据、覆盖 `.env`、执行迁移或重置数据。
|
- 不要为了让检查通过而虚构凭据、覆盖 `.env`、执行迁移或重置数据。
|
||||||
|
|
||||||
2. 执行启动前检查。
|
2. 执行启动前检查。
|
||||||
- 确认所需运行时和依赖目录可用。
|
- 确认所需运行时和依赖目录可用。前端已有 `node_modules` 时直接启动,不要每次重复执行 `npm ci`;仅在依赖缺失或用户明确要求重装时安装。
|
||||||
- 检查计划使用的端口。只有在进程和健康响应明确属于当前项目时才复用服务,绝不要结束未知进程。
|
- 检查计划使用的端口。只有在进程和健康响应明确属于当前项目时才复用服务,绝不要结束未知进程。
|
||||||
- 仅在项目文档说明了启动方式且用户已将其纳入范围时,启动 MySQL 或 Redis 等基础设施。
|
- 仅在项目文档说明了启动方式且用户已将其纳入范围时,启动 MySQL 或 Redis 等基础设施。
|
||||||
- 如果缺少必要的密钥、数据库或账号,应报告阻塞原因,不要降低验证标准。
|
- 如果缺少必要的密钥、数据库或账号,应报告阻塞原因,不要降低验证标准。
|
||||||
|
|
||||||
3. 使用 `scripts/start_and_verify.py` 启动两个应用服务。
|
3. 使用 `scripts/start_and_verify.py` 启动两个应用服务。脚本默认使用 0.25 秒快速轮询,并行等待两个服务就绪。
|
||||||
- 从项目根目录运行脚本。
|
- 从项目根目录运行脚本。
|
||||||
- 传入明确的工作目录、启动命令和地址。对于健康检查之外的重要 API 路由,使用 `--check-url label=url` 添加检查。
|
- 传入明确的工作目录、启动命令和地址。对于健康检查之外的重要 API 路由,使用 `--check-url label=url` 添加只读检查;不要重复添加已经作为 `--backend-url` 的健康地址。
|
||||||
- 后端应使用项目专用的健康检查地址,而不是只检查 TCP 端口是否打开;前端应检查开发服务器根路径或已知路由。
|
- 后端应使用项目专用的健康检查地址,而不是只检查 TCP 端口是否打开;前端应检查开发服务器根路径或已知路由。
|
||||||
- 脚本只管理和清理自己启动的进程。只有用户明确要求服务继续运行时,才使用 `--keep-running`。
|
- 脚本只管理和清理自己启动的进程。只有用户明确要求服务继续运行时,才使用 `--keep-running`。
|
||||||
- 排查失败时使用 `--keep-logs` 保留日志。避免把密钥放在命令行参数中。
|
- 排查失败时使用 `--keep-logs` 保留日志。避免把密钥放在命令行参数中。
|
||||||
@@ -34,22 +34,22 @@ description: 启动本地项目的前端和后端服务,并通过进程状态
|
|||||||
python skills/fullstack-start-verify/scripts/start_and_verify.py `
|
python skills/fullstack-start-verify/scripts/start_and_verify.py `
|
||||||
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
|
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
|
||||||
--backend-dir . `
|
--backend-dir . `
|
||||||
--backend-url http://127.0.0.1:8080/api/health `
|
--backend-url http://127.0.0.1:8080/api/v1/health `
|
||||||
--frontend-cmd "npm run dev -- --host 127.0.0.1" `
|
--frontend-cmd "npm run dev -- --host 127.0.0.1" `
|
||||||
--frontend-dir frontend `
|
--frontend-dir frontend `
|
||||||
--frontend-url http://127.0.0.1:5173/ `
|
--frontend-url http://127.0.0.1:5173/ `
|
||||||
--check-url health=http://127.0.0.1:8080/api/health
|
--check-url summary=http://127.0.0.1:8080/api/v1/users/summary
|
||||||
```
|
```
|
||||||
|
|
||||||
4. 按递进层级验证应用。
|
4. 按递进层级验证应用。
|
||||||
- 确认两个健康检查地址都返回可接受的 HTTP 状态,并确认对应进程仍在运行。
|
- 快速路径先确认两个健康检查地址和 1 至 2 个主要只读 API;脚本会并行执行等待和额外检查。
|
||||||
- 针对主要用户流程执行聚焦的后端或 API 检查。除非用户明确要求写入流程且测试数据安全,否则优先执行只读检查。
|
- 只有代码变更、用户明确要求回归,或快速路径暴露问题时,才执行前端构建、后端测试等较慢命令。
|
||||||
- 在相关且不会重复更权威检查的情况下,执行前端构建或项目已有测试命令。
|
- 只有涉及页面或交互变更且有浏览器工具时,才执行浏览器冒烟:加载页面、完成最小有意义的操作,同时验证页面可见结果和网络/API 结果。
|
||||||
- 如果有 Playwright 或其他浏览器工具,打开前端地址并执行主要用户路径:加载页面、完成最小有意义的操作,同时验证页面可见结果和网络/API 结果。
|
- 除非用户明确要求写入流程且测试数据安全,否则不执行新增、修改、删除或数据库迁移。
|
||||||
- 如果没有浏览器工具,应明确说明未验证 UI 交互;仅凭 HTTP 检查不能声称完成了端到端验证。
|
- 如果没有浏览器工具,应明确说明未验证 UI 交互;仅凭 HTTP 检查不能声称完成了端到端验证。
|
||||||
|
|
||||||
5. 汇报并清理环境。
|
5. 汇报并清理环境。
|
||||||
- 汇报具体命令、地址、执行的检查、通过或失败状态,以及第一个可执行的失败原因。
|
- 汇报验证任务 ID、具体命令、地址、执行的检查、通过或失败状态,以及第一个可执行的失败原因。
|
||||||
- 分开汇报基础设施、后端、前端和浏览器结果,明确哪些部分已通过。
|
- 分开汇报基础设施、后端、前端和浏览器结果,明确哪些部分已通过。
|
||||||
- 失败时给出日志位置或相关日志尾部,同时隐藏密码、令牌和连接字符串。
|
- 失败时给出日志位置或相关日志尾部,同时隐藏密码、令牌和连接字符串。
|
||||||
- 除非用户要求服务持续运行,否则在结束前确认脚本启动的进程已经停止。
|
- 除非用户要求服务持续运行,否则在结束前确认脚本启动的进程已经停止。
|
||||||
@@ -64,4 +64,4 @@ description: 启动本地项目的前端和后端服务,并通过进程状态
|
|||||||
|
|
||||||
## 内置脚本
|
## 内置脚本
|
||||||
|
|
||||||
`scripts/start_and_verify.py` 是一个不依赖第三方库的进程运行器,负责本流程中的启动和 HTTP 检查。使用不常见选项前先阅读 `--help` 输出。脚本默认创建临时日志并在清理后删除;排查问题时传入 `--keep-logs` 保留日志。
|
`scripts/start_and_verify.py` 是一个不依赖第三方库的进程运行器,负责本流程中的启动和 HTTP 检查。脚本会为检查请求添加 `X-Request-Id`,输出验证任务 ID,并行等待服务与执行额外检查。使用不常见选项前先阅读 `--help` 输出。脚本默认创建临时日志并在清理后删除;排查问题时传入 `--keep-logs` 保留日志。错误日志摘要会脱敏,但保留日志前仍需确认服务自身没有输出敏感信息。
|
||||||
@@ -4,7 +4,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -13,6 +15,7 @@ import tempfile
|
|||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -28,6 +31,13 @@ class Service:
|
|||||||
log_file: object | None = None
|
log_file: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
SENSITIVE_VALUE_PATTERN = re.compile(
|
||||||
|
r"(?i)(password|token|secret|authorization|api[_-]?key)"
|
||||||
|
r"(\s*[\"']?\s*[:=]\s*[\"']?)([^\"'\s,;}\]]+)"
|
||||||
|
)
|
||||||
|
BEARER_PATTERN = re.compile(r"(?i)(Bearer\s+)[^\s,;]+")
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="启动前后端命令,检查 HTTP 地址并清理进程。"
|
description="启动前后端命令,检查 HTTP 地址并清理进程。"
|
||||||
@@ -54,8 +64,14 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--interval",
|
"--interval",
|
||||||
type=float,
|
type=float,
|
||||||
default=1.0,
|
default=0.25,
|
||||||
help="每次就绪检查的间隔秒数,默认 1",
|
help="每次就绪检查的间隔秒数,默认 0.25",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--check-timeout",
|
||||||
|
type=float,
|
||||||
|
default=5.0,
|
||||||
|
help="额外接口检查的单请求超时时间,默认 5 秒",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--log-dir",
|
"--log-dir",
|
||||||
@@ -110,8 +126,14 @@ def start_service(service: Service, log_dir: Path) -> None:
|
|||||||
print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}")
|
print(f"启动 {service.name}:进程={service.process.pid},工作目录={service.cwd}")
|
||||||
|
|
||||||
|
|
||||||
def request(url: str, timeout: float) -> tuple[bool, str]:
|
def request(url: str, timeout: float, request_id: str) -> tuple[bool, str]:
|
||||||
request_obj = urllib.request.Request(url, headers={"User-Agent": "fullstack-start-verify/1.0"})
|
request_obj = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"User-Agent": "fullstack-start-verify/1.1",
|
||||||
|
"X-Request-Id": request_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(request_obj, timeout=timeout) as response:
|
with urllib.request.urlopen(request_obj, timeout=timeout) as response:
|
||||||
status = response.status
|
status = response.status
|
||||||
@@ -123,7 +145,9 @@ def request(url: str, timeout: float) -> tuple[bool, str]:
|
|||||||
return False, type(reason).__name__
|
return False, type(reason).__name__
|
||||||
|
|
||||||
|
|
||||||
def wait_for_service(service: Service, timeout: float, interval: float) -> None:
|
def wait_for_service(
|
||||||
|
service: Service, timeout: float, interval: float, run_id: str
|
||||||
|
) -> None:
|
||||||
if service.process is None:
|
if service.process is None:
|
||||||
raise RuntimeError(f"{service.name} 未启动")
|
raise RuntimeError(f"{service.name} 未启动")
|
||||||
|
|
||||||
@@ -134,12 +158,13 @@ def wait_for_service(service: Service, timeout: float, interval: float) -> None:
|
|||||||
if exit_code is not None:
|
if exit_code is not None:
|
||||||
raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}")
|
raise RuntimeError(f"{service.name} 在就绪检查前退出,退出码为 {exit_code}")
|
||||||
|
|
||||||
ok, reason = request(service.url, min(interval, 5.0))
|
request_timeout = min(max(interval * 2, 0.5), 5.0)
|
||||||
|
ok, reason = request(service.url, request_timeout, f"{run_id}-{service.name}")
|
||||||
last_reason = reason
|
last_reason = reason
|
||||||
if ok:
|
if ok:
|
||||||
print(f"通过 {service.name}:{service.url} [{reason}]")
|
print(f"通过 {service.name}:{service.url} [{reason}]")
|
||||||
return
|
return
|
||||||
time.sleep(max(interval, 0.05))
|
time.sleep(min(max(interval, 0.05), max(deadline - time.monotonic(), 0)))
|
||||||
|
|
||||||
raise RuntimeError(f"{service.name} 在 {timeout:g} 秒内未就绪 [{last_reason}]")
|
raise RuntimeError(f"{service.name} 在 {timeout:g} 秒内未就绪 [{last_reason}]")
|
||||||
|
|
||||||
@@ -159,9 +184,43 @@ def parse_extra_checks(values: list[str]) -> list[tuple[str, str]]:
|
|||||||
return checks
|
return checks
|
||||||
|
|
||||||
|
|
||||||
def run_extra_checks(checks: list[tuple[str, str]]) -> None:
|
def wait_for_services(
|
||||||
for label, url in checks:
|
services: list[Service], timeout: float, interval: float, run_id: str
|
||||||
ok, reason = request(url, 10.0)
|
) -> None:
|
||||||
|
"""并行等待服务,整体耗时取最慢服务的就绪时间。"""
|
||||||
|
executor = concurrent.futures.ThreadPoolExecutor(max_workers=len(services))
|
||||||
|
failed = True
|
||||||
|
try:
|
||||||
|
futures = [
|
||||||
|
executor.submit(wait_for_service, service, timeout, interval, run_id)
|
||||||
|
for service in services
|
||||||
|
]
|
||||||
|
done, _ = concurrent.futures.wait(
|
||||||
|
futures, return_when=concurrent.futures.FIRST_EXCEPTION
|
||||||
|
)
|
||||||
|
for future in done:
|
||||||
|
future.result()
|
||||||
|
failed = False
|
||||||
|
finally:
|
||||||
|
executor.shutdown(wait=not failed, cancel_futures=failed)
|
||||||
|
|
||||||
|
|
||||||
|
def run_extra_checks(
|
||||||
|
checks: list[tuple[str, str]], timeout: float, run_id: str
|
||||||
|
) -> None:
|
||||||
|
"""并行执行只读接口检查,避免多个接口检查串行等待。"""
|
||||||
|
if not checks:
|
||||||
|
return
|
||||||
|
|
||||||
|
def execute_check(
|
||||||
|
item: tuple[int, tuple[str, str]]
|
||||||
|
) -> tuple[str, str, tuple[bool, str]]:
|
||||||
|
index, (label, url) = item
|
||||||
|
return label, url, request(url, timeout, f"{run_id}-check-{index}")
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=len(checks)) as executor:
|
||||||
|
results = list(executor.map(execute_check, enumerate(checks, start=1)))
|
||||||
|
for label, url, (ok, reason) in results:
|
||||||
if not ok:
|
if not ok:
|
||||||
raise RuntimeError(f"检查失败:{label} {url} [{reason}]")
|
raise RuntimeError(f"检查失败:{label} {url} [{reason}]")
|
||||||
print(f"通过 {label}:{url} [{reason}]")
|
print(f"通过 {label}:{url} [{reason}]")
|
||||||
@@ -173,17 +232,24 @@ def stop_service(service: Service) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
subprocess.run(
|
|
||||||
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
process.wait(timeout=5)
|
subprocess.run(
|
||||||
|
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
check=False,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
process.wait(timeout=1)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
process.kill()
|
process.kill()
|
||||||
process.wait(timeout=5)
|
try:
|
||||||
|
process.wait(timeout=1)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.returncode = -1
|
||||||
except OSError:
|
except OSError:
|
||||||
# taskkill 已经结束进程,但 Windows 可能同时使句柄失效。
|
# taskkill 已经结束进程,但 Windows 可能同时使句柄失效。
|
||||||
process.returncode = -1
|
process.returncode = -1
|
||||||
@@ -204,8 +270,19 @@ def tail(path: Path | None, lines: int = 20) -> list[str]:
|
|||||||
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
|
return path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:]
|
||||||
|
|
||||||
|
|
||||||
|
def redact(line: str) -> str:
|
||||||
|
"""仅在终端输出日志时脱敏,避免错误摘要泄露凭据。"""
|
||||||
|
line = SENSITIVE_VALUE_PATTERN.sub(r"\1\2[REDACTED]", line)
|
||||||
|
return BEARER_PATTERN.sub(r"\1[REDACTED]", line)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
if args.timeout <= 0 or args.interval <= 0 or args.check_timeout <= 0:
|
||||||
|
raise SystemExit("timeout、interval 和 check-timeout 必须大于 0")
|
||||||
|
run_id = uuid.uuid4().hex[:12]
|
||||||
|
print(f"验证任务 ID:{run_id}")
|
||||||
|
checks = parse_extra_checks(args.check_url)
|
||||||
temp_log_dir: Path | None = None
|
temp_log_dir: Path | None = None
|
||||||
log_dir = args.log_dir
|
log_dir = args.log_dir
|
||||||
if log_dir is None:
|
if log_dir is None:
|
||||||
@@ -223,17 +300,16 @@ def main() -> int:
|
|||||||
try:
|
try:
|
||||||
for service in services:
|
for service in services:
|
||||||
start_service(service, log_dir)
|
start_service(service, log_dir)
|
||||||
for service in services:
|
wait_for_services(services, args.timeout, args.interval, run_id)
|
||||||
wait_for_service(service, args.timeout, args.interval)
|
run_extra_checks(checks, args.check_timeout, run_id)
|
||||||
run_extra_checks(parse_extra_checks(args.check_url))
|
print(f"全栈验证通过(FULLSTACK_VERIFY=PASS,任务 ID:{run_id})")
|
||||||
print("全栈验证通过(FULLSTACK_VERIFY=PASS)")
|
|
||||||
return 0
|
return 0
|
||||||
except (OSError, RuntimeError, ValueError) as error:
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
failure = error
|
failure = error
|
||||||
print(f"全栈验证失败:{error}", file=sys.stderr)
|
print(f"全栈验证失败:{error}", file=sys.stderr)
|
||||||
for service in services:
|
for service in services:
|
||||||
for line in tail(service.log_path):
|
for line in tail(service.log_path):
|
||||||
print(f"日志 {service.name}:{line}", file=sys.stderr)
|
print(f"日志 {service.name}:{redact(line)}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
if not args.keep_running:
|
if not args.keep_running:
|
||||||
@@ -100,7 +100,7 @@ mvn -f backend/pom.xml verify
|
|||||||
从项目根目录执行:
|
从项目根目录执行:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
python .codex/skills/fullstack-start-verify/scripts/start_and_verify.py `
|
python skills/fullstack-start-verify/scripts/start_and_verify.py `
|
||||||
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
|
--backend-cmd "mvn -f backend/pom.xml spring-boot:run" `
|
||||||
--backend-dir . `
|
--backend-dir . `
|
||||||
--backend-url http://127.0.0.1:8080/api/v1/health `
|
--backend-url http://127.0.0.1:8080/api/v1/health `
|
||||||
@@ -108,7 +108,7 @@ python .codex/skills/fullstack-start-verify/scripts/start_and_verify.py `
|
|||||||
--frontend-dir frontend `
|
--frontend-dir frontend `
|
||||||
--frontend-url http://127.0.0.1:5173/ `
|
--frontend-url http://127.0.0.1:5173/ `
|
||||||
--check-url summary=http://127.0.0.1:8080/api/v1/users/summary `
|
--check-url summary=http://127.0.0.1:8080/api/v1/users/summary `
|
||||||
--check-url "users=http://127.0.0.1:8080/api/v1/users?page=1& pageSize=50"
|
--check-url "users=http://127.0.0.1:8080/api/v1/users?page=1&pageSize=50&keyword=&role=&status="
|
||||||
```
|
```
|
||||||
|
|
||||||
验证期间只执行健康检查和 GET 查询,不执行新增、修改、删除或数据库迁移。脚本默认清理本次启动的进程;排查启动失败时增加 `--keep-logs` 保留日志,并确认日志中没有敏感信息。
|
验证期间只执行健康检查和 GET 查询,不执行新增、修改、删除或数据库迁移。脚本默认清理本次启动的进程;排查启动失败时增加 `--keep-logs` 保留日志,并确认日志中没有敏感信息。
|
||||||
|
|||||||
Reference in New Issue
Block a user