Compare commits

..

3 Commits

Author SHA1 Message Date
dadachann dafe9176c9 fix(deps): pin released RAG runtime SDK 0.5.8 2026-09-11 09:34:45 +00:00
dadachann a708cfac40 fix(plugin): preserve explicit nested installation scope 2026-09-11 08:53:23 +00:00
Hyu 1ea9cd3f6f fix(pipelines): show the actual sandbox scope restriction (#2527)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 14:42:38 +08:00
13 changed files with 1136 additions and 76 deletions
+1 -1
View File
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.7",
"langbot-plugin==0.5.8",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
+8 -6
View File
@@ -48,6 +48,7 @@ from ..utils import constants
_DEFAULT_BINARY_STORAGE_VALUE_BYTES = 10 * 1024 * 1024
_HARD_MAX_BINARY_STORAGE_VALUE_BYTES = 64 * 1024 * 1024
_UNSET_INSTALLATION_SCOPE = object()
def _binary_storage_value_limit(ap: Any) -> int:
@@ -479,7 +480,6 @@ class RuntimeConnectionHandler(handler.Handler):
self._outbound_installation_context: contextvars.ContextVar[InstallationBinding | None] = (
contextvars.ContextVar(
f'{self.__class__.__name__}_{id(self)}_outbound_installation',
default=None,
)
)
self._installation_bindings: dict[
@@ -1631,13 +1631,15 @@ class RuntimeConnectionHandler(handler.Handler):
) -> InstallationBinding | ActionContext | None:
if action_context is not None:
return super().resolve_outbound_action_context(action_context)
inbound_context = self.current_action_context
if inbound_context is not None:
return inbound_context
return self._outbound_installation_context.get()
# An explicit scope targets the nested call, not its inbound caller.
# None deliberately clears the context for runtime-scoped actions.
scoped_context = self._outbound_installation_context.get(_UNSET_INSTALLATION_SCOPE)
if scoped_context is not _UNSET_INSTALLATION_SCOPE:
return typing.cast(InstallationBinding | None, scoped_context)
return self.current_action_context
def require_outbound_installation_context(self) -> InstallationBinding:
binding = self._outbound_installation_context.get()
binding = self._outbound_installation_context.get(None)
if not isinstance(binding, InstallationBinding):
raise ValueError('Host plugin action requires an InstallationBinding scope')
return binding
+35 -12
View File
@@ -143,18 +143,41 @@ stages:
operator: eq
value: false
disabled_tooltip:
en_US: >-
Sandbox scope can't be changed: either the Box sandbox is disabled
or unavailable (enable it in config.yaml with box.enabled = true and
ensure the runtime is reachable), or this deployment pins all
pipelines to a fixed scope.
zh_Hans: "无法修改沙箱作用域:Box 沙箱已禁用或不可用(请在配置中启用 box.enabled = true 并确认运行时连接正常),或本部署已将所有流水线固定为统一作用域。"
zh_Hant: "無法修改沙箱作用域:Box 沙箱已停用或無法使用(請在設定中啟用 box.enabled = true 並確認執行時連線正常),或本部署已將所有流水線固定為統一作用域。"
ja_JP: "サンドボックススコープを変更できません:Box サンドボックスが無効/利用不可(設定で box.enabled = true にしてランタイム接続を確認)、またはこのデプロイがすべてのパイプラインを固定スコープに制限しています。"
vi_VN: "Không thể thay đổi phạm vi sandboxBox sandbox bị tắt hoặc không khả dụng (bật box.enabled = true và đảm bảo runtime hoạt động), hoặc bản triển khai này cố định mọi pipeline về một phạm vi."
th_TH: "ไม่สามารถเปลี่ยนขอบเขต Sandbox:Box sandbox ถูกปิดหรือไม่พร้อมใช้งาน (เปิด box.enabled = true และตรวจสอบรันไทม์) หรือการ deploy นี้ล็อกทุก pipeline ไว้ที่ขอบเขตเดียว"
es_ES: "No se puede cambiar el alcance del sandbox: el sandbox de Box está desactivado o no disponible (actívelo con box.enabled = true y verifique el runtime), o este despliegue fija todas las pipelines a un alcance único."
ru_RU: "Невозможно изменить область песочницы: песочница Box отключена или недоступна (включите box.enabled = true и проверьте среду выполнения), либо это развёртывание фиксирует единую область для всех конвейеров."
en_US: "Sandbox is unavailable. Enable Box and check its connection before changing the scope."
zh_Hans: "沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。"
zh_Hant: "沙箱未啟用,請啟用 Box 並確認連線正常後再修改作用域。"
ja_JP: "サンドボックスは利用できません。Box を有効にし、接続を確認してからスコープを変更してください。"
vi_VN: "Sandbox không khả dụng. Hãy bật Box và kiểm tra kết nối trước khi thay đổi phạm vi."
th_TH: "Sandbox ไม่พร้อมใช้งาน โปรดเปิดใช้งาน Box และตรวจสอบการเชื่อมต่อก่อนเปลี่ยนขอบเขต"
es_ES: "El sandbox no está disponible. Active Box y compruebe su conexión antes de cambiar el alcance."
ru_RU: "Песочница недоступна. Включите Box и проверьте подключение, прежде чем менять область."
disabled_tooltip_overrides:
- when:
field: __system.box_scope_forced_global
operator: eq
value: true
tooltip:
en_US: "A global sandbox is enforced; the scope cannot be changed."
zh_Hans: "已强制使用全局沙箱,无法修改作用域。"
zh_Hant: "已強制使用全域沙箱,無法修改作用域。"
ja_JP: "グローバルサンドボックスの使用が強制されているため、スコープを変更できません。"
vi_VN: "Bắt buộc sử dụng sandbox toàn cục; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ Sandbox ส่วนกลาง จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un sandbox global; no se puede cambiar el alcance."
ru_RU: "Принудительно используется глобальная песочница; изменить область нельзя."
- when:
field: __system.box_scope_forced
operator: eq
value: true
tooltip:
en_US: "A fixed sandbox scope is enforced; the scope cannot be changed."
zh_Hans: "已强制使用固定沙箱作用域,无法修改作用域。"
zh_Hant: "已強制使用固定沙箱作用域,無法修改作用域。"
ja_JP: "固定のサンドボックススコープが強制されているため、スコープを変更できません。"
vi_VN: "Phạm vi sandbox đã được cố định bắt buộc; không thể thay đổi phạm vi."
th_TH: "ระบบบังคับใช้ขอบเขต Sandbox แบบตายตัว จึงไม่สามารถเปลี่ยนขอบเขตได้"
es_ES: "Se impone un alcance fijo del sandbox; no se puede cambiar el alcance."
ru_RU: "Принудительно задана фиксированная область песочницы; изменить её нельзя."
type: select
required: false
default: "{launcher_type}_{launcher_id}"
@@ -0,0 +1,307 @@
"""Real Core/SDK protocol regression tests; no subprocesses or external services.
Run against the intended local SDK (``uv run --no-sync`` after local install).
The in-memory transport carries JSON strings through Handler.run on both sides;
send_file, envelope validation, base64 decoding and transfer storage are real.
Only Core's database/object-storage services, parser dispatch/provider and host
sandbox prerequisite probing are doubles. Worker launch/registration is
represented by its already-registered state.
"""
from __future__ import annotations
import asyncio
import json
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.plugin.handler import RuntimeConnectionHandler
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.context import ActionContext, InstallationBinding, PluginWorkerPolicy, RuntimeIdentity
from langbot_plugin.runtime.context import RuntimeContext
from langbot_plugin.runtime.io.connection import Connection
from langbot_plugin.entities.io.errors import ActionCallError, ConnectionClosedError
from langbot_plugin.runtime.io.handler import FILE_CHUNK_LENGTH, Handler
from langbot_plugin.runtime.io.handlers.control import ControlConnectionHandler
from langbot_plugin.runtime.io.handlers.plugin import PluginConnectionHandler
from langbot_plugin.runtime.plugin.mgr import PluginManager
from langbot_plugin.runtime.security import PLUGIN_FILE_STORAGE_DIR_ENV
pytestmark = pytest.mark.asyncio
PAYLOAD = bytes(range(256)) * 161 + b'\x00original RAG file\xff'
BINDING = InstallationBinding(
instance_uuid='instance-a',
workspace_uuid='workspace-a',
placement_generation=7,
installation_uuid='00000000-0000-4000-8000-000000000001',
runtime_revision=3,
artifact_digest='a' * 64,
)
LEGACY = ActionContext(**BINDING.model_dump(exclude={'runtime_revision', 'artifact_digest'}))
class QueueConnection(Connection):
"""Only the byte transport is replaced, not the request/response machinery."""
def __init__(self):
self.incoming = asyncio.Queue()
self.sent = []
self.peer = None
async def send(self, message: str) -> None:
assert isinstance(message, str)
self.sent.append(json.loads(message))
await self.peer.incoming.put(message)
async def receive(self) -> str:
message = await self.incoming.get()
if message is None:
raise ConnectionClosedError('test transport closed')
return message
async def close(self) -> None:
await self.incoming.put(None)
await self.peer.incoming.put(None)
def connection_pair():
left, right = QueueConnection(), QueueConnection()
left.peer, right.peer = right, left
return left, right
@asynccontextmanager
async def protocol_stack(tmp_path, monkeypatch, profile='oss_dev', binding=LEGACY):
monkeypatch.chdir(tmp_path)
stored = tmp_path / 'original.bin'
stored.write_bytes(PAYLOAD)
storage_calls = []
async def get_file_stream(execution_context, storage_path):
storage_calls.append((execution_context, storage_path))
assert execution_context.workspace_uuid == BINDING.workspace_uuid
assert storage_path == 'knowledge/original.bin'
return stored.read_bytes()
async def get_execution_binding(workspace_uuid, expected_generation):
assert workspace_uuid == BINDING.workspace_uuid
assert expected_generation == BINDING.placement_generation
return BINDING
setting = SimpleNamespace(
plugin_author='tester',
plugin_name='engine',
installation_uuid=BINDING.installation_uuid,
runtime_revision=BINDING.runtime_revision,
artifact_digest=BINDING.artifact_digest,
)
app = SimpleNamespace(
deployment=SimpleNamespace(mode='oss' if profile == 'oss_dev' else 'cloud'),
logger=logging.getLogger(__name__),
persistence_mgr=SimpleNamespace(execute_async=AsyncMock(return_value=SimpleNamespace(first=lambda: setting))),
workspace_service=SimpleNamespace(get_execution_binding=get_execution_binding),
rag_runtime_service=SimpleNamespace(get_file_stream=get_file_stream),
)
core_conn, control_conn = connection_pair()
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'core-transfer'))
core = RuntimeConnectionHandler(core_conn, AsyncMock(return_value=False), app)
core.register_installation_binding(BINDING, plugin_author='tester', plugin_name='engine')
runtime = RuntimeContext()
runtime.plugin_mgr = PluginManager(runtime)
# No worker is launched: omit only host nsjail/cgroup prerequisite probing.
monkeypatch.setattr(runtime.plugin_mgr.worker_launcher, 'configure', lambda policy, profile: None)
monkeypatch.setenv(PLUGIN_FILE_STORAGE_DIR_ENV, str(tmp_path / 'runtime-transfer'))
control = ControlConnectionHandler(control_conn, runtime)
runtime.activate_control_handler(control)
bridge_conn, plugin_conn = connection_pair()
bridge = PluginConnectionHandler(bridge_conn, runtime, file_storage_dir=str(tmp_path / 'bridge-transfer'))
plugin = Handler(plugin_conn, file_storage_dir=str(tmp_path / 'plugin-transfer'))
# Trusted state left by registration, not plugin-supplied action data.
bridge.bind_action_context(binding)
runtime.plugin_mgr.plugin_handlers.append(bridge)
runtime.plugin_mgr.plugins.append(SimpleNamespace(_runtime_plugin_handler=bridge))
handlers = [core, control, bridge, plugin]
tasks = [asyncio.create_task(handler.run()) for handler in handlers]
try:
await asyncio.wait_for(
core.set_runtime_config(
runtime_identity=RuntimeIdentity(instance_uuid='instance-a', runtime_id='test-runtime'),
worker_policy=PluginWorkerPolicy(
max_cpus=1,
max_memory_mb=128,
max_pids=32,
max_open_files=64,
max_file_size_mb=8,
require_hard_limits=False,
),
runtime_profile=profile,
cloud_service_url=None,
),
5,
)
if isinstance(binding, InstallationBinding):
runtime.activate_installation_binding(binding)
else:
runtime.bind_workspace(binding)
yield SimpleNamespace(
core=core,
control=control,
runtime=runtime,
bridge=bridge,
plugin=plugin,
core_conn=core_conn,
control_conn=control_conn,
bridge_conn=bridge_conn,
plugin_conn=plugin_conn,
app=app,
storage_calls=storage_calls,
)
finally:
for handler in handlers:
await handler.close()
await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 5)
def assert_chunks(connection, binding, payload=PAYLOAD):
chunks = [message for message in connection.sent if message.get('action') == CommonAction.FILE_CHUNK.value]
expected = (len(payload) + FILE_CHUNK_LENGTH - 1) // FILE_CHUNK_LENGTH
assert expected > 1
assert len(chunks) == expected
assert [chunk['data']['chunk_index'] for chunk in chunks] == list(range(expected))
assert {chunk['data']['chunk_amount'] for chunk in chunks} == {expected}
assert all(chunk['context'] == binding.model_dump() for chunk in chunks)
assert len({chunk['data']['file_key'] for chunk in chunks}) == 1
return chunks[0]['data']['file_key']
@pytest.mark.parametrize(
'profile,binding',
[('oss_dev', LEGACY), ('oss_dev', BINDING), ('shared', BINDING)],
ids=['legacy-oss', 'managed-oss', 'managed-shared'],
)
async def test_knowledge_file_roundtrip_reaches_plugin_original_bytes(tmp_path, monkeypatch, profile, binding):
async with protocol_stack(tmp_path, monkeypatch, profile, binding) as stack:
# Legacy plugin API sends no authority; Runtime supplies its trusted binding.
result = await asyncio.wait_for(
stack.plugin.call_action(
PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM,
{'storage_path': 'knowledge/original.bin'},
),
5,
)
assert await stack.plugin.read_local_file(result['file_key']) == PAYLOAD
assert len(stack.storage_calls) == 1
core_key = assert_chunks(stack.core_conn, binding)
plugin_key = assert_chunks(stack.bridge_conn, binding)
assert result['file_key'] == plugin_key != core_key
assert not (Path(stack.control.file_storage_dir) / core_key).exists()
assert not stack.control._owned_transfer_files
callbacks = [
message
for message in stack.control_conn.sent
if message.get('action') == PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM.value
]
assert len(callbacks) == 1
assert callbacks[0]['context'] == binding.model_dump()
assert callbacks[0]['data'] == {'storage_path': 'knowledge/original.bin'}
async def test_shared_control_rejects_legacy_chunks_before_storage(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
with stack.core.installation_scope(LEGACY):
with pytest.raises(ActionCallError, match='InstallationBinding|Legacy FILE_CHUNK'):
await asyncio.wait_for(stack.core.send_file(PAYLOAD, ''), 5)
assert not list(Path(stack.control.file_storage_dir).iterdir())
assert not stack.control._owned_transfer_files
async def test_candidate_artifact_pretransfer_does_not_require_active_installation(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
candidate = BINDING.model_copy(
update={'installation_uuid': 'candidate-installation', 'runtime_revision': 1, 'artifact_digest': 'c' * 64}
)
assert not stack.runtime.is_current_installation_binding(candidate)
with stack.core.installation_scope(candidate):
key = await asyncio.wait_for(stack.core.send_file(PAYLOAD, 'lbp'), 5)
assert_chunks(stack.core_conn, candidate)
assert await stack.control.read_local_file(key) == PAYLOAD
assert not stack.runtime.is_current_installation_binding(candidate)
async def test_nested_parser_target_owns_file_and_action_envelopes(tmp_path, monkeypatch):
async with protocol_stack(tmp_path, monkeypatch, 'shared', BINDING) as stack:
target = BINDING.model_copy(
update={
'installation_uuid': 'parser-installation',
'runtime_revision': 2,
'artifact_digest': 'b' * 64,
}
)
stack.runtime.activate_installation_binding(target)
parser_calls = []
restored = []
async def parse_document(author, name, context_data, file_bytes):
parser_calls.append((stack.control.current_action_context, author, name, context_data, file_bytes))
return {'documents': [{'text': 'parsed'}]}
stack.runtime.plugin_mgr.parse_document = parse_document
class ParserConnector:
async def require_workspace_context(self, context):
assert context.workspace_uuid == BINDING.workspace_uuid
async def call_parser(self, plugin_name, context_data, file_bytes):
assert plugin_name == 'tester/parser'
assert stack.core.current_action_context == BINDING
with stack.core.installation_scope(target):
result = await stack.core.parse_document('tester', 'parser', context_data, file_bytes)
restored.append(stack.core.resolve_outbound_action_context(None))
return result
stack.app.plugin_connector = ParserConnector()
result = await asyncio.wait_for(
stack.plugin.call_action(
PluginToRuntimeAction.INVOKE_PARSER,
{
'plugin_author': 'tester',
'plugin_name': 'parser',
'storage_path': 'knowledge/original.bin',
'filename': 'original.bin',
},
),
5,
)
assert result == {'documents': [{'text': 'parsed'}]}
key = assert_chunks(stack.core_conn, target)
parse_requests = [
message
for message in stack.core_conn.sent
if message.get('action') == LangBotToRuntimeAction.PARSE_DOCUMENT.value
]
assert len(parse_requests) == 1
assert parse_requests[0]['context'] == target.model_dump()
assert parse_requests[0]['data']['context']['file_key'] == key
assert parser_calls == [
(
target,
'tester',
'parser',
{
'mime_type': 'application/octet-stream',
'filename': 'original.bin',
'metadata': {},
},
PAYLOAD,
)
]
assert restored == [BINDING]
assert stack.core.current_action_context is None
assert stack.core.resolve_outbound_action_context(None) is None
assert not (Path(stack.control.file_storage_dir) / key).exists()
@@ -0,0 +1,193 @@
"""Exercise nested installation routing through real Core/SDK wire envelopes."""
from __future__ import annotations
import asyncio
import base64
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.entities.io.actions.enums import CommonAction, LangBotToRuntimeAction, PluginToRuntimeAction
from langbot_plugin.entities.io.req import ActionRequest
from langbot_plugin.entities.io.resp import ActionResponse
from langbot_plugin.runtime.io import handler as sdk_handler
from langbot.pkg.plugin.connector import PluginRuntimeConnector
from tests.unit_tests.plugin.test_handler_tenancy import RecordingConnection, make_handler, workspace_context
class ReplyingConnection(RecordingConnection):
"""Replace only the transport, retaining serialization and response routing."""
async def send(self, message: str) -> None:
await super().send(message)
request = json.loads(message)
if 'action' in request:
response = ActionResponse.success({'elements': []})
response.seq_id = request['seq_id']
await self.handler._route_response(response.seq_id, response.model_dump())
@property
def requests(self):
return [request for message in self.sent if 'action' in (request := json.loads(message))]
@pytest.fixture
def bridge(monkeypatch):
runtime_handler, app, binding_a = make_handler()
connection = ReplyingConnection()
connection.handler = runtime_handler
runtime_handler.conn = connection
monkeypatch.setattr(sdk_handler, 'FILE_CHUNK_LENGTH', 4)
binding_b = binding_a.model_copy(
update={
'installation_uuid': '00000000-0000-4000-8000-000000000002',
'runtime_revision': 2,
'artifact_digest': 'b' * 64,
}
)
return runtime_handler, app, connection, binding_a, binding_b
@pytest.mark.asyncio
@pytest.mark.parametrize('mode', ['managed', 'legacy'])
async def test_nested_invoke_parser_uses_target_for_every_chunk_and_parse(bridge, mode):
runtime_handler, app, connection, binding_a, binding_b = bridge
app.instance_config = SimpleNamespace(data={'plugin': {'enable': True}})
app.deployment.mode = 'cloud' if mode == 'managed' else 'oss'
connector = PluginRuntimeConnector(app, AsyncMock())
connector.handler = runtime_handler
app.plugin_connector = connector
execution_context = runtime_handler._execution_context(binding_a)
setting_b = SimpleNamespace(
installation_uuid=binding_b.installation_uuid,
runtime_revision=binding_b.runtime_revision,
artifact_digest=binding_b.artifact_digest,
install_info={'_artifact_storage': 'tenant_binary_storage_v1'} if mode == 'managed' else {},
)
connector._setting_for_plugin = AsyncMock(return_value=(execution_context, setting_b))
connector.require_workspace_context = AsyncMock(return_value=execution_context)
file_bytes = b'parser document'
app.rag_runtime_service = SimpleNamespace(get_file_stream=AsyncMock(return_value=file_bytes))
inbound_context = binding_a
if mode == 'legacy':
inbound_context = workspace_context().for_installation(binding_a.installation_uuid)
setting_a = SimpleNamespace(
plugin_author='author-a',
plugin_name='plugin-a',
installation_uuid=binding_a.installation_uuid,
runtime_revision=binding_a.runtime_revision,
artifact_digest=binding_a.artifact_digest,
)
app.persistence_mgr.execute_async.return_value = SimpleNamespace(first=lambda: setting_a)
expected = binding_b if mode == 'managed' else connector._legacy_oss_bridge_binding(execution_context)
request = ActionRequest.make_request(
101,
PluginToRuntimeAction.INVOKE_PARSER.value,
{'plugin_author': 'author-b', 'plugin_name': 'parser-b', 'storage_path': 'file-a'},
inbound_context,
)
await runtime_handler._handle_action(request.model_dump())
response = json.loads(connection.sent[-1])
assert response['code'] == 0, response
chunks = connection.requests[:-1]
parse = connection.requests[-1]
assert len(chunks) == 4
assert all(chunk['action'] == CommonAction.FILE_CHUNK.value for chunk in chunks)
assert parse['action'] == LangBotToRuntimeAction.PARSE_DOCUMENT.value
assert all(request['context'] == expected.model_dump() for request in connection.requests)
assert b''.join(base64.b64decode(chunk['data']['chunk_base64']) for chunk in chunks) == file_bytes
assert {chunk['data']['file_key'] for chunk in chunks} == {parse['data']['context']['file_key']}
connector._setting_for_plugin.assert_awaited_once_with('author-b', 'parser-b', require_enabled=True)
assert runtime_handler.current_action_context is None
assert runtime_handler.resolve_outbound_action_context(None) is None
@pytest.mark.asyncio
async def test_explicit_argument_overrides_scope_and_inbound_falls_back(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
token = runtime_handler._current_action_context.set(binding_a)
try:
with runtime_handler.installation_scope(binding_b):
await runtime_handler.call_action(
LangBotToRuntimeAction.LIST_PARSERS, {}, action_context=binding_a.model_dump()
)
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
assert [request['context'] for request in connection.requests] == [binding_a.model_dump()] * 2
assert runtime_handler.resolve_outbound_action_context(None) is None
@pytest.mark.asyncio
async def test_explicit_none_scope_clears_inbound_and_restores_outer_scope(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
token = runtime_handler._current_action_context.set(binding_a)
try:
with runtime_handler.installation_scope(binding_b):
await runtime_handler.ping()
await runtime_handler.list_parsers()
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
assert [request.get('context') for request in connection.requests] == [
None,
binding_b.model_dump(),
binding_a.model_dump(),
]
@pytest.mark.asyncio
@pytest.mark.parametrize('failure', [RuntimeError, asyncio.CancelledError])
async def test_scope_restores_after_exception_or_cancellation(bridge, failure):
runtime_handler, _, connection, binding_a, binding_b = bridge
with runtime_handler.installation_scope(binding_a):
with pytest.raises(failure):
with runtime_handler.installation_scope(binding_b):
await runtime_handler.list_parsers()
raise failure()
await runtime_handler.list_parsers()
await runtime_handler.list_parsers()
assert [request.get('context') for request in connection.requests] == [
binding_b.model_dump(),
binding_a.model_dump(),
None,
]
@pytest.mark.asyncio
async def test_concurrent_nested_scopes_do_not_leak_on_task_cancellation(bridge):
runtime_handler, _, connection, binding_a, binding_b = bridge
entered = asyncio.Event()
release = asyncio.Event()
async def cancelled_invocation():
with runtime_handler.installation_scope(binding_b):
await runtime_handler.list_parsers()
entered.set()
await release.wait()
token = runtime_handler._current_action_context.set(binding_a)
task = asyncio.create_task(cancelled_invocation())
try:
await asyncio.wait_for(entered.wait(), timeout=2)
with runtime_handler.installation_scope(None):
await runtime_handler.list_parsers()
task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
await runtime_handler.list_parsers()
finally:
runtime_handler._current_action_context.reset(token)
task.cancel()
await asyncio.gather(task, return_exceptions=True)
assert [request.get('context') for request in connection.requests] == [
binding_b.model_dump(),
None,
binding_a.model_dump(),
]
assert runtime_handler.resolve_outbound_action_context(None) is None
Generated
+4 -4
View File
@@ -2129,7 +2129,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.7" },
{ name = "langbot-plugin", specifier = "==0.5.8" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2196,7 +2196,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.7"
version = "0.5.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2217,9 +2217,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d2/7d/b024770f1f52c9dc71ddcab79fc07dfb6147ce8e645f0fed170d758e49cb/langbot_plugin-0.5.7.tar.gz", hash = "sha256:faecd566b7ff57dc5f3a5b1be01e2165d25924031c0a65a829c83b51c65255ee", size = 480635, upload-time = "2026-09-04T13:39:22.505Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/ab/8d8bd6b8355c5b30b4aab2b5322fd28d8f36158f36d6b4ee33f4df4bc861/langbot_plugin-0.5.8.tar.gz", hash = "sha256:46fbdf948f4a2d110607738ab35633c9ab22a30784edce3a4e684cd19bab84ff", size = 487972, upload-time = "2026-09-11T09:27:58.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/25/416745039cacace6a0ca3f719a2eff41dc74cdb30ef7ffaec1de0142bd2e/langbot_plugin-0.5.7-py3-none-any.whl", hash = "sha256:b1a20bcb6a2d482019eafbfe0ac628c106b8e915c7afe89df057b4d8e2015f05", size = 310463, upload-time = "2026-09-04T13:39:21.18Z" },
{ url = "https://files.pythonhosted.org/packages/c2/13/4939205e2f7922ec09113e390e35f9355ce6d93e1b380a4b3c49441130f5/langbot_plugin-0.5.8-py3-none-any.whl", hash = "sha256:4fbbcfa55f1dcb9af8392b48de8b7877ea79c880dfd268d651404702614d182e", size = 311552, upload-time = "2026-09-11T09:27:57.082Z" },
]
[[package]]
@@ -46,30 +46,10 @@ import {
} from '@/components/ui/tooltip';
import { systemInfo } from '@/app/infra/http';
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
/**
* Resolve the value referenced by a `show_if.field` string.
*
* Fields prefixed with `__system.` are looked up in the caller-supplied
* `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`).
* All other field names are resolved from the live form values first, then
* fall back to `externalDependentValues`.
*/
function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
const key = field.slice(SYSTEM_FIELD_PREFIX.length);
return systemContext?.[key];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
import {
resolveDisabledState,
resolveShowIfValue,
} from './DynamicFormConditions';
type DynamicFormValueSpec = Pick<
IDynamicFormItemSchema,
@@ -675,40 +655,19 @@ export default function DynamicFormComponent({
}
}
// ``disable_if`` mirrors ``show_if``'s evaluator but instead of
// hiding the field, leaves it visible and inert. Use it when the
// operator needs to see that the field exists yet cannot edit it
// under the current runtime state (e.g. sandbox-bound fields when
// Box is disabled).
let isDisabledByCondition = false;
if (config.disable_if) {
const dependValue = resolveShowIfValue(
config.disable_if.field,
// Keep locked fields visible and resolve only the applicable reason.
const { isDisabledByCondition, disabledTooltip: tooltip } =
resolveDisabledState(
config,
watchedValues as Record<string, unknown>,
externalDependentValues,
systemContext,
);
const cond = config.disable_if;
if (cond.operator === 'eq' && dependValue === cond.value) {
isDisabledByCondition = true;
} else if (cond.operator === 'neq' && dependValue !== cond.value) {
isDisabledByCondition = true;
} else if (
cond.operator === 'in' &&
Array.isArray(cond.value) &&
cond.value.includes(dependValue)
) {
isDisabledByCondition = true;
}
}
// All fields are disabled when editing (creation_settings are
// immutable) or when ``disable_if`` matches.
const isFieldDisabled = !!isEditing || isDisabledByCondition;
const disabledTooltip =
isDisabledByCondition && config.disabled_tooltip
? extractI18nObject(config.disabled_tooltip)
: '';
const disabledTooltip = tooltip ? extractI18nObject(tooltip) : '';
const renderDisabledTooltipIcon = () =>
disabledTooltip ? (
<DisabledTooltipIcon text={disabledTooltip} />
@@ -0,0 +1,71 @@
import {
SYSTEM_FIELD_PREFIX,
type IDynamicFormItemSchema,
type IShowIfCondition,
} from '@/app/infra/entities/form/dynamic';
/** System references use caller context; other fields prefer live form values. */
export function resolveShowIfValue(
field: string,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): unknown {
if (field.startsWith(SYSTEM_FIELD_PREFIX)) {
return systemContext?.[field.slice(SYSTEM_FIELD_PREFIX.length)];
}
if (watchedValues[field] !== undefined) {
return watchedValues[field];
}
return externalDependentValues?.[field];
}
export function matchesFormCondition(
condition: IShowIfCondition,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
): boolean {
const value = resolveShowIfValue(
condition.field,
watchedValues,
externalDependentValues,
systemContext,
);
switch (condition.operator) {
case 'eq':
return value === condition.value;
case 'neq':
return value !== condition.value;
case 'in':
return Array.isArray(condition.value) && condition.value.includes(value);
default:
return false;
}
}
export function resolveDisabledState(
config: Pick<
IDynamicFormItemSchema,
'disable_if' | 'disabled_tooltip' | 'disabled_tooltip_overrides'
>,
watchedValues: Record<string, unknown>,
externalDependentValues?: Record<string, unknown>,
systemContext?: Record<string, unknown>,
) {
const matches = (condition: IShowIfCondition) =>
matchesFormCondition(
condition,
watchedValues,
externalDependentValues,
systemContext,
);
const isDisabledByCondition =
!!config.disable_if && matches(config.disable_if);
const disabledTooltip = isDisabledByCondition
? (config.disabled_tooltip_overrides?.find((override) =>
matches(override.when),
)?.tooltip ?? config.disabled_tooltip)
: undefined;
return { isDisabledByCondition, disabledTooltip };
}
@@ -0,0 +1,14 @@
/** Unavailability takes priority over the deployment's scope restriction. */
export function getBoxScopeContext(
boxAvailable: boolean,
forcedTemplate?: string,
) {
forcedTemplate = forcedTemplate?.trim();
return {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !forcedTemplate,
// Only expose forced-scope reasons when the sandbox is available.
box_scope_forced: boxAvailable && !!forcedTemplate,
box_scope_forced_global: boxAvailable && forcedTemplate === '{global}',
};
}
@@ -8,6 +8,7 @@ import {
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { getBoxScopeContext } from './BoxScopeContext';
import { systemInfo } from '@/app/infra/http';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
@@ -425,13 +426,12 @@ export default function PipelineFormComponent({
// 2. the deployment pins all pipelines to a fixed scope via
// ``system.limitation.force_box_session_id_template`` (SaaS).
const forcedBoxTemplate =
systemInfo.limitation?.force_box_session_id_template || '';
systemInfo.limitation?.force_box_session_id_template?.trim() || '';
const boxScopeForced = !!forcedBoxTemplate;
const isLocalAgentStage = formName === 'ai' && stage.name === 'local-agent';
const stageSystemContext = isLocalAgentStage
? {
box_available: boxAvailable,
box_scope_editable: boxAvailable && !boxScopeForced,
...getBoxScopeContext(boxAvailable, forcedBoxTemplate),
pipeline_id: pipelineId,
}
: undefined;
@@ -39,6 +39,13 @@ export interface IDynamicFormItemSchema {
disable_if?: IShowIfCondition;
/** Tooltip shown next to the field label when ``disable_if`` is active. */
disabled_tooltip?: I18nObject;
/** Optional overrides evaluated in order when ``disable_if`` matches.
* The first matching ``when`` wins; otherwise use ``disabled_tooltip``.
* Conditions use the same operators and value lookup as ``disable_if``. */
disabled_tooltip_overrides?: {
when: IShowIfCondition;
tooltip: I18nObject;
}[];
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];
+232
View File
@@ -0,0 +1,232 @@
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
// UI fixtures only: real app/components, intercepted APIs, no production Box.
// Load the shipped metadata rather than reproducing its tooltip conditions.
const requireFromTest = createRequire(__filename);
const { load } = createRequire(requireFromTest.resolve('eslint'))(
'js-yaml',
) as {
load: (source: string) => unknown;
};
const aiMetadata = load(
readFileSync(
resolve(
__dirname,
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
),
'utf8',
),
);
const unavailableHint = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const forcedHint = '已强制使用全局沙箱,无法修改作用域。';
interface BoxState {
enabled: boolean;
available: boolean;
}
async function openPipeline(page: Page, box: BoxState, forced = '') {
await installLangBotApiMocks(page, {
authenticated: true,
storage: { langbot_language: 'zh-Hans' },
});
await page.route('**/api/v1/system/info', (route) =>
route.fulfill({
json: {
code: 0,
data: {
debug: false,
version: 'sandbox-scope-ui-fixture',
edition: 'community',
cloud_service_url: 'https://space.langbot.app',
enable_marketplace: true,
allow_modify_login_info: true,
disable_models_service: false,
limitation: {
max_bots: -1,
max_pipelines: -1,
max_extensions: -1,
force_box_session_id_template: forced,
},
outbound_ips: [],
wizard_status: 'completed',
wizard_progress: null,
},
},
}),
);
await page.route('**/api/v1/box/status', (route) =>
route.fulfill({
json: {
code: 0,
data: {
...box,
profile: 'UI fixture only',
recent_error_count: 0,
active_sessions: 0,
managed_processes: 0,
session_ttl_sec: 3600,
backend: { name: 'ui-fixture', available: box.available },
},
},
}),
);
await page.route(/\/api\/v1\/tools(?:\?.*)?$/, (route) =>
route.fulfill({ json: { code: 0, data: { tools: [] } } }),
);
await page.route('**/api/v1/pipelines/_/metadata', (route) =>
route.fulfill({ json: { code: 0, data: { configs: [aiMetadata] } } }),
);
await page.route('**/api/v1/pipelines/sandbox-scope-fixture', (route) =>
route.fulfill({
json: {
code: 0,
data: {
pipeline: {
uuid: 'sandbox-scope-fixture',
name: 'Sandbox scope — UI fixture only',
description: '',
emoji: '⚙️',
is_default: false,
config: {
ai: {
runner: { runner: 'local-agent' },
'local-agent': {
'box-session-id-template': '{launcher_type}_{launcher_id}',
},
},
trigger: {},
safety: {},
output: {},
},
},
},
},
}),
);
await page.goto('/home/pipelines?id=sandbox-scope-fixture');
await page.getByRole('button', { name: 'AI 能力', exact: true }).click();
// DynamicForm gates this control through its wrapper's pointer-events,
// and its label targets that wrapper rather than the nested select.
const scope = page
.locator('[data-slot="form-item"]')
.filter({ has: page.getByText('沙箱作用域', { exact: true }) })
.getByRole('combobox');
await expect(scope).toBeVisible();
return scope;
}
async function expectWarning(page: Page, hint: string) {
const warning = page.getByRole('button', { name: hint, exact: true });
await expect(warning).toBeVisible();
await warning.hover();
await expect(page.getByRole('tooltip')).toHaveText(hint);
}
async function expectNoWarning(page: Page) {
await expect(page.getByRole('button', { name: unavailableHint })).toHaveCount(
0,
);
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(0);
await expect(page.getByRole('tooltip')).toHaveCount(0);
}
test.describe('sandbox scope disabled reason (UI fixtures only)', () => {
for (const scenario of [
{ name: 'Box disabled', enabled: false, available: false, forced: '' },
{ name: 'Box disconnected', enabled: true, available: false, forced: '' },
{
name: 'unavailable Box takes precedence over forced global',
enabled: true,
available: false,
forced: '{global}',
},
]) {
test(scenario.name, async ({ page }) => {
const scope = await openPipeline(page, scenario, scenario.forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
await expect(page.getByRole('button', { name: forcedHint })).toHaveCount(
0,
);
});
}
for (const forced of ['{global}', ' {global} ']) {
test(`available Box with forced global explains the deployment restriction (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expect(scope).toHaveText('全局(所有人共享)');
await expectWarning(page, forcedHint);
await expect(
page.getByRole('button', { name: unavailableHint }),
).toHaveCount(0);
});
}
for (const forced of ['', ' ']) {
test(`available and unforced Box is editable without a disabled warning (${JSON.stringify(forced)})`, async ({
page,
}) => {
const scope = await openPipeline(
page,
{ enabled: true, available: true },
forced,
);
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expect(scope).toHaveText('每个会话(推荐)');
await expectNoWarning(page);
await scope.click();
await page
.getByRole('option', { name: '全局(所有人共享)', exact: true })
.click();
await expect(scope).toHaveText('全局(所有人共享)');
await expectNoWarning(page);
});
}
for (const forced of ['', '{global}']) {
test(`Box status polls update the warning without remounting (${forced || 'unforced'})`, async ({
page,
}) => {
await page.clock.install();
const box = { enabled: true, available: false };
const scope = await openPipeline(page, box, forced);
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await page.mouse.move(0, 0);
const recovered = page.waitForResponse('**/api/v1/box/status');
box.available = true;
await page.clock.fastForward(31_000);
await recovered;
if (forced) {
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, forcedHint);
} else {
await expect(scope).toHaveCSS('pointer-events', 'auto');
await expectNoWarning(page);
}
await page.mouse.move(0, 0);
const disconnected = page.waitForResponse('**/api/v1/box/status');
box.available = false;
await page.clock.fastForward(31_000);
await disconnected;
await expect(scope).toHaveCSS('pointer-events', 'none');
await expectWarning(page, unavailableHint);
await expect(page.getByRole('tooltip')).not.toContainText('强制');
});
}
});
@@ -0,0 +1,252 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { createRequire } from 'node:module';
import test from 'node:test';
import ts from 'typescript';
const require = createRequire(import.meta.url);
const { load } = createRequire(require.resolve('eslint'))('js-yaml');
const metadata = load(
fs.readFileSync(
new URL(
'../../../src/langbot/templates/metadata/pipeline/ai.yaml',
import.meta.url,
),
'utf8',
),
);
const scope = metadata.stages
.find((stage) => stage.name === 'local-agent')
.config.find((item) => item.name === 'box-session-id-template');
const unavailable = '沙箱未启用,请启用 Box 并确认连接正常后再修改作用域。';
const globalForced = '已强制使用全局沙箱,无法修改作用域。';
const customForced = '已强制使用固定沙箱作用域,无法修改作用域。';
function loadSource(relativePath) {
const filename = new URL(`../../src/${relativePath}`, import.meta.url);
assert.ok(fs.existsSync(filename), `Missing policy module: ${relativePath}`);
const compiled = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText;
const loaded = { exports: {} };
new Function('require', 'module', 'exports', compiled)(
(name) => {
if (name === '@/app/infra/entities/form/dynamic')
return loadSource('app/infra/entities/form/dynamic.ts');
throw new Error(`Unexpected runtime import: ${name}`);
},
loaded,
loaded.exports,
);
return loaded.exports;
}
function policies() {
return {
...loadSource('app/home/components/dynamic-form/DynamicFormConditions.ts'),
...loadSource(
'app/home/pipelines/components/pipeline-form/BoxScopeContext.ts',
),
};
}
function scopeState(available, forcedTemplate) {
const { getBoxScopeContext, resolveDisabledState } = policies();
return resolveDisabledState(
scope,
{},
undefined,
getBoxScopeContext(available, forcedTemplate),
);
}
test('sandbox default tooltip explains only unavailability', () => {
assert.equal(scope.disabled_tooltip.zh_Hans, unavailable);
});
for (const [name, available, template, expected] of [
['Box disabled', false, '', unavailable],
['Box disconnected', false, undefined, unavailable],
[
'unavailable takes precedence over forced global',
false,
'{global}',
unavailable,
],
[
'unavailable takes precedence over forced custom',
false,
'{pipeline_id}',
unavailable,
],
['available forced global', true, '{global}', globalForced],
['available padded forced global', true, ' {global} ', globalForced],
['available whitespace-only editable', true, ' ', undefined],
['available forced custom', true, '{pipeline_id}', customForced],
['available forced literal', true, 'tenant-sandbox', customForced],
['available editable', true, '', undefined],
['available without limitation', true, undefined, undefined],
]) {
test(name, () => {
const state = scopeState(available, template);
assert.equal(state.isDisabledByCondition, expected !== undefined);
assert.equal(state.disabledTooltip?.zh_Hans, expected);
});
}
test('reason follows availability and forced-scope transitions without mutating metadata', () => {
const snapshot = structuredClone(scope);
for (const [available, template, expected] of [
[false, '{global}', unavailable],
[true, '{global}', globalForced],
[true, '{pipeline_id}', customForced],
[true, '', undefined],
[false, '', unavailable],
[true, '', undefined],
]) {
assert.equal(
scopeState(available, template).disabledTooltip?.zh_Hans,
expected,
);
}
assert.deepEqual(scope, snapshot);
});
test('all sandbox reason variants preserve the eight metadata locales', () => {
const locales = [
'en_US',
'zh_Hans',
'zh_Hant',
'ja_JP',
'vi_VN',
'th_TH',
'es_ES',
'ru_RU',
].sort();
assert.equal(scope.disabled_tooltip_overrides?.length, 2);
const messages = [
scope.disabled_tooltip,
...scope.disabled_tooltip_overrides.map((entry) => entry.tooltip),
];
for (const message of messages) {
assert.deepEqual(Object.keys(message).sort(), locales);
for (const locale of locales) assert.ok(message[locale].trim(), locale);
}
for (const locale of locales) {
assert.equal(
new Set(messages.map((message) => message[locale])).size,
3,
locale,
);
assert.equal(
scopeState(false, '{global}').disabledTooltip[locale],
messages[0][locale],
);
assert.equal(
scopeState(true, '{global}').disabledTooltip[locale],
messages[1][locale],
);
assert.equal(
scopeState(true, '{pipeline_id}').disabledTooltip[locale],
messages[2][locale],
);
}
});
test('ordinary static disabled tooltip remains compatible', () => {
const { resolveDisabledState } = policies();
const tooltip = { en_US: 'Read only' };
const config = {
disable_if: { field: 'locked', operator: 'eq', value: true },
disabled_tooltip: tooltip,
};
assert.deepEqual(resolveDisabledState(config, { locked: true }), {
isDisabledByCondition: true,
disabledTooltip: tooltip,
});
assert.deepEqual(resolveDisabledState(config, { locked: false }), {
isDisabledByCondition: false,
disabledTooltip: undefined,
});
assert.equal(
resolveDisabledState({ disabled_tooltip: tooltip }, {}).disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState({ disable_if: config.disable_if }, { locked: true })
.disabledTooltip,
undefined,
);
});
test('conditional overrides reuse eq, neq, in and live/external/system resolution', () => {
const { matchesFormCondition, resolveDisabledState } = policies();
const watched = { mode: 'live', empty: null, '__system.locked': false };
const external = { mode: 'external', fallback: 3, empty: 'external' };
const system = { locked: true };
for (const [condition, expected] of [
[{ field: 'mode', operator: 'eq', value: 'live' }, true],
[{ field: 'mode', operator: 'eq', value: 'external' }, false],
[{ field: 'fallback', operator: 'neq', value: 4 }, true],
[{ field: 'fallback', operator: 'in', value: [2, 3] }, true],
[{ field: 'fallback', operator: 'in', value: '3' }, false],
[{ field: 'fallback', operator: 'eq', value: '3' }, false],
[{ field: 'empty', operator: 'eq', value: null }, true],
[{ field: '__system.locked', operator: 'eq', value: true }, true],
[{ field: 'absent', operator: 'eq', value: true }, false],
])
assert.equal(
matchesFormCondition(condition, watched, external, system),
expected,
);
const config = {
disable_if: { field: '__system.locked', operator: 'eq', value: true },
disabled_tooltip: { en_US: 'Default' },
disabled_tooltip_overrides: [
{
when: { field: 'mode', operator: 'eq', value: 'external' },
tooltip: { en_US: 'Wrong' },
},
{
when: { field: 'fallback', operator: 'in', value: [3] },
tooltip: { en_US: 'First match' },
},
{
when: { field: 'mode', operator: 'neq', value: 'external' },
tooltip: { en_US: 'Later match' },
},
],
};
assert.equal(
resolveDisabledState(config, watched, external, system).disabledTooltip
.en_US,
'First match',
);
assert.equal(
resolveDisabledState(config, {}, {}, system).disabledTooltip.en_US,
'Later match',
);
assert.equal(
resolveDisabledState(config, watched, external, { locked: false })
.disabledTooltip,
undefined,
);
assert.equal(
resolveDisabledState(
{ ...config, disabled_tooltip_overrides: [] },
watched,
external,
system,
).disabledTooltip.en_US,
'Default',
);
const unmatched = {
...config,
disabled_tooltip_overrides: [config.disabled_tooltip_overrides[0]],
};
assert.equal(
resolveDisabledState(unmatched, watched, external, system).disabledTooltip
.en_US,
'Default',
);
});