mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-15 07:41:20 +00:00
test(ci): run bundled extension behavior suites outside the core shards (#8180)
Bundled examples keep their behavior tests in backend/tests/, so the core backend workflow ran optional extension behavior on every pull request. Mark those suites with pytest.mark.bundled_extension, exclude them from the core shards and from the duration baseline, and run them in a dedicated workflow through make test-bundled-extensions. Tests of host mechanisms stay in the core shards even when an example package is the payload. The marked suites import deerflow.agents, config, runtime, knowledge and app.gateway, so the workflow triggers for backend/packages/**, backend/app/gateway/** and the marked test modules; a backend test pins those paths, the make target and both marker selections, and fails when a marked module is missing from the path list. Host/extension compatibility checks — a host API gate and authorization of contributed routes — live in test_extension_host_contracts.py so they still fail core CI. The committed duration baseline drops the 482 entries for the excluded suites. Refs #8173 Co-authored-by: REI-330 <173389910+REI-330@users.noreply.github.com> Co-authored-by: willem-bd <143703838+willem-bd@users.noreply.github.com> Co-authored-by: Eilen6316 <136898293+Eilen6316@users.noreply.github.com>
This commit is contained in:
parent
cee54cdd49
commit
9cb2bde557
104
.github/workflows/bundled-extension-tests.yml
vendored
Normal file
104
.github/workflows/bundled-extension-tests.yml
vendored
Normal file
@ -0,0 +1,104 @@
|
||||
name: Bundled Extension Tests
|
||||
|
||||
# Bundled extension examples own their behavior suites, which carry the
|
||||
# `bundled_extension` marker. The core backend shards exclude them so an
|
||||
# optional example cannot fail an unrelated core PR; this workflow runs them
|
||||
# whenever an example, the extension contract, the host they load into or the
|
||||
# bundled tests change.
|
||||
#
|
||||
# The filter is deliberately bounded: the marked modules import `deerflow.*`
|
||||
# (packages) and `app.gateway.*`, so only the Gateway subtree is listed rather
|
||||
# than all of `backend/app`. Test files are listed one by one instead of
|
||||
# `backend/tests/**`, and `test_bundled_extension_ci.py` fails when a module
|
||||
# carries the marker without appearing here, so this list cannot drift.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'examples/deerflow-extension-*/**'
|
||||
- 'backend/packages/**'
|
||||
- 'backend/app/gateway/**'
|
||||
- 'backend/tests/conftest.py'
|
||||
- 'backend/tests/support/**'
|
||||
- 'backend/tests/test_agent_teams_plugin.py'
|
||||
- 'backend/tests/test_bookmark_plugin.py'
|
||||
- 'backend/tests/test_bundled_extension_ci.py'
|
||||
- 'backend/tests/test_jev_classify_extension.py'
|
||||
- 'backend/tests/test_jev_context_extension.py'
|
||||
- 'backend/tests/test_jev_result_screening_extension.py'
|
||||
- 'backend/tests/test_jev_screening_pipeline.py'
|
||||
- 'backend/tests/test_jev_screening_policy.py'
|
||||
- 'backend/tests/test_knowledge_center_extension.py'
|
||||
- 'backend/tests/test_knowledge_management_extension.py'
|
||||
- 'backend/tests/test_knowledge_platform_extension.py'
|
||||
- 'backend/tests/test_lightrag_relations_extension.py'
|
||||
- 'backend/tests/test_report_sections_extension.py'
|
||||
- 'backend/tests/test_retrieval_plugin_artifacts.py'
|
||||
- 'backend/tests/test_weknora_extension.py'
|
||||
- 'backend/tests/test_workflow_plugin.py'
|
||||
- 'backend/pyproject.toml'
|
||||
- 'backend/uv.lock'
|
||||
- 'backend/Makefile'
|
||||
- '.github/workflows/bundled-extension-tests.yml'
|
||||
push:
|
||||
branches: [main, "*-dev"]
|
||||
paths:
|
||||
- 'examples/deerflow-extension-*/**'
|
||||
- 'backend/packages/**'
|
||||
- 'backend/app/gateway/**'
|
||||
- 'backend/tests/conftest.py'
|
||||
- 'backend/tests/support/**'
|
||||
- 'backend/tests/test_agent_teams_plugin.py'
|
||||
- 'backend/tests/test_bookmark_plugin.py'
|
||||
- 'backend/tests/test_bundled_extension_ci.py'
|
||||
- 'backend/tests/test_jev_classify_extension.py'
|
||||
- 'backend/tests/test_jev_context_extension.py'
|
||||
- 'backend/tests/test_jev_result_screening_extension.py'
|
||||
- 'backend/tests/test_jev_screening_pipeline.py'
|
||||
- 'backend/tests/test_jev_screening_policy.py'
|
||||
- 'backend/tests/test_knowledge_center_extension.py'
|
||||
- 'backend/tests/test_knowledge_management_extension.py'
|
||||
- 'backend/tests/test_knowledge_platform_extension.py'
|
||||
- 'backend/tests/test_lightrag_relations_extension.py'
|
||||
- 'backend/tests/test_report_sections_extension.py'
|
||||
- 'backend/tests/test_retrieval_plugin_artifacts.py'
|
||||
- 'backend/tests/test_weknora_extension.py'
|
||||
- 'backend/tests/test_workflow_plugin.py'
|
||||
- 'backend/pyproject.toml'
|
||||
- 'backend/uv.lock'
|
||||
- 'backend/Makefile'
|
||||
- '.github/workflows/bundled-extension-tests.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: bundled-extension-tests-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
bundled-extension-tests:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
# Must match backend/Dockerfile's UV_IMAGE tag; pinned by backend/tests/test_ci_uv_version_pin.py
|
||||
version: "0.11.1"
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: uv sync --group dev
|
||||
|
||||
- name: Run bundled extension behavior tests
|
||||
working-directory: backend
|
||||
run: make test-bundled-extensions
|
||||
@ -350,7 +350,7 @@ before review.
|
||||
python -m pip install -r tests/skills/requirements.txt
|
||||
python -m pytest tests/skills -q
|
||||
|
||||
# Default backend tests (four parallel shards; excludes live and blocking-I/O tests)
|
||||
# Default backend tests (four parallel shards; excludes live, blocking-I/O and bundled-extension tests)
|
||||
cd backend
|
||||
make test
|
||||
|
||||
@ -360,6 +360,9 @@ make test TEST_JOBS=1
|
||||
# Strict blocking-I/O tests
|
||||
make test-blocking-io
|
||||
|
||||
# Behavior suites of the bundled extension examples
|
||||
make test-bundled-extensions
|
||||
|
||||
# Live DeerFlowClient integration tests (explicit opt-in)
|
||||
# Requires a valid root config.yaml and API credentials.
|
||||
make test-live
|
||||
@ -388,6 +391,7 @@ must also set `DEER_FLOW_RUN_LIVE_TESTS=1`.
|
||||
Every pull request triggers the following CI workflows:
|
||||
|
||||
- **Backend unit tests** — [.github/workflows/backend-unit-tests.yml](.github/workflows/backend-unit-tests.yml)
|
||||
- **Bundled extension tests** — [.github/workflows/bundled-extension-tests.yml](.github/workflows/bundled-extension-tests.yml) (triggered by example packages, `backend/packages/**`, `backend/app/gateway/**` and the marked test modules)
|
||||
- **Frontend unit tests** — [.github/workflows/frontend-unit-tests.yml](.github/workflows/frontend-unit-tests.yml)
|
||||
- **Frontend E2E tests** — [.github/workflows/e2e-tests.yml](.github/workflows/e2e-tests.yml) (triggered only when `frontend/` files change)
|
||||
|
||||
@ -404,13 +408,25 @@ setup also verifies SSL and SQLite imports before launching fixture gateways.
|
||||
|
||||
Backend `make test` runs four duration-balanced shards concurrently and reports
|
||||
failure if any shard fails. Use `make test TEST_JOBS=1` to run the shards sequentially.
|
||||
It excludes live external-API and blocking-I/O coverage.
|
||||
It excludes live external-API, blocking-I/O and bundled-extension coverage.
|
||||
Run `cd backend && make test-blocking-io` for strict blocking-I/O checks.
|
||||
Maintainers can run the real `DeerFlowClient` suite with `cd backend && make test-live`.
|
||||
This command requires a valid root `config.yaml` and API credentials.
|
||||
It can incur API costs and create local sandboxes, artifacts, or files.
|
||||
Direct pytest runs additionally require `DEER_FLOW_RUN_LIVE_TESTS=1`.
|
||||
|
||||
Bundled extension examples keep their behavior suites in `backend/tests/` under
|
||||
the `bundled_extension` pytest marker. The core shards exclude them so an
|
||||
optional example cannot fail an unrelated core PR, and a separate CI workflow
|
||||
runs them when an example, the public extension contract or the bundled tests
|
||||
change. Run `cd backend && make test-bundled-extensions` to exercise them
|
||||
locally. Tests that validate a host mechanism — loading, registration, the
|
||||
public extension API and authorization — stay in the core shards even when an
|
||||
example package is the payload. The host/extension compatibility checks (an
|
||||
extension requiring a newer API than the host provides, authorization of
|
||||
contributed routes) live in `backend/tests/test_extension_host_contracts.py` so
|
||||
a broken compatibility guarantee fails core CI.
|
||||
|
||||
Regression coverage includes Docker sandbox mode detection and provisioner kubeconfig-path handling tests in `backend/tests/`.
|
||||
Backend blocking-IO diagnostics are available from the repository root with
|
||||
`make detect-blocking-io`: it statically scans backend business code for
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -29,6 +29,7 @@ Read the guide for an area before you change it.
|
||||
```bash
|
||||
make test # offline suite in parallel shards
|
||||
make test-blocking-io # strict blocking-I/O suite
|
||||
make test-bundled-extensions # behavior suites of bundled examples (not in make test)
|
||||
make lint
|
||||
make format
|
||||
make test-live # calls real APIs; needs DEER_FLOW_RUN_LIVE_TESTS=1
|
||||
|
||||
@ -22,7 +22,7 @@ gateway:
|
||||
# Run all four duration-balanced shards locally; wait for every shard on failure.
|
||||
TEST_JOBS ?= 4
|
||||
TEST_SHARD_TARGETS := test-shard-1 test-shard-2 test-shard-3 test-shard-4
|
||||
.PHONY: test test-shard $(TEST_SHARD_TARGETS)
|
||||
.PHONY: test test-shard test-bundled-extensions $(TEST_SHARD_TARGETS)
|
||||
test:
|
||||
$(MAKE) --no-print-directory -k -j$(TEST_JOBS) $(TEST_SHARD_TARGETS)
|
||||
|
||||
@ -49,13 +49,21 @@ DURATIONS_FILE ?= .test_durations
|
||||
test-shard:
|
||||
@test -f "$(DURATIONS_FILE)" || \
|
||||
(echo "error: $(DURATIONS_FILE) is missing; run 'make test-shard-durations' to regenerate the duration baseline" >&2; exit 1)
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live" --ignore=tests/blocking_io --splits $(SPLITS) --group $(GROUP) --splitting-algorithm least_duration --durations-path=$(DURATIONS_FILE) tests/ -q
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live and not bundled_extension" --ignore=tests/blocking_io --splits $(SPLITS) --group $(GROUP) --splitting-algorithm least_duration --durations-path=$(DURATIONS_FILE) tests/ -q
|
||||
|
||||
# Regenerate .test_durations from the full offline (non-live, non-blocking-I/O)
|
||||
# suite so duration-aware sharding reflects real wall-clock cost. Run locally or
|
||||
# in a periodic job, then commit the refreshed file.
|
||||
# Regenerate .test_durations from the offline core suite (non-live,
|
||||
# non-blocking-I/O, non-bundled-extension) so duration-aware sharding reflects
|
||||
# real wall-clock cost. Run locally or in a periodic job, then commit the
|
||||
# refreshed file.
|
||||
test-shard-durations:
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live" --ignore=tests/blocking_io --store-durations --clean-durations --durations-path=$(DURATIONS_FILE) tests/ -q
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "not live and not bundled_extension" --ignore=tests/blocking_io --store-durations --clean-durations --durations-path=$(DURATIONS_FILE) tests/ -q
|
||||
|
||||
# Bundled extension examples own their behavior suites; the core shards exclude
|
||||
# them so an optional example cannot fail an unrelated core PR. The extension CI
|
||||
# workflow runs this target when an example, the extension contract or the
|
||||
# bundled tests themselves change.
|
||||
test-bundled-extensions:
|
||||
PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest -m "bundled_extension and not live" tests/ -q
|
||||
|
||||
lint:
|
||||
uv run ruff check .
|
||||
|
||||
@ -89,6 +89,7 @@ markers = [
|
||||
"allow_blocking_io: opt out of the strict Blockbuster gate in tests/blocking_io/",
|
||||
"integration: tests that require an external service (e.g. Redis); skipped when unavailable",
|
||||
"live: tests that call real external APIs and require explicit opt-in",
|
||||
"bundled_extension: behavior tests owned by a bundled extension example; excluded from the core shards and run by the bundled extension CI job",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@ -62,6 +62,18 @@ with a reason when the binary is missing.
|
||||
- **Circular imports are solved in `conftest.py`** with a `sys.modules` stub; see
|
||||
the existing entry for `deerflow.subagents.executor`.
|
||||
|
||||
## Bundled extension behavior
|
||||
|
||||
A module that validates a bundled example's own behavior carries
|
||||
`pytest.mark.bundled_extension` at module level, so the core shards skip it and
|
||||
the bundled extension CI runs it. Host mechanisms stay in the core shards even
|
||||
when an example package is the payload: loading and registration, the public
|
||||
extension API, authorization, routing and runtime. Host/extension compatibility
|
||||
checks live in `test_extension_host_contracts.py`, so a broken compatibility
|
||||
guarantee still fails core CI, and `test_bundled_extension_ci.py` keeps the
|
||||
workflow's test-file list in step with the marker. Run the bundled suites with
|
||||
`make test-bundled-extensions`.
|
||||
|
||||
## Blocking-I/O tests
|
||||
|
||||
Every test under `blocking_io/` runs with a detector that fails on a blocking
|
||||
|
||||
@ -11,20 +11,7 @@ from deerflow_extension_api.plugins import ActionContext, ToolContext
|
||||
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_version", ["0.2.10", "0.2.11"])
|
||||
def test_team_artifact_extension_requires_a_host_with_artifact_contracts(tmp_path, monkeypatch, host_version):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams"))
|
||||
monkeypatch.setattr("deerflow.extensions.loader.API_VERSION", host_version)
|
||||
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_agent_teams:install", config={"enabled": True, "storage_path": str(tmp_path / "teams.sqlite")})])
|
||||
|
||||
if host_version == "0.2.10":
|
||||
assert not loaded.plugins
|
||||
assert len(diagnostics) == 1
|
||||
assert "requires extension-api 0.2.11, host provides 0.2.10" in diagnostics[0].message
|
||||
else:
|
||||
assert not diagnostics
|
||||
assert len(loaded.plugins) == 1
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
def test_team_artifact_package_dependency_rejects_the_pre_artifact_api():
|
||||
|
||||
@ -14,6 +14,8 @@ from langgraph.prebuilt import ToolNode
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
from deerflow.extensions.plugin_tools import build_plugin_tools
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def bookmarks(tmp_path, monkeypatch):
|
||||
|
||||
114
backend/tests/test_bundled_extension_ci.py
Normal file
114
backend/tests/test_bundled_extension_ci.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""Keep the bundled extension CI wired to the marker and the Makefile targets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TESTS_DIR = REPO_ROOT / "backend/tests"
|
||||
WORKFLOW = REPO_ROOT / ".github/workflows/bundled-extension-tests.yml"
|
||||
MAKEFILE = REPO_ROOT / "backend/Makefile"
|
||||
MARKER = "bundled_extension"
|
||||
|
||||
# The marked suites load the bundled examples into these host subtrees, so a
|
||||
# change there has to run them even though the core shards no longer do.
|
||||
PAYLOAD_PATHS = [
|
||||
"examples/deerflow-extension-*/**",
|
||||
"backend/packages/**",
|
||||
"backend/app/gateway/**",
|
||||
]
|
||||
|
||||
# Shared test infrastructure the marked modules may use.
|
||||
SHARED_TEST_PATHS = [
|
||||
"backend/tests/conftest.py",
|
||||
"backend/tests/support/**",
|
||||
]
|
||||
|
||||
|
||||
def _workflow() -> dict:
|
||||
# BaseLoader preserves GitHub Actions' `on` key rather than treating it as
|
||||
# YAML 1.1's boolean True.
|
||||
return yaml.load(WORKFLOW.read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
|
||||
|
||||
|
||||
def _marked_modules() -> set[str]:
|
||||
# Parse the assignment instead of grepping: this file mentions the marker in
|
||||
# strings, and a marked module may live in a subdirectory.
|
||||
marked = set()
|
||||
for path in TESTS_DIR.rglob("test_*.py"):
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and any(isinstance(target, ast.Name) and target.id == "pytestmark" for target in node.targets):
|
||||
if "bundled_extension" in ast.dump(node.value):
|
||||
marked.add(path.relative_to(TESTS_DIR).as_posix())
|
||||
return marked
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["push", "pull_request"])
|
||||
def test_each_event_triggers_on_the_bundled_payloads(event: str) -> None:
|
||||
paths = _workflow()["on"][event]["paths"]
|
||||
|
||||
for path in PAYLOAD_PATHS:
|
||||
assert path in paths, f"{path} is missing from the {event} paths filter"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["push", "pull_request"])
|
||||
def test_each_marked_module_is_listed_in_the_trigger_paths(event: str) -> None:
|
||||
paths = _workflow()["on"][event]["paths"]
|
||||
marked = _marked_modules()
|
||||
|
||||
assert marked, "no module carries the marker"
|
||||
for path in SHARED_TEST_PATHS:
|
||||
assert path in paths, f"the {event} filter must cover {path}"
|
||||
for module in sorted(marked):
|
||||
assert f"backend/tests/{module}" in paths, f"{module} carries the marker but is not in the {event} trigger paths"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event", ["push", "pull_request"])
|
||||
def test_the_filter_does_not_cover_all_of_the_backend(event: str) -> None:
|
||||
paths = _workflow()["on"][event]["paths"]
|
||||
|
||||
# Broad backend subtrees would run the bundled suites for unrelated Gateway
|
||||
# or test changes; the marked modules only import app.gateway.
|
||||
assert "backend/tests/**" not in paths
|
||||
assert "backend/app/**" not in paths
|
||||
assert "backend/app/gateway/**" in paths
|
||||
|
||||
|
||||
def test_the_workflow_supersedes_in_flight_runs() -> None:
|
||||
concurrency = _workflow()["concurrency"]
|
||||
|
||||
# A rapid push must supersede the in-flight run instead of queuing another
|
||||
# full dependency install, as the sibling workflows do.
|
||||
assert concurrency["group"].startswith("bundled-extension-tests-")
|
||||
assert "${{ github.event.pull_request.number || github.ref }}" in concurrency["group"]
|
||||
assert concurrency["cancel-in-progress"] == "true"
|
||||
|
||||
|
||||
def test_the_job_runs_the_bundled_target_in_the_backend() -> None:
|
||||
steps = _workflow()["jobs"]["bundled-extension-tests"]["steps"]
|
||||
step = next((item for item in steps if item.get("run") == "make test-bundled-extensions"), None)
|
||||
|
||||
assert step is not None, "the workflow must run make test-bundled-extensions"
|
||||
assert step["working-directory"] == "backend"
|
||||
|
||||
|
||||
def test_core_and_bundled_selections_stay_complementary() -> None:
|
||||
makefile = MAKEFILE.read_text(encoding="utf-8")
|
||||
|
||||
# Core shards and their duration baseline exclude the marker; the bundled
|
||||
# target selects exactly it. Renaming either side must fail here.
|
||||
assert makefile.count('-m "not live and not bundled_extension"') == 2
|
||||
assert makefile.count('-m "bundled_extension and not live"') == 1
|
||||
|
||||
|
||||
def test_marker_is_registered() -> None:
|
||||
project = tomllib.loads((REPO_ROOT / "backend/pyproject.toml").read_text(encoding="utf-8"))
|
||||
markers = project["tool"]["pytest"]["ini_options"]["markers"]
|
||||
|
||||
assert any(marker.startswith(f"{MARKER}:") for marker in markers), f"{MARKER} must be a registered pytest marker"
|
||||
@ -204,7 +204,7 @@ def test_make_targets_keep_default_tests_offline_and_support_live_opt_in() -> No
|
||||
default_command = _dry_run_make_target("test")
|
||||
live_command = _dry_run_make_target("test-live")
|
||||
|
||||
assert 'pytest -m "not live"' in default_command
|
||||
assert 'pytest -m "not live and not bundled_extension"' in default_command
|
||||
assert "--ignore=tests/blocking_io" in default_command
|
||||
assert "tests/" in default_command
|
||||
assert LIVE_OPT_IN not in default_command
|
||||
@ -256,7 +256,7 @@ def test_ci_unit_test_workflow_runs_duration_aware_shards() -> None:
|
||||
|
||||
assert "--splits 6" in command
|
||||
assert "--group 6" in command
|
||||
assert 'pytest -m "not live"' in command
|
||||
assert 'pytest -m "not live and not bundled_extension"' in command
|
||||
assert "--ignore=tests/blocking_io" in command
|
||||
assert "--splitting-algorithm least_duration" in command
|
||||
assert "--durations-path=.test_durations" in command
|
||||
@ -267,7 +267,7 @@ def test_ci_unit_test_workflow_runs_duration_aware_shards() -> None:
|
||||
|
||||
# The full-suite entry point stays offline-only.
|
||||
full_command = _dry_run_make_target("test")
|
||||
assert 'pytest -m "not live"' in full_command
|
||||
assert 'pytest -m "not live and not bundled_extension"' in full_command
|
||||
assert "--ignore=tests/blocking_io" in full_command
|
||||
assert LIVE_OPT_IN not in full_command
|
||||
|
||||
|
||||
66
backend/tests/test_extension_host_contracts.py
Normal file
66
backend/tests/test_extension_host_contracts.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""Host/extension compatibility checks that core CI must own.
|
||||
|
||||
The bundled examples' own suites live behind the `bundled_extension` marker, so
|
||||
an example regression cannot red an unrelated core PR. These checks are the
|
||||
other side of that split: they assert what the host promises any extension
|
||||
(API-version gates and the authorization gate on contributed routes), and they
|
||||
belong to the core shards even when an example package is the payload.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
|
||||
|
||||
@pytest.mark.parametrize("host_version", ["0.2.10", "0.2.11"])
|
||||
def test_extension_requiring_a_newer_host_api_is_rejected(tmp_path, monkeypatch, host_version):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-agent-teams"))
|
||||
monkeypatch.setattr("deerflow.extensions.loader.API_VERSION", host_version)
|
||||
loaded, diagnostics = load_extensions([ExtensionSpec(use="deerflow_extension_agent_teams:install", config={"enabled": True, "storage_path": str(tmp_path / "teams.sqlite")})])
|
||||
|
||||
if host_version == "0.2.10":
|
||||
assert not loaded.plugins
|
||||
assert len(diagnostics) == 1
|
||||
assert "requires extension-api 0.2.11, host provides 0.2.10" in diagnostics[0].message
|
||||
else:
|
||||
assert not diagnostics
|
||||
assert len(loaded.plugins) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method", ["GET", "POST"])
|
||||
@pytest.mark.parametrize("root_path", ["", "/proxy"])
|
||||
async def test_stale_browser_viewer_never_reaches_contributed_routes(monkeypatch, method, root_path):
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
|
||||
async def user(request):
|
||||
return SimpleNamespace(id="bob", system_role="admin")
|
||||
|
||||
async def permissions(*args, **kwargs):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: False)
|
||||
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", user)
|
||||
monkeypatch.setattr("app.gateway.auth_middleware.resolve_route_permissions", permissions)
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
calls = []
|
||||
|
||||
@app.api_route("/api/extensions/example/documents", methods=["GET", "POST"])
|
||||
async def contributed():
|
||||
calls.append("provider")
|
||||
return {"ok": True}
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, root_path=root_path), base_url="http://test", cookies={"access_token": "session-b"}) as client:
|
||||
path = root_path + "/api/extensions/example/documents"
|
||||
stale = await client.request(method, path, headers={"X-Deerflow-Plugin-Viewer": "alice"})
|
||||
assert stale.status_code == 409
|
||||
assert calls == []
|
||||
for header in ({"X-Deerflow-Plugin-Viewer": "bob"}, {}):
|
||||
assert (await client.request(method, path, headers=header)).status_code == 200
|
||||
assert calls == ["provider", "provider"]
|
||||
@ -16,6 +16,8 @@ from langgraph.prebuilt import ToolNode
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
from deerflow.extensions.plugin_tools import build_plugin_tools, plugin_tool_name
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-jev-classify"
|
||||
REAL_ASYNC_CLIENT = httpx.AsyncClient
|
||||
KEY = "test-only-not-a-real-key"
|
||||
|
||||
@ -29,6 +29,8 @@ from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
from deerflow.extensions.stack import compose_with_extensions
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def jev(monkeypatch):
|
||||
|
||||
@ -28,6 +28,8 @@ from deerflow.extensions.anchors import outermost
|
||||
from deerflow.extensions.injection import inject_middlewares
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-jev-screening"
|
||||
PACKAGE = EXAMPLE / "deerflow_extension_jev_screening"
|
||||
ENTRY = "deerflow_extension_jev_screening:install"
|
||||
|
||||
@ -36,6 +36,8 @@ from deerflow.config.pii_redaction_config import PiiRedactionConfig
|
||||
from deerflow.config.sandbox_config import SandboxConfig
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-jev-screening"
|
||||
REAL_CLIENT = httpx.AsyncClient
|
||||
WARNING = "[Potential instruction addressed to the assistant"
|
||||
|
||||
@ -18,6 +18,8 @@ from deerflow.extensions.anchors import outermost
|
||||
from deerflow.extensions.injection import inject_middlewares
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-jev-screening"
|
||||
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ import pytest
|
||||
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def load_center(monkeypatch):
|
||||
@ -36,42 +38,3 @@ def test_page_config_cannot_introduce_credentials_or_provider_configuration(load
|
||||
loaded, diagnostics = load_center(config)
|
||||
assert not loaded.plugins
|
||||
assert diagnostics
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("method", ["GET", "POST"])
|
||||
@pytest.mark.parametrize("root_path", ["", "/proxy"])
|
||||
async def test_stale_browser_viewer_never_reaches_contributed_routes(monkeypatch, method, root_path):
|
||||
from types import SimpleNamespace
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.gateway.auth_middleware import AuthMiddleware
|
||||
|
||||
async def user(request):
|
||||
return SimpleNamespace(id="bob", system_role="admin")
|
||||
|
||||
async def permissions(*args, **kwargs):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("app.gateway.auth_middleware.is_auth_disabled", lambda: False)
|
||||
monkeypatch.setattr("app.gateway.deps.get_current_user_from_request", user)
|
||||
monkeypatch.setattr("app.gateway.auth_middleware.resolve_route_permissions", permissions)
|
||||
app = FastAPI()
|
||||
app.add_middleware(AuthMiddleware)
|
||||
calls = []
|
||||
|
||||
@app.api_route("/api/extensions/example/documents", methods=["GET", "POST"])
|
||||
async def contributed():
|
||||
calls.append("provider")
|
||||
return {"ok": True}
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app, root_path=root_path), base_url="http://test", cookies={"access_token": "session-b"}) as client:
|
||||
path = root_path + "/api/extensions/example/documents"
|
||||
stale = await client.request(method, path, headers={"X-Deerflow-Plugin-Viewer": "alice"})
|
||||
assert stale.status_code == 409
|
||||
assert calls == []
|
||||
for header in ({"X-Deerflow-Plugin-Viewer": "bob"}, {}):
|
||||
assert (await client.request(method, path, headers=header)).status_code == 200
|
||||
assert calls == ["provider", "provider"]
|
||||
|
||||
@ -7,6 +7,8 @@ import httpx
|
||||
import pytest
|
||||
from deerflow_extension_api.auth import ExtensionPrincipal
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def management(monkeypatch):
|
||||
|
||||
@ -6,6 +6,8 @@ from pathlib import Path
|
||||
import pytest
|
||||
from deerflow_extension_api.auth import ExtensionPrincipal
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platform(tmp_path, monkeypatch):
|
||||
|
||||
@ -9,6 +9,8 @@ import pytest
|
||||
|
||||
from deerflow.knowledge import KnowledgeRequestContext, format_result, sources_from_messages
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph(monkeypatch):
|
||||
|
||||
@ -80,7 +80,7 @@ def test_make_test_runs_all_four_shards_and_reports_any_failure(test_project: Pa
|
||||
for group in range(1, 5):
|
||||
args = json.loads((test_project / f"{group}.started").read_text(encoding="utf-8"))
|
||||
assert args[:3] == ["run", "pytest", "-m"]
|
||||
assert args[args.index("-m") + 1] == "not live"
|
||||
assert args[args.index("-m") + 1] == "not live and not bundled_extension"
|
||||
assert "--ignore=tests/blocking_io" in args
|
||||
assert args[args.index("--splits") + 1] == "4"
|
||||
assert args[args.index("--group") + 1] == str(group)
|
||||
|
||||
@ -10,6 +10,8 @@ from deerflow_extension_api.plugins import ToolContext
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
from deerflow.extensions.plugin_tools import build_plugin_tools
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
EXAMPLE = Path(__file__).resolve().parents[2] / "examples/deerflow-extension-report-sections"
|
||||
|
||||
OUTLINE = "# 2026 年新能源汽车行业研究报告\n\n本报告分五章。"
|
||||
|
||||
@ -18,6 +18,8 @@ from deerflow.runtime.checkpoint_mode import inject_checkpoint_mode
|
||||
from deerflow.runtime.checkpoint_state import CheckpointStateAccessor
|
||||
from deerflow.runtime.serialization import serialize_channel_values_for_api
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def retrieval_plugin(monkeypatch):
|
||||
|
||||
@ -8,6 +8,8 @@ import pytest
|
||||
|
||||
from deerflow.knowledge import KnowledgeRequestContext, format_result, sources_from_messages
|
||||
|
||||
pytestmark = pytest.mark.bundled_extension
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def adapter(monkeypatch):
|
||||
|
||||
@ -16,7 +16,10 @@ from test_extension_agent_runs import runtime as _runtime_fixture
|
||||
|
||||
from deerflow.extensions.loader import ExtensionSpec, load_extensions
|
||||
|
||||
pytestmark = pytest.mark.skipif(os.name != "posix", reason="Workflow worker execution requires POSIX")
|
||||
pytestmark = [
|
||||
pytest.mark.bundled_extension,
|
||||
pytest.mark.skipif(os.name != "posix", reason="Workflow worker execution requires POSIX"),
|
||||
]
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user