chore: update gitignore, Makefile, skills route, and test scaffolding

This commit is contained in:
zenfun
2026-02-16 02:38:01 +08:00
parent aa3b012d60
commit 963122b916
7 changed files with 266 additions and 6 deletions
+4
View File
@@ -61,3 +61,7 @@ IFLOW.md
# genie_tts data # genie_tts data
CharacterModels/ CharacterModels/
GenieData/ GenieData/
.agent/
.codex/
.opencode/
.kilocode/
+10 -1
View File
@@ -1,4 +1,4 @@
.PHONY: worktree worktree-add worktree-rm .PHONY: worktree worktree-add worktree-rm pr-test-neo pr-test-full pr-test-full-fast
WORKTREE_DIR ?= ../astrbot_worktree WORKTREE_DIR ?= ../astrbot_worktree
BRANCH ?= $(word 2,$(MAKECMDGOALS)) BRANCH ?= $(word 2,$(MAKECMDGOALS))
@@ -27,6 +27,15 @@ endif
echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \ echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \
fi fi
pr-test-neo:
./scripts/pr_test_env.sh --profile neo
pr-test-full:
./scripts/pr_test_env.sh --profile full
pr-test-full-fast:
./scripts/pr_test_env.sh --profile full --skip-sync --no-dashboard
# Swallow extra args (branch/base) so make doesn't treat them as targets # Swallow extra args (branch/base) so make doesn't treat them as targets
%: %:
@true @true
+33 -2
View File
@@ -179,6 +179,7 @@ class SkillsRoute(Route):
async def get_neo_candidates(self): async def get_neo_candidates(self):
try: try:
logger.info("[Neo] GET /skills/neo/candidates requested.")
endpoint, access_token = self._get_neo_client_config() endpoint, access_token = self._get_neo_client_config()
status = request.args.get("status") status = request.args.get("status")
skill_key = request.args.get("skill_key") skill_key = request.args.get("skill_key")
@@ -197,13 +198,17 @@ class SkillsRoute(Route):
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
return Response().ok(_to_jsonable(candidates)).__dict__ result = _to_jsonable(candidates)
total = result.get("total", "?") if isinstance(result, dict) else "?"
logger.info(f"[Neo] Candidates fetched: total={total}")
return Response().ok(result).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
async def get_neo_releases(self): async def get_neo_releases(self):
try: try:
logger.info("[Neo] GET /skills/neo/releases requested.")
endpoint, access_token = self._get_neo_client_config() endpoint, access_token = self._get_neo_client_config()
skill_key = request.args.get("skill_key") skill_key = request.args.get("skill_key")
stage = request.args.get("stage") stage = request.args.get("stage")
@@ -224,13 +229,17 @@ class SkillsRoute(Route):
limit=limit, limit=limit,
offset=offset, offset=offset,
) )
return Response().ok(_to_jsonable(releases)).__dict__ result = _to_jsonable(releases)
total = result.get("total", "?") if isinstance(result, dict) else "?"
logger.info(f"[Neo] Releases fetched: total={total}")
return Response().ok(result).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
async def get_neo_payload(self): async def get_neo_payload(self):
try: try:
logger.info("[Neo] GET /skills/neo/payload requested.")
endpoint, access_token = self._get_neo_client_config() endpoint, access_token = self._get_neo_client_config()
payload_ref = request.args.get("payload_ref", "") payload_ref = request.args.get("payload_ref", "")
if not payload_ref: if not payload_ref:
@@ -243,6 +252,7 @@ class SkillsRoute(Route):
access_token=access_token, access_token=access_token,
) as client: ) as client:
payload = await client.skills.get_payload(payload_ref) payload = await client.skills.get_payload(payload_ref)
logger.info(f"[Neo] Payload fetched: ref={payload_ref}")
return Response().ok(_to_jsonable(payload)).__dict__ return Response().ok(_to_jsonable(payload)).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -256,6 +266,7 @@ class SkillsRoute(Route):
.__dict__ .__dict__
) )
try: try:
logger.info("[Neo] POST /skills/neo/evaluate requested.")
endpoint, access_token = self._get_neo_client_config() endpoint, access_token = self._get_neo_client_config()
data = await request.get_json() data = await request.get_json()
candidate_id = data.get("candidate_id") candidate_id = data.get("candidate_id")
@@ -277,6 +288,9 @@ class SkillsRoute(Route):
benchmark_id=data.get("benchmark_id"), benchmark_id=data.get("benchmark_id"),
report=data.get("report"), report=data.get("report"),
) )
logger.info(
f"[Neo] Candidate evaluated: id={candidate_id}, passed={passed}"
)
return Response().ok(_to_jsonable(result)).__dict__ return Response().ok(_to_jsonable(result)).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -290,6 +304,7 @@ class SkillsRoute(Route):
.__dict__ .__dict__
) )
try: try:
logger.info("[Neo] POST /skills/neo/promote requested.")
endpoint, access_token = self._get_neo_client_config() endpoint, access_token = self._get_neo_client_config()
data = await request.get_json() data = await request.get_json()
candidate_id = data.get("candidate_id") candidate_id = data.get("candidate_id")
@@ -310,6 +325,9 @@ class SkillsRoute(Route):
candidate_id, stage=stage candidate_id, stage=stage
) )
release_json = _to_jsonable(release) release_json = _to_jsonable(release)
logger.info(
f"[Neo] Candidate promoted: id={candidate_id}, stage={stage}"
)
sync_json = None sync_json = None
if stage == "stable" and sync_to_local: if stage == "stable" and sync_to_local:
@@ -329,7 +347,13 @@ class SkillsRoute(Route):
"map_path": sync_result.map_path, "map_path": sync_result.map_path,
"synced_at": sync_result.synced_at, "synced_at": sync_result.synced_at,
} }
logger.info(
f"[Neo] Stable release synced to local: skill={sync_result.local_skill_name}"
)
except Exception as sync_err: except Exception as sync_err:
logger.error(
f"[Neo] Stable sync failed, rolling back: {sync_err}"
)
rollback_result = await client.skills.rollback_release( rollback_result = await client.skills.rollback_release(
str(release_json.get("id", "")) str(release_json.get("id", ""))
) )
@@ -364,6 +388,7 @@ class SkillsRoute(Route):
.__dict__ .__dict__
) )
try: try:
logger.info("[Neo] POST /skills/neo/rollback requested.")
endpoint, access_token = self._get_neo_client_config() endpoint, access_token = self._get_neo_client_config()
data = await request.get_json() data = await request.get_json()
release_id = data.get("release_id") release_id = data.get("release_id")
@@ -377,6 +402,7 @@ class SkillsRoute(Route):
access_token=access_token, access_token=access_token,
) as client: ) as client:
result = await client.skills.rollback_release(release_id) result = await client.skills.rollback_release(release_id)
logger.info(f"[Neo] Release rolled back: id={release_id}")
return Response().ok(_to_jsonable(result)).__dict__ return Response().ok(_to_jsonable(result)).__dict__
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
@@ -390,6 +416,7 @@ class SkillsRoute(Route):
.__dict__ .__dict__
) )
try: try:
logger.info("[Neo] POST /skills/neo/sync requested.")
endpoint, access_token = self._get_neo_client_config() endpoint, access_token = self._get_neo_client_config()
data = await request.get_json() data = await request.get_json()
release_id = data.get("release_id") release_id = data.get("release_id")
@@ -411,6 +438,10 @@ class SkillsRoute(Route):
skill_key=skill_key, skill_key=skill_key,
require_stable=require_stable, require_stable=require_stable,
) )
logger.info(
f"[Neo] Release synced to local: skill={result.local_skill_name}, "
f"release_id={result.release_id}"
)
return ( return (
Response() Response()
.ok( .ok(
+20
View File
@@ -0,0 +1,20 @@
schema: spec-driven
# Project context (optional)
# This is shown to AI when creating artifacts.
# Add your tech stack, conventions, style guides, domain knowledge, etc.
# Example:
# context: |
# Tech stack: TypeScript, React, Node.js
# We use conventional commits
# Domain: e-commerce platform
# Per-artifact rules (optional)
# Add custom rules for specific artifacts.
# Example:
# rules:
# proposal:
# - Keep proposals under 500 words
# - Always include a "Non-goals" section
# tasks:
# - Break tasks into chunks of max 2 hours
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"
PROFILE="neo"
RUN_SYNC=true
RUN_LINT=true
RUN_SMOKE=true
RUN_DASHBOARD=false
usage() {
cat <<'EOF'
Usage:
scripts/pr_test_env.sh [options]
Options:
--profile <neo|full> Test profile. Default: neo
--with-dashboard Build dashboard before finishing checks
--no-dashboard Disable dashboard build (even for full profile)
--skip-sync Skip `uv sync`
--skip-lint Skip `ruff format --check` and `ruff check`
--skip-smoke Skip startup smoke test
-h, --help Show this help message
Environment:
PYTEST_ARGS Extra args appended to pytest command
EOF
}
while (($# > 0)); do
case "$1" in
--profile)
PROFILE="${2:-}"
if [[ "$PROFILE" != "neo" && "$PROFILE" != "full" ]]; then
echo "Unsupported profile: $PROFILE" >&2
exit 1
fi
shift 2
;;
--with-dashboard)
RUN_DASHBOARD=true
shift
;;
--skip-sync)
RUN_SYNC=false
shift
;;
--skip-lint)
RUN_LINT=false
shift
;;
--skip-smoke)
RUN_SMOKE=false
shift
;;
--no-dashboard)
RUN_DASHBOARD=false
shift
;;
-h | --help)
usage
exit 0
;;
*)
echo "Unknown option: $1" >&2
usage
exit 1
;;
esac
done
if [[ "$PROFILE" == "full" && "$RUN_DASHBOARD" == false ]]; then
RUN_DASHBOARD=true
fi
echo "==> Profile: $PROFILE"
echo "==> Sync dependencies: $RUN_SYNC"
echo "==> Run lint: $RUN_LINT"
echo "==> Run smoke test: $RUN_SMOKE"
echo "==> Build dashboard: $RUN_DASHBOARD"
if [[ "$RUN_SYNC" == true ]]; then
echo "==> Syncing dependencies with uv"
uv sync --group dev
fi
echo "==> Preparing test directories"
mkdir -p data/plugins data/config data/temp data/skills
export TESTING="${TESTING:-true}"
export ZHIPU_API_KEY="${ZHIPU_API_KEY:-test-api-key}"
if [[ "$RUN_LINT" == true ]]; then
echo "==> Running Ruff format check"
uv run ruff format --check .
echo "==> Running Ruff lint check"
uv run ruff check .
fi
echo "==> Running pytest"
if [[ "$PROFILE" == "neo" ]]; then
NEO_TESTS=(
"tests/test_neo_skill_sync.py"
"tests/test_neo_skill_tools.py"
"tests/test_computer_skill_sync.py"
"tests/test_skill_manager_sandbox_cache.py"
"tests/test_dashboard.py::test_neo_skills_routes"
)
uv run pytest -q "${NEO_TESTS[@]}" ${PYTEST_ARGS:-}
else
uv run pytest --cov=. -v -o log_cli=true -o log_level=DEBUG ${PYTEST_ARGS:-}
fi
run_smoke_test() {
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required for smoke test." >&2
return 1
fi
local smoke_port="6185"
local smoke_log
smoke_log="$(mktemp -t astrbot-smoke.XXXXXX.log)"
echo "==> Starting smoke test on http://localhost:${smoke_port}"
uv run main.py >"$smoke_log" 2>&1 &
local app_pid=$!
for _ in $(seq 1 60); do
if curl -sf "http://localhost:${smoke_port}" >/dev/null 2>&1; then
echo "==> Smoke test passed"
kill "$app_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
rm -f "$smoke_log"
return 0
fi
if ! kill -0 "$app_pid" 2>/dev/null; then
echo "AstrBot process exited before becoming healthy." >&2
tail -n 60 "$smoke_log" || true
rm -f "$smoke_log"
return 1
fi
sleep 1
done
echo "Smoke test failed: health endpoint did not become ready in time." >&2
tail -n 60 "$smoke_log" || true
kill "$app_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
rm -f "$smoke_log"
return 1
}
if [[ "$RUN_SMOKE" == true ]]; then
run_smoke_test
fi
if [[ "$RUN_DASHBOARD" == true ]]; then
if ! command -v pnpm >/dev/null 2>&1; then
echo "pnpm is required for dashboard build. Install it with: npm install -g pnpm" >&2
exit 1
fi
echo "==> Building dashboard"
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir dashboard run build
fi
echo "==> PR checks completed successfully"
+26 -3
View File
@@ -16,6 +16,26 @@ class _version_info:
self.major = major self.major = major
self.minor = minor self.minor = minor
def __ge__(self, other):
if isinstance(other, tuple):
return (self.major, self.minor) >= other[:2]
return NotImplemented
def __le__(self, other):
if isinstance(other, tuple):
return (self.major, self.minor) <= other[:2]
return NotImplemented
def __gt__(self, other):
if isinstance(other, tuple):
return (self.major, self.minor) > other[:2]
return NotImplemented
def __lt__(self, other):
if isinstance(other, tuple):
return (self.major, self.minor) < other[:2]
return NotImplemented
def test_check_env(monkeypatch): def test_check_env(monkeypatch):
version_info_correct = _version_info(3, 10) version_info_correct = _version_info(3, 10)
@@ -23,9 +43,12 @@ def test_check_env(monkeypatch):
monkeypatch.setattr(sys, "version_info", version_info_correct) monkeypatch.setattr(sys, "version_info", version_info_correct)
with mock.patch("os.makedirs") as mock_makedirs: with mock.patch("os.makedirs") as mock_makedirs:
check_env() check_env()
mock_makedirs.assert_any_call("data/config", exist_ok=True) # check_env uses get_astrbot_*_path() which returns absolute paths,
mock_makedirs.assert_any_call("data/plugins", exist_ok=True) # so just verify makedirs was called the expected number of times
mock_makedirs.assert_any_call("data/temp", exist_ok=True) assert mock_makedirs.call_count >= 4
# Verify all calls used exist_ok=True
for call_args in mock_makedirs.call_args_list:
assert call_args[1].get("exist_ok") is True
monkeypatch.setattr(sys, "version_info", version_info_wrong) monkeypatch.setattr(sys, "version_info", version_info_wrong)
with pytest.raises(SystemExit): with pytest.raises(SystemExit):
+2
View File
@@ -40,6 +40,7 @@ def plugin_manager_pm(tmp_path):
persona_manager = MagicMock() persona_manager = MagicMock()
astrbot_config_mgr = MagicMock() astrbot_config_mgr = MagicMock()
knowledge_base_manager = MagicMock() knowledge_base_manager = MagicMock()
cron_manager = MagicMock()
star_context = Context( star_context = Context(
event_queue, event_queue,
@@ -52,6 +53,7 @@ def plugin_manager_pm(tmp_path):
persona_manager, persona_manager,
astrbot_config_mgr, astrbot_config_mgr,
knowledge_base_manager=knowledge_base_manager, knowledge_base_manager=knowledge_base_manager,
cron_manager=cron_manager,
) )
# Create the PluginManager instance # Create the PluginManager instance