From 963122b9162740f1b1ae8baf983ca59362b4c791 Mon Sep 17 00:00:00 2001 From: zenfun Date: Mon, 16 Feb 2026 02:38:01 +0800 Subject: [PATCH] chore: update gitignore, Makefile, skills route, and test scaffolding --- .gitignore | 4 + Makefile | 11 +- astrbot/dashboard/routes/skills.py | 35 +++++- openspec/config.yaml | 20 ++++ scripts/pr_test_env.sh | 171 +++++++++++++++++++++++++++++ tests/test_main.py | 29 ++++- tests/test_plugin_manager.py | 2 + 7 files changed, 266 insertions(+), 6 deletions(-) create mode 100644 openspec/config.yaml create mode 100755 scripts/pr_test_env.sh diff --git a/.gitignore b/.gitignore index e060b85a6..85da68ebb 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,7 @@ IFLOW.md # genie_tts data CharacterModels/ GenieData/ +.agent/ +.codex/ +.opencode/ +.kilocode/ diff --git a/Makefile b/Makefile index d8fdb04ba..1a981e537 100644 --- a/Makefile +++ b/Makefile @@ -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 BRANCH ?= $(word 2,$(MAKECMDGOALS)) @@ -27,6 +27,15 @@ endif echo "Worktree $(WORKTREE_DIR)/$(BRANCH) not found."; \ 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 %: @true diff --git a/astrbot/dashboard/routes/skills.py b/astrbot/dashboard/routes/skills.py index 327cc6f41..425d2a8c9 100644 --- a/astrbot/dashboard/routes/skills.py +++ b/astrbot/dashboard/routes/skills.py @@ -179,6 +179,7 @@ class SkillsRoute(Route): async def get_neo_candidates(self): try: + logger.info("[Neo] GET /skills/neo/candidates requested.") endpoint, access_token = self._get_neo_client_config() status = request.args.get("status") skill_key = request.args.get("skill_key") @@ -197,13 +198,17 @@ class SkillsRoute(Route): limit=limit, 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: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ async def get_neo_releases(self): try: + logger.info("[Neo] GET /skills/neo/releases requested.") endpoint, access_token = self._get_neo_client_config() skill_key = request.args.get("skill_key") stage = request.args.get("stage") @@ -224,13 +229,17 @@ class SkillsRoute(Route): limit=limit, 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: logger.error(traceback.format_exc()) return Response().error(str(e)).__dict__ async def get_neo_payload(self): try: + logger.info("[Neo] GET /skills/neo/payload requested.") endpoint, access_token = self._get_neo_client_config() payload_ref = request.args.get("payload_ref", "") if not payload_ref: @@ -243,6 +252,7 @@ class SkillsRoute(Route): access_token=access_token, ) as client: payload = await client.skills.get_payload(payload_ref) + logger.info(f"[Neo] Payload fetched: ref={payload_ref}") return Response().ok(_to_jsonable(payload)).__dict__ except Exception as e: logger.error(traceback.format_exc()) @@ -256,6 +266,7 @@ class SkillsRoute(Route): .__dict__ ) try: + logger.info("[Neo] POST /skills/neo/evaluate requested.") endpoint, access_token = self._get_neo_client_config() data = await request.get_json() candidate_id = data.get("candidate_id") @@ -277,6 +288,9 @@ class SkillsRoute(Route): benchmark_id=data.get("benchmark_id"), report=data.get("report"), ) + logger.info( + f"[Neo] Candidate evaluated: id={candidate_id}, passed={passed}" + ) return Response().ok(_to_jsonable(result)).__dict__ except Exception as e: logger.error(traceback.format_exc()) @@ -290,6 +304,7 @@ class SkillsRoute(Route): .__dict__ ) try: + logger.info("[Neo] POST /skills/neo/promote requested.") endpoint, access_token = self._get_neo_client_config() data = await request.get_json() candidate_id = data.get("candidate_id") @@ -310,6 +325,9 @@ class SkillsRoute(Route): candidate_id, stage=stage ) release_json = _to_jsonable(release) + logger.info( + f"[Neo] Candidate promoted: id={candidate_id}, stage={stage}" + ) sync_json = None if stage == "stable" and sync_to_local: @@ -329,7 +347,13 @@ class SkillsRoute(Route): "map_path": sync_result.map_path, "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: + logger.error( + f"[Neo] Stable sync failed, rolling back: {sync_err}" + ) rollback_result = await client.skills.rollback_release( str(release_json.get("id", "")) ) @@ -364,6 +388,7 @@ class SkillsRoute(Route): .__dict__ ) try: + logger.info("[Neo] POST /skills/neo/rollback requested.") endpoint, access_token = self._get_neo_client_config() data = await request.get_json() release_id = data.get("release_id") @@ -377,6 +402,7 @@ class SkillsRoute(Route): access_token=access_token, ) as client: 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__ except Exception as e: logger.error(traceback.format_exc()) @@ -390,6 +416,7 @@ class SkillsRoute(Route): .__dict__ ) try: + logger.info("[Neo] POST /skills/neo/sync requested.") endpoint, access_token = self._get_neo_client_config() data = await request.get_json() release_id = data.get("release_id") @@ -411,6 +438,10 @@ class SkillsRoute(Route): skill_key=skill_key, require_stable=require_stable, ) + logger.info( + f"[Neo] Release synced to local: skill={result.local_skill_name}, " + f"release_id={result.release_id}" + ) return ( Response() .ok( diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 000000000..392946c67 --- /dev/null +++ b/openspec/config.yaml @@ -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 diff --git a/scripts/pr_test_env.sh b/scripts/pr_test_env.sh new file mode 100755 index 000000000..23bc52979 --- /dev/null +++ b/scripts/pr_test_env.sh @@ -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 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" diff --git a/tests/test_main.py b/tests/test_main.py index 0453a51ee..29d942760 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -16,6 +16,26 @@ class _version_info: self.major = major 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): version_info_correct = _version_info(3, 10) @@ -23,9 +43,12 @@ def test_check_env(monkeypatch): monkeypatch.setattr(sys, "version_info", version_info_correct) with mock.patch("os.makedirs") as mock_makedirs: check_env() - mock_makedirs.assert_any_call("data/config", exist_ok=True) - mock_makedirs.assert_any_call("data/plugins", exist_ok=True) - mock_makedirs.assert_any_call("data/temp", exist_ok=True) + # check_env uses get_astrbot_*_path() which returns absolute paths, + # so just verify makedirs was called the expected number of times + 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) with pytest.raises(SystemExit): diff --git a/tests/test_plugin_manager.py b/tests/test_plugin_manager.py index 1e4cd866a..8efe83dd0 100644 --- a/tests/test_plugin_manager.py +++ b/tests/test_plugin_manager.py @@ -40,6 +40,7 @@ def plugin_manager_pm(tmp_path): persona_manager = MagicMock() astrbot_config_mgr = MagicMock() knowledge_base_manager = MagicMock() + cron_manager = MagicMock() star_context = Context( event_queue, @@ -52,6 +53,7 @@ def plugin_manager_pm(tmp_path): persona_manager, astrbot_config_mgr, knowledge_base_manager=knowledge_base_manager, + cron_manager=cron_manager, ) # Create the PluginManager instance