mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-10-22 08:25:21 +08:00
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:
@@ -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
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- 'uv.lock'
|
||||
- 'run_tests.sh'
|
||||
- 'scripts/test-*.sh'
|
||||
- 'scripts/assert-required-tests.py'
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user