test(core): provision real plugin, CLI and SeekDB contracts (#2636)

* test(core): provision official plugin CLI and SeekDB contracts

* test(core): trigger report guard tests and trim fixture checks
This commit is contained in:
huanghuoguoguo
2026-10-17 09:13:44 +08:00
committed by GitHub
parent 2b79b04d36
commit 438cbd41a9
9 changed files with 578 additions and 35 deletions
+215
View File
@@ -0,0 +1,215 @@
name: Dependency Contracts
on:
pull_request:
types: [opened, ready_for_review, synchronize]
paths:
- 'src/langbot/**'
- 'tests/**'
- 'pyproject.toml'
- 'pytest.ini'
- 'uv.lock'
- 'scripts/assert-required-tests.py'
- '.github/workflows/optional-contracts.yml'
push:
branches: [master, develop]
paths:
- 'src/langbot/**'
- 'tests/**'
- 'pyproject.toml'
- 'pytest.ini'
- 'uv.lock'
- 'scripts/assert-required-tests.py'
- '.github/workflows/optional-contracts.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
compiled-cli:
name: Compiled CLI HTTP Contract
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
env:
GOTOOLCHAIN: local
CGO_ENABLED: '0'
steps:
- name: Checkout Core
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Checkout official CLI source
uses: actions/checkout@v4
with:
repository: langbot-app/langbot-cli
# lbctl v0.2.0; advance only after verifying the Core HTTP contract.
ref: 3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9
path: .contract-sources/langbot-cli
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@v5
with:
# Match the pinned CLI's release toolchain.
go-version: '1.26.8'
cache-dependency-path: .contract-sources/langbot-cli/go.sum
- name: Build pinned CLI
working-directory: .contract-sources/langbot-cli
env:
LANGBOT_CLI_BIN: ${{ runner.temp }}/langbot-cli/lbctl
run: |
test "$(git rev-parse HEAD)" = 3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9
go mod download
go mod verify
mkdir -p "$(dirname "$LANGBOT_CLI_BIN")"
go build -mod=readonly -trimpath -o "$LANGBOT_CLI_BIN" ./cmd/lbctl
test -x "$LANGBOT_CLI_BIN"
go version -m "$LANGBOT_CLI_BIN"
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install Core dependencies
run: uv sync --dev --frozen
- name: Run required compiled CLI contract
env:
LANGBOT_CLI_BIN: ${{ runner.temp }}/langbot-cli/lbctl
run: |
uv run --no-sync pytest \
tests/unit_tests/api/service/test_cli_diagnostics.py::test_compiled_cli_against_core_http \
-q --tb=short -rs --junitxml=compiled-cli-results.xml
python scripts/assert-required-tests.py compiled-cli-results.xml \
--module tests.unit_tests.api.service.test_cli_diagnostics \
--name-prefix test_compiled_cli_against_core_http --expected-tests 1
- name: Upload compiled CLI contract result
if: always()
uses: actions/upload-artifact@v4
with:
name: compiled-cli-contract-result
path: compiled-cli-results.xml
if-no-files-found: ignore
plugin-identity:
name: Official Plugin Identity Contract
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout Core
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Read official source pin
id: plugin-source
run: |
python3 - <<'PY'
import json
import os
import re
from pathlib import Path
lock = json.loads(Path('tests/fixtures/legacy_plugin_identity_sources.json').read_text())
assert lock['repository'] == 'langbot-app/langbot-plugins'
assert re.fullmatch(r'[0-9a-f]{40}', lock['revision'])
with open(os.environ['GITHUB_OUTPUT'], 'a') as output:
output.write(f"revision={lock['revision']}\n")
PY
- name: Checkout pinned official plugin sources
uses: actions/checkout@v4
with:
repository: langbot-app/langbot-plugins
ref: ${{ steps.plugin-source.outputs.revision }}
path: .contract-sources/langbot-plugins
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install Core dependencies
run: uv sync --dev --frozen
- name: Run required identity consumer contracts
env:
LANGBOT_LEGACY_PLUGIN_ROOT: ${{ github.workspace }}/.contract-sources/langbot-plugins
run: |
uv run --no-sync pytest \
tests/unit_tests/agent/test_legacy_identity_boundary.py::test_real_plugin_identity_helper_with_host_payload \
-q --tb=short -rs --junitxml=plugin-identity-results.xml
python scripts/assert-required-tests.py plugin-identity-results.xml \
--module tests.unit_tests.agent.test_legacy_identity_boundary \
--name-prefix test_real_plugin_identity_helper_with_host_payload --expected-tests 16
- name: Upload plugin identity results
if: always()
uses: actions/upload-artifact@v4
with:
name: plugin-identity-contract-results
path: plugin-identity-results.xml
if-no-files-found: ignore
seekdb:
name: Embedded SeekDB Contract
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout Core
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install locked SeekDB extra
run: uv sync --dev --extra seekdb --frozen
- name: Run required embedded SeekDB contracts
env:
SEEKDB_TEST_TMP: ${{ runner.temp }}/langbot-seekdb-contract
run: |
uv run --no-sync python - <<'PY'
from importlib.metadata import version
import pyseekdb
import pylibseekdb
print(f"pyseekdb={version('pyseekdb')}, pylibseekdb={version('pylibseekdb')}")
PY
uv run --no-sync pytest tests/integration/vector/test_seekdb.py \
-m slow -q --tb=short -rs --durations=5 \
--basetemp="$SEEKDB_TEST_TMP" --junitxml=seekdb-contract-results.xml
python scripts/assert-required-tests.py seekdb-contract-results.xml \
--module tests.integration.vector.test_seekdb --name-prefix test_ --expected-tests 2
- name: Upload SeekDB results and startup logs
if: always()
uses: actions/upload-artifact@v4
with:
name: seekdb-contract-results
path: |
seekdb-contract-results.xml
${{ runner.temp }}/langbot-seekdb-contract/**/log/*.log
if-no-files-found: ignore
+1
View File
@@ -11,6 +11,7 @@ on:
- 'uv.lock'
- 'run_tests.sh'
- 'scripts/test-*.sh'
- 'scripts/assert-required-tests.py'
push:
branches:
- master
+112
View File
@@ -0,0 +1,112 @@
# Provisioned dependency contracts
`.github/workflows/optional-contracts.yml` runs three isolated jobs for optional
product dependencies. Every job provisions its dependency and rejects missing,
skipped, failed, or errored required cases. These are tests of actual integration
boundaries; removing them or substituting fake external implementations would
lose coverage. No production credentials or external model accounts are needed.
The fixtures are deliberately pinned for reproducibility. They are not a promise
that the latest release of every external repository passes. Update pins only
after rerunning the relevant contracts. This workflow is separate from the
latest-LocalAgent Space-resolution work in PR #2632.
## Official plugin identity boundary
The source lock in `tests/fixtures/legacy_plugin_identity_sources.json` records
the official repository, immutable revision, and SHA-256 of the consumed Python
source files. CI checks out that revision with read-only access
and no persisted Git credentials. Tests verify file digests before executing the
actual identity methods, their genuine configuration-error classes, and their
real `scoped_identity` dependency. Vendor HTTP clients are not constructed.
The original test expected bare upstream user IDs and extracted only one method
from a hard-coded sibling checkout. Supported current plugins intentionally
namespace IDs by Workspace or trusted installation, so merely provisioning that
old test produced false failures. The current fixture is:
- Official repository: `langbot-app/langbot-plugins`
- Revision: `7ad54b1a139d88113bc55128326800ed29779737`
- DifyAgent 0.2.3; N8nAgent, CozeAgent and TboxAgent 0.2.2
- All exceed Core's current migration minimums (0.1.10; TboxAgent 0.1.8)
The 19 Host provenance/resume tests retain their exact raw-identity assertions.
The consumer boundary adds dedicated/shared profiles to the former eight cases:
16 real-plugin cases verify the independently specified session/event/bot source,
current documented namespace, and rejection of missing trusted identity. Shared
cases use the real SDK `bind_invocation` and `InstallationBinding`. They are not
full plugin-installation, vendor API, or migration-admission tests.
The documented semantic change is in each pinned plugin's `SHARED_RUNTIME.md`,
for example [Dify's upstream identity migration](https://github.com/langbot-app/langbot-plugins/blob/7ad54b1a139d88113bc55128326800ed29779737/Runner/dify-agent/SHARED_RUNTIME.md#upstream-identity-migration).
An older bare-ID source revision also passed the old tests, but its manifest
versions are below Core's migration minimums. It is intentionally not used to
claim current compatibility.
To run with an official checkout at the locked revision:
```bash
export LANGBOT_LEGACY_PLUGIN_ROOT=/absolute/path/to/langbot-plugins
uv run --frozen pytest tests/unit_tests/agent/test_legacy_identity_boundary.py -q -rs
```
An unset variable allows an explicit local prerequisite skip. An empty variable,
a missing file, a path escaping the fixture root, or a hash mismatch fails.
CI selects the 16 consumer cases and requires all 16 to pass without skips.
## Embedded SeekDB
The job installs `uv sync --dev --extra seekdb --frozen`, using the versions and
wheel hashes already committed in `uv.lock`: pyseekdb 1.4.0.post1 and
pylibseekdb 1.4.0. It runs the existing two slow tests against a real embedded
engine, covering upsert/text preservation and full-text/hybrid relevance order.
The tests supply their own small embedding vectors; no model download is needed.
```bash
uv sync --dev --extra seekdb --frozen
uv run --no-sync pytest tests/integration/vector/test_seekdb.py \
-m slow -q -rs --durations=5 --junitxml=seekdb-contract-results.xml
python scripts/assert-required-tests.py seekdb-contract-results.xml \
--module tests.integration.vector.test_seekdb --expected-tests 2
```
This isolated Linux job keeps native dependency startup failures visible instead
of treating them as skips. JUnit results and native startup logs are uploaded
when available. A missing optional package or native engine failure is not
counted as a passing contract.
## Compiled CLI HTTP contract
The `Compiled CLI HTTP Contract` CI job runs the real `lbctl` process against a
loopback-bound Core HTTP server with temporary SQLite data and synthetic runtime
providers. It checks command parsing, API-key/workspace and capability discovery,
knowledge-engine/parser routes, sandbox diagnostics, pipeline extension updates,
pipeline execution, and monitoring reads. It does not need production accounts,
external model services, Docker, or a running LangBot deployment.
CI builds the official [`langbot-app/langbot-cli` v0.2.0 source](https://github.com/langbot-app/langbot-cli/tree/3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9),
pinned to commit `3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9`, using Go 1.26.8.
Module versions and checksums come from that checkout's `go.mod` and `go.sum`.
The job verifies modules and builds with `CGO_ENABLED=0`, `-mod=readonly`, and
`-trimpath`. It requires exactly one passing JUnit case and rejects skips.
To run locally with an official pinned CLI checkout and Go 1.26.8:
```bash
# In the official langbot-cli checkout:
git checkout 3bd20eecd4771b1fe5d0dca61d8b5523182e7bd9
export GOTOOLCHAIN=local CGO_ENABLED=0
go mod download
go mod verify
go build -mod=readonly -trimpath -o ./bin/lbctl ./cmd/lbctl
export LANGBOT_CLI_BIN="$(pwd)/bin/lbctl"
# In the LangBot Core checkout:
uv run --frozen pytest \
tests/unit_tests/api/service/test_cli_diagnostics.py::test_compiled_cli_against_core_http \
-q --tb=short -rs
```
Without `LANGBOT_CLI_BIN`, this test remains an explicit local skip. When set, it
must point to a real compiled executable. CI supplies an absolute path and treats
a missing binary, command failure, or skipped case as a failure. Update the source
pin deliberately and rerun the contract before accepting a newer CLI version.
+39
View File
@@ -0,0 +1,39 @@
"""Fail closed when a provisioned dependency-contract lane skips or loses tests."""
from __future__ import annotations
import argparse
from pathlib import Path
import xml.etree.ElementTree as ET
def verify_report(path: Path, expected_module: str, expected_tests: int, name_prefix: str = '') -> int:
cases = list(ET.parse(path).getroot().iter('testcase'))
if len(cases) != expected_tests:
raise ValueError(f'Expected {expected_tests} tests, found {len(cases)} in {path}')
for case in cases:
if case.get('classname') != expected_module:
raise ValueError(f'Unexpected test module: {case.get("classname")!r}')
if not case.get('name', '').startswith(name_prefix):
raise ValueError(f'Unexpected test name: {case.get("name")!r}')
for outcome in ('skipped', 'failure', 'error'):
if case.find(outcome) is not None:
raise ValueError(f'Required test {case.get("name")!r} reported {outcome}')
return len(cases)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('report', type=Path)
parser.add_argument('--module', required=True)
parser.add_argument('--expected-tests', type=int, required=True)
parser.add_argument('--name-prefix', default='')
args = parser.parse_args()
if args.expected_tests < 1:
parser.error('--expected-tests must be positive')
count = verify_report(args.report, args.module, args.expected_tests, args.name_prefix)
print(f'{args.module}: {count} required tests passed, 0 skipped')
if __name__ == '__main__':
main()
+4
View File
@@ -160,6 +160,10 @@ The test suite uses a centralized fixture system that provides:
3. **Coverage**: Tests cover happy paths, edge cases, and error conditions
4. **Extensibility**: Easy to add new tests by reusing existing fixtures
See [provisioned dependency contracts](../docs/DEPENDENCY_CONTRACT_TESTS.md) for
the real compiled CLI, pinned official plugin identity helpers, and embedded
SeekDB CI jobs, including local prerequisites and verification boundaries.
## Running Tests
### Quick self-test for developers
+19
View File
@@ -0,0 +1,19 @@
{
"repository": "langbot-app/langbot-plugins",
"revision": "7ad54b1a139d88113bc55128326800ed29779737",
"contract": "Current dedicated-workspace and trusted shared-installation identity; see each plugin SHARED_RUNTIME.md.",
"files": {
"Runner/dify-agent/components/runner/default.py": "0bcaff74c64c89be2bddb4be5871ce148a814676b50987a65f95228ecb7da8fa",
"Runner/dify-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a",
"Runner/dify-agent/pkg/dify_client.py": "607ed8d610d916e5b64ed20699e3de6e2216b238a5fecc9cabcdb75533b8dc56",
"Runner/n8n-agent/components/runner/default.py": "2719ec0a8ff779e90e61bd0209653fb9a1eab4b552877cfcbd4d3c9ce803fcf1",
"Runner/n8n-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a",
"Runner/n8n-agent/pkg/n8n_client.py": "15149b2d4162b51a3a7427d639908130ea4179014067f04a459a8a71ca105c15",
"Runner/coze-agent/components/runner/default.py": "23f222bd1bf94682d44df021d56e6e69801fbe8ea5bac99693d0f1e2bce8f5e8",
"Runner/coze-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a",
"Runner/coze-agent/pkg/coze_client.py": "379cb7f07f9931cc9de4cc40f8ebddea171c2a05383e662f6bd88ecdc5b3c317",
"Runner/tbox-agent/components/runner/default.py": "4c9fdf8cc10c916915036112ded8768a619a827ca4b5e6a3f05881a7d0e4492f",
"Runner/tbox-agent/pkg/scoped_identity.py": "5d03d0c56aaf3301bc5d594905ecf862151fa2abc73daba1a6596f0fdc16374a",
"Runner/tbox-agent/pkg/tbox_client.py": "0f09a3c70f773bc6377fd5b55a41d186dd15487d46aab3e61b837c08e15100a0"
}
}
@@ -1,12 +1,15 @@
"""Native identity provenance across the Host/SDK and persisted resume boundary."""
import ast
from pathlib import Path
import hashlib
import json
from contextlib import nullcontext
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
from langbot_plugin.api.proxies.invocation import bind_invocation
from langbot_plugin.entities.io.context import InstallationBinding
from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter
from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder
from langbot.pkg.agent.runner.host_models import AgentEventEnvelope
@@ -16,6 +19,7 @@ from langbot.pkg.entity.persistence.base import Base
from sqlalchemy.ext.asyncio import create_async_engine
from tests.unit_tests.agent import test_event_first_protocol as event_fixtures
from tests.unit_tests.agent.test_context_validation import TestContextValidation as Helpers
from tests.utils.legacy_plugin_contract import load_identity_helper, verified_plugin_root
@pytest.fixture
@@ -192,35 +196,30 @@ async def test_tbox_resume_without_persisted_identity_fails_closed(mock_query):
assert context['runtime']['metadata']['bot_id'] is None
@pytest.fixture(scope='session')
def official_plugin_root():
return verified_plugin_root()
@pytest.mark.asyncio
@pytest.mark.parametrize(
'runner,folder,method',
'runner,folder,method,error_name,local_identity',
[
('DifyAgent', 'dify-agent', '_get_user_tag'),
('N8nAgent', 'n8n-agent', '_get_user_tag'),
('CozeAgent', 'coze-agent', '_get_user_id'),
('TboxAgent', 'tbox-agent', '_get_user_id'),
('DifyAgent', 'dify-agent', '_get_user_tag', 'DifyConfigError', 'person_Session_007'),
('N8nAgent', 'n8n-agent', '_get_user_tag', 'N8nConfigError', 'person_Session_007'),
('CozeAgent', 'coze-agent', '_get_user_id', 'CozeConfigError', 'person_launcher-123'),
('TboxAgent', 'tbox-agent', '_get_user_id', 'TboxConfigError', 'bot-uuid-123'),
],
)
@pytest.mark.parametrize('missing', [False, True])
async def test_real_plugin_identity_helper_with_host_payload(mock_query, runner, folder, method, missing):
# Execute the actual pure helper AST without loading vendor clients or making network calls.
root = Path(__file__).resolve().parents[3].parent / 'langbot-plugins-411-migration'
path = root / 'Runner' / folder / 'components/runner/default.py'
if not path.exists():
pytest.skip('sibling plugin source checkout is required for cross-repository contract gate')
tree = ast.parse(path.read_text())
function = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == method)
class ConfigError(Exception):
def __init__(self, message, **kwargs):
super().__init__(message)
namespace = {
'RunnerContext': RunnerContext,
**{name: ConfigError for name in ('DifyConfigError', 'N8nConfigError', 'CozeConfigError', 'TboxConfigError')},
}
exec(compile(ast.Module(body=[function], type_ignores=[]), str(path), 'exec'), namespace)
@pytest.mark.parametrize('missing', [False, True], ids=['trusted-identity', 'missing-identity'])
@pytest.mark.parametrize('profile', ['dedicated', 'shared'])
async def test_real_plugin_identity_helper_with_host_payload(
mock_query, monkeypatch, official_plugin_root, runner, folder, method, error_name, local_identity, missing, profile
):
# Keep exact Host identity selection above; current supported plugins namespace
# that identity to prevent collisions across Workspaces and installations.
helper, config_error = load_identity_helper(official_plugin_root, folder, method, error_name)
monkeypatch.setenv('LANGBOT_PLUGIN_RUNTIME_PROFILE', profile)
mock_query.session.launcher_id = 'Session_007'
event = QueryEntryAdapter.query_to_event(mock_query)
if missing:
@@ -229,13 +228,34 @@ async def test_real_plugin_identity_helper_with_host_payload(mock_query, runner,
source = 'legacy-bot' if runner == 'TboxAgent' else 'legacy-session'
context, _ = await build(event, runner, source)
sdk = RunnerContext.model_validate(context)
if missing:
with pytest.raises(ConfigError, match='trusted Host identity'):
namespace[method](None, sdk)
else:
expected = (
mock_query.bot_uuid
if runner == 'TboxAgent'
else ('person_launcher-123' if runner == 'CozeAgent' else 'person_Session_007')
handler = SimpleNamespace()
consumer = SimpleNamespace(_plugin_runtime_handler=handler)
binding = InstallationBinding(
instance_uuid='identity-instance',
workspace_uuid=event.workspace_id,
installation_uuid='00000000-0000-4000-8000-000000000007',
runtime_revision=1,
artifact_digest='a' * 64,
)
if profile == 'shared':
# A stale connection binding must never win over the active invocation.
handler.bound_action_context = binding.model_copy(
update={'workspace_uuid': 'foreign-workspace', 'installation_uuid': 'foreign-installation'}
)
assert namespace[method](None, sdk) == expected
authority = bind_invocation(handler, binding=binding) if profile == 'shared' else nullcontext()
with authority:
if missing:
with pytest.raises(config_error, match='trusted Host identity') as caught:
helper(consumer, sdk)
assert caught.value.code == f'{folder.removesuffix("-agent")}.identity_unavailable'
else:
# The expected local IDs above are independent of the actual Host
# payload and plugin output, preserving session-vs-event provenance.
scope = (
['identity-instance', event.workspace_id, '00000000-0000-4000-8000-000000000007']
if profile == 'shared'
else [event.workspace_id]
)
encoded = json.dumps([scope, local_identity], ensure_ascii=False, separators=(',', ':')).encode()
expected = 'lb_' + hashlib.sha256(encoded).hexdigest()
assert helper(consumer, sdk) == expected
@@ -0,0 +1,72 @@
"""The optional-dependency CI guard must reject a false-green JUnit report."""
from __future__ import annotations
import importlib.util
from pathlib import Path
import subprocess
import sys
import pytest
SCRIPT = Path(__file__).resolve().parents[3] / 'scripts' / 'assert-required-tests.py'
SPEC = importlib.util.spec_from_file_location('required_contract_report', SCRIPT)
assert SPEC is not None and SPEC.loader is not None
REPORT = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(REPORT)
def write_report(tmp_path, cases):
path = tmp_path / 'report.xml'
path.write_text(f'<testsuites><testsuite>{cases}</testsuite></testsuites>')
return path
def test_accepts_exact_passing_cases(tmp_path):
path = write_report(
tmp_path, '<testcase classname="contracts" name="first"/><testcase classname="contracts" name="second"/>'
)
assert REPORT.verify_report(path, 'contracts', 2) == 2
@pytest.mark.parametrize('outcome', ['skipped', 'failure', 'error'])
def test_rejects_every_nonpassing_outcome(tmp_path, outcome):
path = write_report(tmp_path, f'<testcase classname="contracts" name="required"><{outcome}/></testcase>')
with pytest.raises(ValueError, match=outcome):
REPORT.verify_report(path, 'contracts', 1)
@pytest.mark.parametrize('count', [0, 2])
def test_rejects_changed_case_count(tmp_path, count):
path = write_report(tmp_path, '<testcase classname="contracts" name="case"/>' * count)
with pytest.raises(ValueError, match='Expected 1 tests'):
REPORT.verify_report(path, 'contracts', 1)
def test_rejects_wrong_module(tmp_path):
path = write_report(tmp_path, '<testcase classname="unrelated" name="case"/>')
with pytest.raises(ValueError, match='Unexpected test module'):
REPORT.verify_report(path, 'contracts', 1)
def test_rejects_missing_report(tmp_path):
with pytest.raises(FileNotFoundError):
REPORT.verify_report(tmp_path / 'missing.xml', 'contracts', 1)
def test_command_line_success_and_failure(tmp_path):
path = write_report(tmp_path, '<testcase classname="contracts" name="case"/>')
command = [sys.executable, str(SCRIPT), str(path), '--module', 'contracts', '--expected-tests']
good = subprocess.run([*command, '1'], capture_output=True, text=True)
assert good.returncode == 0
assert '1 required tests passed, 0 skipped' in good.stdout
bad = subprocess.run([*command, '0'], capture_output=True, text=True)
assert bad.returncode != 0
assert 'must be positive' in bad.stderr
def test_rejects_wrong_function(tmp_path):
path = write_report(tmp_path, '<testcase classname="contracts" name="unrelated"/>')
with pytest.raises(ValueError, match='Unexpected test name'):
REPORT.verify_report(path, 'contracts', 1, 'test_required')
+61
View File
@@ -0,0 +1,61 @@
"""Load identity consumers from integrity-checked official plugin source."""
import ast
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import pytest
from langbot_plugin.api.entities.builtin.runner.context import RunnerContext
SOURCE_LOCK = Path(__file__).resolve().parents[1] / 'fixtures' / 'legacy_plugin_identity_sources.json'
def verified_plugin_root() -> Path:
"""Allow optional local execution, but never skip an explicitly provisioned source."""
configured = os.environ.get('LANGBOT_LEGACY_PLUGIN_ROOT')
if configured is None:
pytest.skip('set LANGBOT_LEGACY_PLUGIN_ROOT to the official revision in legacy_plugin_identity_sources.json')
if not configured.strip():
pytest.fail('LANGBOT_LEGACY_PLUGIN_ROOT is set but empty', pytrace=False)
root = Path(configured).resolve()
lock = json.loads(SOURCE_LOCK.read_text())
for relative, expected in lock['files'].items():
path = root / relative
if not path.resolve().is_relative_to(root) or not path.is_file():
pytest.fail(f'Official plugin fixture is missing or outside its root: {relative}', pytrace=False)
actual = hashlib.sha256(path.read_bytes()).hexdigest()
if actual != expected:
pytest.fail(
f'Official plugin fixture hash mismatch: {relative}; expected {lock["repository"]}@{lock["revision"]}',
pytrace=False,
)
return root
def load_identity_helper(root: Path, folder: str, method: str, error_name: str):
"""Execute actual identity code and its error class without vendor client I/O."""
plugin = root / 'Runner' / folder
path = plugin / 'components/runner/default.py'
tree = ast.parse(path.read_text())
runner_classes = [node for node in tree.body if isinstance(node, ast.ClassDef) and node.name == 'DefaultRunner']
assert len(runner_classes) == 1, f'{path}: expected one DefaultRunner'
methods = [node for node in runner_classes[0].body if isinstance(node, ast.FunctionDef) and node.name == method]
assert len(methods) == 1, f'{path}: expected one {method}'
client_path = plugin / 'pkg' / f'{folder.removesuffix("-agent")}_client.py'
client_tree = ast.parse(client_path.read_text())
errors = [node for node in client_tree.body if isinstance(node, ast.ClassDef) and node.name == error_name]
assert len(errors) == 1, f'{client_path}: expected one {error_name}'
scoped_path = plugin / 'pkg/scoped_identity.py'
spec = importlib.util.spec_from_file_location(f'identity_contract_{folder.replace("-", "_")}', scoped_path)
assert spec is not None and spec.loader is not None
scoped_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(scoped_module)
namespace = {'RunnerContext': RunnerContext, 'scoped_identity': scoped_module.scoped_identity}
exec(compile(ast.Module(body=errors, type_ignores=[]), str(client_path), 'exec'), namespace)
exec(compile(ast.Module(body=methods, type_ignores=[]), str(path), 'exec'), namespace)
return namespace[method], namespace[error_name]