chore: update gitignore, Makefile, skills route, and test scaffolding
This commit is contained in:
@@ -61,3 +61,7 @@ IFLOW.md
|
||||
# genie_tts data
|
||||
CharacterModels/
|
||||
GenieData/
|
||||
.agent/
|
||||
.codex/
|
||||
.opencode/
|
||||
.kilocode/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
Executable
+171
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user