mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 06:41:07 +08:00
Merge branch 'main' into feat/dify-builder
This commit is contained in:
135 files changed
+4880
-954
No files matched your search
@@ -7,6 +7,18 @@ class AgentBackendConfig(BaseSettings):
|
||||
Configuration settings for the Agent backend runtime integration.
|
||||
"""
|
||||
|
||||
AGENT_SANDBOX_METERING_ENABLED: bool = Field(default=False, description="Persist independent E2B runtime usage.")
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID: str = Field(
|
||||
default="", description="Allowed E2B project/team for this database."
|
||||
)
|
||||
AGENT_SANDBOX_METERING_START_AT: str = Field(
|
||||
default="", description="Immutable UTC activation instant, aligned to a whole second; required when enabled."
|
||||
)
|
||||
AGENT_SANDBOX_METERING_INTERVAL_SECONDS: int | str = Field(
|
||||
default=60,
|
||||
description="Celery Beat interval in seconds; validated only when registering optional usage collection.",
|
||||
)
|
||||
|
||||
AGENT_BACKEND_BASE_URL: str | None = Field(
|
||||
description="Base URL for the Dify Agent backend service.",
|
||||
default=None,
|
||||
|
||||
@@ -19,6 +19,7 @@ from . import mail as _mail
|
||||
from . import runtime_credentials as _runtime_credentials
|
||||
from .agent import files as _agent_files
|
||||
from .agent import llm as _agent_llm
|
||||
from .agent import runtime_usage as _agent_runtime_usage
|
||||
from .agent import tools as _agent_tools
|
||||
from .app import dsl as _app_dsl
|
||||
from .app import file_grants as _app_file_grants
|
||||
@@ -34,6 +35,7 @@ __all__ = [
|
||||
"_agent_config",
|
||||
"_agent_files",
|
||||
"_agent_llm",
|
||||
"_agent_runtime_usage",
|
||||
"_agent_tools",
|
||||
"_app_dsl",
|
||||
"_app_file_grants",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Trusted ingestion for independently persisted E2B execution usage."""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import agent_inner_api_only
|
||||
from fields.base import ResponseModel
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.helper import dump_response
|
||||
from services.agent.runtime_usage_service import SandboxUsageError, SandboxUsageEvent, SandboxUsageService
|
||||
|
||||
|
||||
class SandboxUsageHttpError(BaseHTTPException):
|
||||
error_code = "sandbox_usage_invalid_request"
|
||||
description = "Invalid sandbox usage request."
|
||||
code = 400
|
||||
|
||||
def __init__(self, error: SandboxUsageError | None = None) -> None:
|
||||
if error is not None:
|
||||
self.error_code = error.code
|
||||
self.description = error.code
|
||||
self.code = error.status_code
|
||||
super().__init__(self.description)
|
||||
|
||||
|
||||
class SandboxUsagePayload(BaseModel):
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
events: list[SandboxUsageEvent] = Field(min_length=1, max_length=100)
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SandboxUsageQuery(BaseModel):
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SandboxUsageResponse(ResponseModel):
|
||||
accepted: int
|
||||
duplicates: int
|
||||
conflicts: int
|
||||
ignored: int
|
||||
|
||||
|
||||
class SandboxUsageDiagnosticsResponse(ResponseModel):
|
||||
unresolved_events: int = 0
|
||||
conflict_events: int = 0
|
||||
open_executions: int = 0
|
||||
unattributed_executions: int = 0
|
||||
|
||||
|
||||
class SandboxUsageStateResponse(ResponseModel):
|
||||
enabled: bool
|
||||
project_id: str
|
||||
started_at: str | None
|
||||
checkpoint_at: str | None
|
||||
full_scan_at: str | None
|
||||
diagnostics: SandboxUsageDiagnosticsResponse
|
||||
|
||||
|
||||
register_schema_models(inner_api_ns, SandboxUsagePayload, SandboxUsageQuery)
|
||||
register_response_schema_models(inner_api_ns, SandboxUsageResponse, SandboxUsageStateResponse)
|
||||
|
||||
|
||||
@inner_api_ns.route("/agent/sandbox-usage/events")
|
||||
class SandboxUsageEventsApi(Resource):
|
||||
@agent_inner_api_only
|
||||
@inner_api_ns.doc("inner_agent_sandbox_usage_events")
|
||||
@inner_api_ns.expect(inner_api_ns.models[SandboxUsagePayload.__name__])
|
||||
@inner_api_ns.response(200, "Durably committed", inner_api_ns.models[SandboxUsageResponse.__name__])
|
||||
def post(self):
|
||||
request.max_content_length = 1024 * 1024
|
||||
try:
|
||||
payload = SandboxUsagePayload.model_validate(inner_api_ns.payload or {})
|
||||
result = SandboxUsageService.ingest(project_id=payload.project_id, events=payload.events)
|
||||
except ValidationError as exc:
|
||||
raise SandboxUsageHttpError() from exc
|
||||
except SandboxUsageError as exc:
|
||||
raise SandboxUsageHttpError(exc) from exc
|
||||
return dump_response(SandboxUsageResponse, result)
|
||||
|
||||
|
||||
@inner_api_ns.route("/agent/sandbox-usage/state")
|
||||
class SandboxUsageStateApi(Resource):
|
||||
@agent_inner_api_only
|
||||
@inner_api_ns.doc("inner_agent_sandbox_usage_state", params=query_params_from_model(SandboxUsageQuery))
|
||||
@inner_api_ns.response(
|
||||
200, "Persisted activation and completed scans", inner_api_ns.models[SandboxUsageStateResponse.__name__]
|
||||
)
|
||||
def get(self):
|
||||
try:
|
||||
query = SandboxUsageQuery.model_validate(request.args.to_dict(flat=True))
|
||||
result = SandboxUsageService.get_state(project_id=query.project_id)
|
||||
except ValidationError as exc:
|
||||
raise SandboxUsageHttpError() from exc
|
||||
except SandboxUsageError as exc:
|
||||
raise SandboxUsageHttpError(exc) from exc
|
||||
return dump_response(SandboxUsageStateResponse, result)
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
import ssl
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from typing import Any, NotRequired
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from celery import Celery, Task
|
||||
@@ -14,6 +15,8 @@ from enums import DeploymentEdition
|
||||
from extensions.redis_names import normalize_redis_key_prefix
|
||||
from extensions.workflow_warm_shutdown import setup_workflow_warm_shutdown_handler
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CelerySentinelKwargsDict(TypedDict):
|
||||
socket_timeout: float | None
|
||||
@@ -36,6 +39,7 @@ class CelerySSLOptionsDict(TypedDict):
|
||||
class CeleryBeatScheduleEntry(TypedDict):
|
||||
task: str
|
||||
schedule: crontab | timedelta
|
||||
options: NotRequired[dict[str, Any]]
|
||||
|
||||
|
||||
def _enqueue_initial_community_telemetry_heartbeat(sender: Any, **_: Any) -> None:
|
||||
@@ -166,6 +170,7 @@ def init_app(app: DifyApp) -> Celery:
|
||||
setup_workflow_warm_shutdown_handler()
|
||||
|
||||
imports = [
|
||||
"schedule.collect_agent_sandbox_usage", # optional background provider accounting
|
||||
"tasks.async_workflow_tasks", # trigger workers
|
||||
"tasks.collect_agent_resources_task", # retired Agent resource collection
|
||||
"tasks.trigger_processing_tasks", # async trigger processing
|
||||
@@ -183,6 +188,20 @@ def init_app(app: DifyApp) -> Celery:
|
||||
|
||||
# if you add a new task, please add the switch to CeleryScheduleTasksConfig
|
||||
beat_schedule: dict[str, CeleryBeatScheduleEntry] = {}
|
||||
if dify_config.AGENT_SANDBOX_METERING_ENABLED:
|
||||
try:
|
||||
interval = int(dify_config.AGENT_SANDBOX_METERING_INTERVAL_SECONDS)
|
||||
if interval < 1:
|
||||
raise ValueError("collection interval must be positive")
|
||||
collection_schedule = timedelta(seconds=interval)
|
||||
except (ValueError, OverflowError):
|
||||
logger.warning("Skipping sandbox usage schedule: interval must be a valid positive number of seconds")
|
||||
else:
|
||||
beat_schedule["collect_agent_sandbox_usage"] = {
|
||||
"task": "schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage",
|
||||
"schedule": collection_schedule,
|
||||
"options": {"expires": interval},
|
||||
}
|
||||
if dify_config.ENABLE_CONVERSATION_CLEANUP_TASK:
|
||||
imports.append("tasks.delete_conversation_task")
|
||||
beat_schedule["conversation_cleanup_sweeper"] = {
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Add independent sandbox execution accounting.
|
||||
|
||||
Revision ID: e7b2a9c4d601
|
||||
Revises: d8e4a6b1c902
|
||||
Create Date: 2026-09-20 12:00:00
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from models.types import StringUUID
|
||||
|
||||
revision = "e7b2a9c4d601"
|
||||
down_revision = "d8e4a6b1c902"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _identity_columns():
|
||||
# UTC timestamps, explicit application values; not runtime measurements.
|
||||
return [
|
||||
sa.Column("id", StringUUID(), primary_key=True, nullable=False),
|
||||
sa.Column("provider", sa.String(32), nullable=False),
|
||||
sa.Column("provider_project_id", sa.String(128), nullable=False),
|
||||
sa.Column("allocation_id", StringUUID()),
|
||||
sa.Column("tenant_id", StringUUID()),
|
||||
sa.Column("app_id", StringUUID()),
|
||||
sa.Column("agent_id", StringUUID()),
|
||||
sa.Column("binding_id", StringUUID()),
|
||||
sa.Column("workspace_id", StringUUID()),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
|
||||
]
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"agent_sandbox_usage_events",
|
||||
*_identity_columns(),
|
||||
sa.Column("source", sa.String(32), nullable=False),
|
||||
sa.Column("source_event_id", sa.String(255), nullable=False),
|
||||
sa.Column("event_type", sa.String(96), nullable=False),
|
||||
sa.Column("sandbox_id", sa.String(128)),
|
||||
sa.Column("provider_execution_id", sa.String(128)),
|
||||
sa.Column("operation_id", StringUUID()),
|
||||
sa.Column("lease_id", StringUUID()),
|
||||
sa.Column("attempt", sa.Integer()),
|
||||
sa.Column("purpose", sa.String(32)),
|
||||
sa.Column("correlation", sa.JSON(), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime()),
|
||||
sa.Column("received_at", sa.DateTime(), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("canonical_hash", sa.String(64), nullable=False),
|
||||
sa.Column("payload_versions", sa.JSON(), nullable=False),
|
||||
sa.Column("resolution", sa.JSON(), nullable=False),
|
||||
sa.Column("projection_status", sa.String(32), nullable=False),
|
||||
sa.Column("projection_error_code", sa.String(96)),
|
||||
sa.Column("processed_at", sa.DateTime()),
|
||||
sa.UniqueConstraint(
|
||||
"provider", "provider_project_id", "source", "source_event_id", name="sandbox_usage_event_unique"
|
||||
),
|
||||
sa.CheckConstraint("source IN ('application', 'provider')", name="sandbox_usage_event_source_check"),
|
||||
)
|
||||
for name, columns in (
|
||||
("sandbox_usage_event_sandbox_idx", ["provider_project_id", "sandbox_id", "occurred_at"]),
|
||||
("sandbox_usage_event_allocation_idx", ["allocation_id", "received_at"]),
|
||||
("sandbox_usage_event_owner_idx", ["provider_project_id", "binding_id", "event_type"]),
|
||||
("sandbox_usage_event_pending_idx", ["projection_status", "received_at"]),
|
||||
("sandbox_usage_checkpoint_idx", ["provider_project_id", "event_type", "purpose", "occurred_at"]),
|
||||
("sandbox_usage_diagnostics_idx", ["provider_project_id", "projection_status"]),
|
||||
("sandbox_usage_execution_idx", ["provider_project_id", "provider_execution_id", "projection_error_code"]),
|
||||
):
|
||||
op.create_index(name, "agent_sandbox_usage_events", columns)
|
||||
|
||||
op.create_table(
|
||||
"agent_sandbox_executions",
|
||||
*_identity_columns(),
|
||||
sa.Column("sandbox_id", sa.String(128), nullable=False),
|
||||
sa.Column("provider_execution_id", sa.String(128), nullable=False),
|
||||
sa.Column("attribution_status", sa.String(32), nullable=False),
|
||||
sa.Column("template_id", sa.String(128)),
|
||||
sa.Column("template_build_id", sa.String(128)),
|
||||
sa.Column("vcpu_count", sa.Integer()),
|
||||
sa.Column("memory_mib", sa.BigInteger()),
|
||||
sa.Column("started_at", sa.DateTime()),
|
||||
sa.Column("started_at_source", sa.String(32)),
|
||||
sa.Column("started_at_precision_ms", sa.Integer()),
|
||||
sa.Column("terminal_event_at", sa.DateTime()),
|
||||
sa.Column("metered_duration_ms", sa.BigInteger()),
|
||||
sa.Column("measurement_source", sa.String(32), nullable=False),
|
||||
sa.Column("state", sa.String(16), nullable=False),
|
||||
sa.Column("quality", sa.String(16), nullable=False),
|
||||
sa.Column("close_reason", sa.String(64)),
|
||||
sa.Column("terminal_event_id", StringUUID()),
|
||||
sa.Column("last_reconciled_at", sa.DateTime()),
|
||||
sa.Column("revision", sa.Integer(), nullable=False),
|
||||
sa.UniqueConstraint(
|
||||
"provider", "provider_project_id", "provider_execution_id", name="sandbox_execution_unique"
|
||||
),
|
||||
sa.CheckConstraint("metered_duration_ms IS NULL OR metered_duration_ms >= 0", name="sandbox_duration_check"),
|
||||
sa.CheckConstraint("vcpu_count IS NULL OR vcpu_count > 0", name="sandbox_vcpu_check"),
|
||||
sa.CheckConstraint("memory_mib IS NULL OR memory_mib > 0", name="sandbox_memory_check"),
|
||||
sa.CheckConstraint(
|
||||
"quality <> 'metered' OR (state = 'closed' AND measurement_source = 'provider_execution' "
|
||||
"AND metered_duration_ms IS NOT NULL AND started_at IS NOT NULL "
|
||||
"AND vcpu_count IS NOT NULL AND memory_mib IS NOT NULL)",
|
||||
name="sandbox_metered_check",
|
||||
),
|
||||
)
|
||||
for name, columns in (
|
||||
("sandbox_execution_project_time_idx", ["provider_project_id", "started_at"]),
|
||||
("sandbox_execution_tenant_time_idx", ["tenant_id", "started_at"]),
|
||||
("sandbox_execution_sandbox_idx", ["provider_project_id", "sandbox_id", "started_at"]),
|
||||
("sandbox_execution_allocation_idx", ["allocation_id"]),
|
||||
("sandbox_execution_reconcile_idx", ["quality", "state", "last_reconciled_at"]),
|
||||
("sandbox_execution_diagnostics_idx", ["provider_project_id", "state", "attribution_status"]),
|
||||
):
|
||||
op.create_index(name, "agent_sandbox_executions", columns)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("agent_sandbox_executions")
|
||||
op.drop_table("agent_sandbox_usage_events")
|
||||
@@ -30,6 +30,7 @@ from .agent import (
|
||||
WorkflowAgentBindingType,
|
||||
WorkflowAgentNodeBinding,
|
||||
)
|
||||
from .agent_sandbox_usage import AgentSandboxExecution, AgentSandboxUsageEvent
|
||||
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
|
||||
from .comment import (
|
||||
WorkflowComment,
|
||||
@@ -180,6 +181,8 @@ __all__ = [
|
||||
"AgentHomeSnapshot",
|
||||
"AgentIconType",
|
||||
"AgentKind",
|
||||
"AgentSandboxExecution",
|
||||
"AgentSandboxUsageEvent",
|
||||
"AgentScope",
|
||||
"AgentSkillBinding",
|
||||
"AgentSource",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""Independent E2B accounting records, retained after business resource deletion.
|
||||
|
||||
All timestamps are explicit naive UTC values, following the API's database
|
||||
convention. These records deliberately have no business-object foreign keys.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
from .base import Base, DefaultFieldsMixin
|
||||
from .types import StringUUID
|
||||
|
||||
|
||||
class AgentSandboxUsageEvent(DefaultFieldsMixin, Base):
|
||||
__tablename__ = "agent_sandbox_usage_events"
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint(
|
||||
"provider", "provider_project_id", "source", "source_event_id", name="sandbox_usage_event_unique"
|
||||
),
|
||||
sa.Index("sandbox_usage_event_sandbox_idx", "provider_project_id", "sandbox_id", "occurred_at"),
|
||||
sa.Index("sandbox_usage_event_allocation_idx", "allocation_id", "received_at"),
|
||||
sa.Index("sandbox_usage_event_owner_idx", "provider_project_id", "binding_id", "event_type"),
|
||||
sa.Index("sandbox_usage_event_pending_idx", "projection_status", "received_at"),
|
||||
sa.Index("sandbox_usage_checkpoint_idx", "provider_project_id", "event_type", "purpose", "occurred_at"),
|
||||
sa.Index("sandbox_usage_diagnostics_idx", "provider_project_id", "projection_status"),
|
||||
sa.Index(
|
||||
"sandbox_usage_execution_idx", "provider_project_id", "provider_execution_id", "projection_error_code"
|
||||
),
|
||||
sa.CheckConstraint("source IN ('application', 'provider')", name="sandbox_usage_event_source_check"),
|
||||
)
|
||||
|
||||
provider: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="e2b")
|
||||
provider_project_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
|
||||
source: Mapped[str] = mapped_column(sa.String(32), nullable=False)
|
||||
source_event_id: Mapped[str] = mapped_column(sa.String(255), nullable=False)
|
||||
event_type: Mapped[str] = mapped_column(sa.String(96), nullable=False)
|
||||
sandbox_id: Mapped[str | None] = mapped_column(sa.String(128))
|
||||
provider_execution_id: Mapped[str | None] = mapped_column(sa.String(128))
|
||||
allocation_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
operation_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
lease_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
attempt: Mapped[int | None] = mapped_column(sa.Integer)
|
||||
tenant_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
app_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
agent_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
binding_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
workspace_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
purpose: Mapped[str | None] = mapped_column(sa.String(32))
|
||||
correlation: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, default=dict)
|
||||
occurred_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
|
||||
received_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, default=naive_utc_now)
|
||||
payload: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False)
|
||||
canonical_hash: Mapped[str] = mapped_column(sa.String(64), nullable=False)
|
||||
payload_versions: Mapped[list[dict[str, Any]]] = mapped_column(sa.JSON, nullable=False, default=list)
|
||||
resolution: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, default=dict)
|
||||
projection_status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="pending")
|
||||
projection_error_code: Mapped[str | None] = mapped_column(sa.String(96))
|
||||
processed_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
|
||||
|
||||
|
||||
class AgentSandboxExecution(DefaultFieldsMixin, Base):
|
||||
"""One provider execution, not one request, lease, or connect call."""
|
||||
|
||||
__tablename__ = "agent_sandbox_executions"
|
||||
__table_args__ = (
|
||||
sa.UniqueConstraint(
|
||||
"provider", "provider_project_id", "provider_execution_id", name="sandbox_execution_unique"
|
||||
),
|
||||
sa.Index("sandbox_execution_project_time_idx", "provider_project_id", "started_at"),
|
||||
sa.Index("sandbox_execution_tenant_time_idx", "tenant_id", "started_at"),
|
||||
sa.Index("sandbox_execution_sandbox_idx", "provider_project_id", "sandbox_id", "started_at"),
|
||||
sa.Index("sandbox_execution_allocation_idx", "allocation_id"),
|
||||
sa.Index("sandbox_execution_reconcile_idx", "quality", "state", "last_reconciled_at"),
|
||||
sa.Index("sandbox_execution_diagnostics_idx", "provider_project_id", "state", "attribution_status"),
|
||||
sa.CheckConstraint("metered_duration_ms IS NULL OR metered_duration_ms >= 0", name="sandbox_duration_check"),
|
||||
sa.CheckConstraint("vcpu_count IS NULL OR vcpu_count > 0", name="sandbox_vcpu_check"),
|
||||
sa.CheckConstraint("memory_mib IS NULL OR memory_mib > 0", name="sandbox_memory_check"),
|
||||
sa.CheckConstraint(
|
||||
"quality <> 'metered' OR (state = 'closed' AND measurement_source = 'provider_execution' "
|
||||
"AND metered_duration_ms IS NOT NULL AND started_at IS NOT NULL "
|
||||
"AND vcpu_count IS NOT NULL AND memory_mib IS NOT NULL)",
|
||||
name="sandbox_metered_check",
|
||||
),
|
||||
)
|
||||
|
||||
provider: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="e2b")
|
||||
provider_project_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
|
||||
sandbox_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
|
||||
provider_execution_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
|
||||
allocation_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
tenant_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
app_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
agent_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
binding_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
workspace_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
attribution_status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="unresolved")
|
||||
template_id: Mapped[str | None] = mapped_column(sa.String(128))
|
||||
template_build_id: Mapped[str | None] = mapped_column(sa.String(128))
|
||||
vcpu_count: Mapped[int | None] = mapped_column(sa.Integer)
|
||||
memory_mib: Mapped[int | None] = mapped_column(sa.BigInteger)
|
||||
started_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
|
||||
started_at_source: Mapped[str | None] = mapped_column(sa.String(32))
|
||||
started_at_precision_ms: Mapped[int | None] = mapped_column(sa.Integer)
|
||||
terminal_event_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
|
||||
metered_duration_ms: Mapped[int | None] = mapped_column(sa.BigInteger)
|
||||
measurement_source: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="unknown")
|
||||
state: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="unknown")
|
||||
quality: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="pending")
|
||||
close_reason: Mapped[str | None] = mapped_column(sa.String(64))
|
||||
terminal_event_id: Mapped[str | None] = mapped_column(StringUUID)
|
||||
last_reconciled_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
|
||||
revision: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=1)
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Use existing Celery infrastructure to request one bounded E2B usage scan."""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from celery import shared_task
|
||||
from pydantic import BaseModel, ConfigDict, StrictBool
|
||||
|
||||
from configs import dify_config
|
||||
from core.helper import ssrf_proxy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _CollectionResponse(BaseModel):
|
||||
completed: StrictBool
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
@shared_task(
|
||||
name="schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage",
|
||||
queue="ops_trace",
|
||||
soft_time_limit=270,
|
||||
time_limit=300,
|
||||
ignore_result=True,
|
||||
)
|
||||
def collect_agent_sandbox_usage() -> bool:
|
||||
"""Accounting failures fail this job only; the next scheduled scan retries.
|
||||
|
||||
No provider credential is copied to the API. The Agent's authenticated
|
||||
control plane owns its E2B client and executes one request-scoped scan.
|
||||
"""
|
||||
if not dify_config.AGENT_SANDBOX_METERING_ENABLED:
|
||||
return False
|
||||
try:
|
||||
base_url = (dify_config.AGENT_BACKEND_BASE_URL or "").rstrip("/")
|
||||
token = (dify_config.AGENT_BACKEND_API_TOKEN or "").strip()
|
||||
project_id = dify_config.AGENT_SANDBOX_METERING_PROJECT_ID.strip()
|
||||
parsed = urlsplit(base_url)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or not token
|
||||
or not project_id
|
||||
):
|
||||
raise ValueError("Sandbox usage collection requires backend URL, token, and project configuration")
|
||||
response = ssrf_proxy.post(
|
||||
f"{base_url}/internal/e2b/usage/collect",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
json={"project_id": project_id},
|
||||
max_retries=0,
|
||||
timeout=260,
|
||||
)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
completed = _CollectionResponse.model_validate(response.json()).completed
|
||||
finally:
|
||||
response.close()
|
||||
if not completed:
|
||||
raise RuntimeError("Sandbox usage collection did not complete its bounded scan")
|
||||
except Exception as exc:
|
||||
# Do not include credentials, response bodies, or provider metadata.
|
||||
logger.warning("Background sandbox usage collection failed", extra={"error_type": type(exc).__name__})
|
||||
raise
|
||||
logger.info("Background sandbox usage collection completed")
|
||||
return True
|
||||
@@ -0,0 +1,680 @@
|
||||
"""Forward-only, provider-authoritative sandbox accounting.
|
||||
|
||||
The activation event is also a project-scoped transaction lock. This keeps
|
||||
deduplication, checkpoints, and execution projection atomic
|
||||
across API workers without coupling billing records to business transactions.
|
||||
Business creation/lookups never call accounting. Ownership is resolved only
|
||||
while collecting provider events. No network calls belong in this service.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable, Mapping
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, Literal, TypeVar
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from core.db.session_factory import session_factory
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.agent import Agent, AgentWorkspace, AgentWorkspaceBinding
|
||||
from models.agent_sandbox_usage import AgentSandboxExecution, AgentSandboxUsageEvent
|
||||
from models.model import App
|
||||
|
||||
_T = TypeVar("_T")
|
||||
_COLLECTOR_EVENT_TYPES = {"collector_checkpoint", "collector_retention_gap"}
|
||||
_TERMINAL_TYPES = {"sandbox.lifecycle.paused", "sandbox.lifecycle.killed"}
|
||||
_NONTERMINAL_TYPES = {"sandbox.lifecycle.created", "sandbox.lifecycle.resumed", "sandbox.lifecycle.updated"}
|
||||
|
||||
|
||||
class SandboxUsageError(ValueError):
|
||||
def __init__(self, code: str, *, status_code: int = 400) -> None:
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class SandboxUsageEvent(BaseModel):
|
||||
id: str = Field(min_length=1, max_length=255)
|
||||
source: Literal["application", "provider"]
|
||||
type: str = Field(min_length=1, max_length=96)
|
||||
timestamp: str | None = None
|
||||
sandbox_id: str | None = Field(default=None, max_length=128)
|
||||
execution_id: str | None = Field(default=None, max_length=128)
|
||||
payload: dict[str, JsonValue]
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def _utc(value: object) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise SandboxUsageError("invalid_timestamp")
|
||||
try:
|
||||
result = datetime.fromisoformat(value)
|
||||
except ValueError as exc:
|
||||
raise SandboxUsageError("invalid_timestamp") from exc
|
||||
if result.tzinfo is None:
|
||||
raise SandboxUsageError("timestamp_requires_timezone")
|
||||
return result.astimezone(UTC).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _iso(value: datetime | None) -> str | None:
|
||||
return value.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") if value else None
|
||||
|
||||
|
||||
def _hash(payload: Mapping[str, Any]) -> str:
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _mapping(value: object) -> dict[str, Any]:
|
||||
return dict(value) if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def _pick(data: Mapping[str, Any], *names: str) -> Any:
|
||||
for name in names:
|
||||
if name in data and data[name] is not None:
|
||||
return data[name]
|
||||
return None
|
||||
|
||||
|
||||
def _positive_int(value: object, *, allow_zero: bool = False) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if type(value) is not int or value < (0 if allow_zero else 1) or value > 2**63 - 1:
|
||||
raise SandboxUsageError("invalid_provider_quantity")
|
||||
return value
|
||||
|
||||
|
||||
def _provider_values(event: SandboxUsageEvent, project_id: str) -> dict[str, Any]:
|
||||
"""Normalize the current v2 API and webhook spellings, not old event versions."""
|
||||
raw = event.payload
|
||||
data = _mapping(_pick(raw, "eventData", "event_data", "data"))
|
||||
execution = _mapping(data.get("execution"))
|
||||
team = _pick(raw, "sandboxTeamId", "sandbox_team_id")
|
||||
if team != project_id:
|
||||
raise SandboxUsageError("provider_project_mismatch", status_code=403)
|
||||
raw_id = raw.get("id")
|
||||
raw_type = raw.get("type")
|
||||
sandbox_id = _pick(raw, "sandboxId", "sandbox_id")
|
||||
execution_id = _pick(raw, "sandboxExecutionId", "sandbox_execution_id")
|
||||
if raw_id != event.id or raw_type != event.type:
|
||||
raise SandboxUsageError("provider_envelope_mismatch")
|
||||
for declared, actual in ((event.sandbox_id, sandbox_id), (event.execution_id, execution_id)):
|
||||
if declared is not None and declared != actual:
|
||||
raise SandboxUsageError("provider_envelope_mismatch")
|
||||
for identifier in (sandbox_id, execution_id):
|
||||
if identifier is not None and (not isinstance(identifier, str) or not identifier or len(identifier) > 128):
|
||||
raise SandboxUsageError("invalid_provider_identifier")
|
||||
return {
|
||||
"id": event.id,
|
||||
"version": raw.get("version"),
|
||||
"type": event.type,
|
||||
"timestamp": _iso(_utc(raw.get("timestamp"))),
|
||||
"sandbox_id": sandbox_id,
|
||||
"execution_id": execution_id,
|
||||
"project_id": project_id,
|
||||
"template_id": _pick(raw, "sandboxTemplateId", "sandbox_template_id"),
|
||||
"template_build_id": _pick(raw, "sandboxBuildId", "sandbox_build_id"),
|
||||
"started_at": _iso(_utc(_pick(execution, "started_at", "startedAt"))),
|
||||
"duration_ms": _positive_int(_pick(execution, "execution_time", "executionTime"), allow_zero=True),
|
||||
"vcpu_count": _positive_int(_pick(execution, "vcpu_count", "vcpuCount")),
|
||||
"memory_mib": _positive_int(_pick(execution, "memory_mb", "memoryMb")),
|
||||
"metadata": _mapping(_pick(data, "sandbox_metadata", "sandboxMetadata")),
|
||||
}
|
||||
|
||||
|
||||
def _enrich(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""Accept absent-field enrichment; never silently choose contradictory facts."""
|
||||
merged = dict(existing)
|
||||
for key, value in incoming.items():
|
||||
old = merged.get(key)
|
||||
if isinstance(old, dict) and isinstance(value, dict):
|
||||
value = _enrich(old, value)
|
||||
if value is None:
|
||||
return None
|
||||
elif old is not None and value is not None and old != value:
|
||||
return None
|
||||
if value is not None:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
class SandboxUsageService:
|
||||
@staticmethod
|
||||
def _config(project_id: str | None = None) -> tuple[str, datetime]:
|
||||
if not dify_config.AGENT_SANDBOX_METERING_ENABLED:
|
||||
raise SandboxUsageError("sandbox_metering_disabled", status_code=503)
|
||||
configured = dify_config.AGENT_SANDBOX_METERING_PROJECT_ID.strip()
|
||||
if not configured or len(configured) > 128:
|
||||
raise SandboxUsageError("sandbox_metering_project_not_configured", status_code=503)
|
||||
if project_id is not None and project_id != configured:
|
||||
raise SandboxUsageError("sandbox_metering_project_not_allowed", status_code=403)
|
||||
try:
|
||||
start = _utc(dify_config.AGENT_SANDBOX_METERING_START_AT)
|
||||
except SandboxUsageError as exc:
|
||||
raise SandboxUsageError("sandbox_metering_start_not_configured", status_code=503) from exc
|
||||
if start is None or start.microsecond:
|
||||
raise SandboxUsageError("sandbox_metering_start_requires_whole_second", status_code=503)
|
||||
return configured, start
|
||||
|
||||
@classmethod
|
||||
def _write(cls, project_id: str, action: Callable[[Session, datetime], _T]) -> _T:
|
||||
_, start = cls._config(project_id)
|
||||
# The first activation can race. Retrying the whole short transaction
|
||||
# also handles concurrent unique-key inserts without losing an event.
|
||||
for attempt in range(3):
|
||||
try:
|
||||
with session_factory.create_session() as session, session.begin():
|
||||
activation = cls._event(session, project_id, "application", "metering_activated", lock=True)
|
||||
if activation is None:
|
||||
activation = AgentSandboxUsageEvent(
|
||||
provider="e2b",
|
||||
provider_project_id=project_id,
|
||||
source="application",
|
||||
source_event_id="metering_activated",
|
||||
event_type="metering_activated",
|
||||
occurred_at=start,
|
||||
payload={"started_at": _iso(start)},
|
||||
canonical_hash=_hash({"started_at": _iso(start)}),
|
||||
projection_status="applied",
|
||||
)
|
||||
session.add(activation)
|
||||
session.flush()
|
||||
elif _utc(activation.payload.get("started_at")) != start:
|
||||
raise SandboxUsageError("sandbox_metering_start_is_immutable", status_code=409)
|
||||
result = action(session, start)
|
||||
return result
|
||||
except IntegrityError:
|
||||
if attempt == 2:
|
||||
raise
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
@staticmethod
|
||||
def _event(
|
||||
session: Session, project_id: str, source: str, event_id: str, *, lock: bool = False
|
||||
) -> AgentSandboxUsageEvent | None:
|
||||
query = sa.select(AgentSandboxUsageEvent).where(
|
||||
AgentSandboxUsageEvent.provider == "e2b",
|
||||
AgentSandboxUsageEvent.provider_project_id == project_id,
|
||||
AgentSandboxUsageEvent.source == source,
|
||||
AgentSandboxUsageEvent.source_event_id == event_id,
|
||||
)
|
||||
return session.scalar(query.with_for_update() if lock else query)
|
||||
|
||||
@classmethod
|
||||
def get_state(cls, *, project_id: str) -> dict[str, Any]:
|
||||
if not dify_config.AGENT_SANDBOX_METERING_ENABLED:
|
||||
return {
|
||||
"enabled": False,
|
||||
"project_id": project_id,
|
||||
"started_at": None,
|
||||
"checkpoint_at": None,
|
||||
"full_scan_at": None,
|
||||
"diagnostics": {
|
||||
"unresolved_events": 0,
|
||||
"conflict_events": 0,
|
||||
"open_executions": 0,
|
||||
"unattributed_executions": 0,
|
||||
},
|
||||
}
|
||||
|
||||
def read(session: Session, start: datetime) -> dict[str, Any]:
|
||||
checkpoints = session.execute(
|
||||
sa.select(AgentSandboxUsageEvent.purpose, sa.func.max(AgentSandboxUsageEvent.occurred_at))
|
||||
.where(
|
||||
AgentSandboxUsageEvent.provider_project_id == project_id,
|
||||
AgentSandboxUsageEvent.source == "application",
|
||||
AgentSandboxUsageEvent.event_type == "collector_checkpoint",
|
||||
AgentSandboxUsageEvent.projection_status == "applied",
|
||||
)
|
||||
.group_by(AgentSandboxUsageEvent.purpose)
|
||||
).all()
|
||||
ends = [end for _purpose, end in checkpoints if end is not None]
|
||||
full = [end for purpose, end in checkpoints if purpose == "full" and end is not None]
|
||||
event_scope = sa.and_(
|
||||
AgentSandboxUsageEvent.provider == "e2b",
|
||||
AgentSandboxUsageEvent.provider_project_id == project_id,
|
||||
sa.or_(
|
||||
AgentSandboxUsageEvent.source == "provider",
|
||||
AgentSandboxUsageEvent.event_type.in_(_COLLECTOR_EVENT_TYPES),
|
||||
),
|
||||
)
|
||||
execution_scope = AgentSandboxExecution.provider_project_id == project_id
|
||||
diagnostics = {
|
||||
"unresolved_events": session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(AgentSandboxUsageEvent)
|
||||
.where(event_scope, AgentSandboxUsageEvent.projection_status.in_(["pending", "unresolved"]))
|
||||
)
|
||||
or 0,
|
||||
"conflict_events": session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(AgentSandboxUsageEvent)
|
||||
.where(event_scope, AgentSandboxUsageEvent.projection_status == "conflict")
|
||||
)
|
||||
or 0,
|
||||
"open_executions": session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(AgentSandboxExecution)
|
||||
.where(execution_scope, AgentSandboxExecution.state == "open")
|
||||
)
|
||||
or 0,
|
||||
"unattributed_executions": session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(AgentSandboxExecution)
|
||||
.where(execution_scope, AgentSandboxExecution.attribution_status != "resolved")
|
||||
)
|
||||
or 0,
|
||||
}
|
||||
return {
|
||||
"enabled": True,
|
||||
"project_id": project_id,
|
||||
"started_at": _iso(start),
|
||||
"checkpoint_at": _iso(max(ends, default=None)),
|
||||
"full_scan_at": _iso(max(full, default=None)),
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
|
||||
_, configured_start = cls._config(project_id)
|
||||
with session_factory.create_session() as session:
|
||||
activation = cls._event(session, project_id, "application", "metering_activated")
|
||||
if activation is not None:
|
||||
if _utc(activation.payload.get("started_at")) != configured_start:
|
||||
raise SandboxUsageError("sandbox_metering_start_is_immutable", status_code=409)
|
||||
return read(session, configured_start)
|
||||
# Only the first lookup initializes T0. Ordinary polling is SELECT-only,
|
||||
# does not lock the project row, and cannot delay an ingestion transaction.
|
||||
return cls._write(project_id, read)
|
||||
|
||||
@classmethod
|
||||
def ingest(cls, *, project_id: str, events: list[SandboxUsageEvent]) -> dict[str, int]:
|
||||
if len(events) > 100:
|
||||
raise SandboxUsageError("too_many_usage_events")
|
||||
|
||||
def ingest_batch(session: Session, start: datetime) -> dict[str, int]:
|
||||
counts = {"accepted": 0, "duplicates": 0, "conflicts": 0, "ignored": 0}
|
||||
for event in events:
|
||||
counts[cls._ingest_one(session, project_id, start, event)] += 1
|
||||
return counts
|
||||
|
||||
return cls._write(project_id, ingest_batch)
|
||||
|
||||
@classmethod
|
||||
def _ingest_one(cls, session: Session, project_id: str, start: datetime, event: SandboxUsageEvent) -> str:
|
||||
if event.source == "provider":
|
||||
canonical = _provider_values(event, project_id)
|
||||
begun = _utc(canonical["started_at"])
|
||||
occurred = _utc(canonical["timestamp"])
|
||||
if (begun is not None and begun < start) or (begun is None and occurred is not None and occurred < start):
|
||||
previous = cls._event(session, project_id, "provider", event.id)
|
||||
physical = (
|
||||
session.scalar(
|
||||
sa.select(AgentSandboxExecution).where(
|
||||
AgentSandboxExecution.provider_project_id == project_id,
|
||||
AgentSandboxExecution.provider_execution_id == canonical["execution_id"],
|
||||
)
|
||||
)
|
||||
if canonical["execution_id"]
|
||||
else None
|
||||
)
|
||||
# New old executions are excluded; corrections to known facts
|
||||
# must still go through conflict detection and preserve evidence.
|
||||
if physical is None and previous is None:
|
||||
return "ignored"
|
||||
if physical is None or physical.started_at is None:
|
||||
if physical is not None:
|
||||
session.delete(physical)
|
||||
for pending in session.scalars(
|
||||
sa.select(AgentSandboxUsageEvent).where(
|
||||
AgentSandboxUsageEvent.provider_project_id == project_id,
|
||||
AgentSandboxUsageEvent.source == "provider",
|
||||
AgentSandboxUsageEvent.provider_execution_id == canonical["execution_id"],
|
||||
)
|
||||
):
|
||||
pending.projection_status = "applied"
|
||||
pending.projection_error_code = "execution_predates_activation"
|
||||
if previous is not None:
|
||||
digest = _hash(canonical)
|
||||
if not any(version.get("hash") == digest for version in previous.payload_versions):
|
||||
previous.payload_versions = [
|
||||
*previous.payload_versions,
|
||||
{
|
||||
"hash": digest,
|
||||
"payload": event.payload,
|
||||
"received_at": _iso(naive_utc_now()),
|
||||
},
|
||||
]
|
||||
previous.resolution = {**previous.resolution, "scope": "excluded_before_activation"}
|
||||
return "ignored"
|
||||
else:
|
||||
if event.type not in _COLLECTOR_EVENT_TYPES:
|
||||
raise SandboxUsageError("unsupported_application_event")
|
||||
occurred = _utc(event.timestamp)
|
||||
if occurred is not None and occurred < start:
|
||||
return "ignored"
|
||||
canonical = event.model_dump(mode="json")
|
||||
if event.type == "collector_checkpoint":
|
||||
cls._validate_checkpoint(event.payload, start)
|
||||
elif event.type == "collector_retention_gap":
|
||||
lower = _utc(event.payload.get("uncovered_start"))
|
||||
upper = _utc(event.payload.get("uncovered_end"))
|
||||
if (
|
||||
lower is None
|
||||
or upper is None
|
||||
or occurred is None
|
||||
or not start <= lower < upper <= occurred
|
||||
or _positive_int(event.payload.get("assumed_retention_seconds")) is None
|
||||
):
|
||||
raise SandboxUsageError("invalid_collector_retention_gap")
|
||||
digest = _hash(canonical)
|
||||
row = cls._event(session, project_id, event.source, event.id)
|
||||
if row is not None:
|
||||
if row.canonical_hash == digest:
|
||||
if event.source == "provider" and row.projection_status != "conflict":
|
||||
cls._project_provider(session, row, canonical, start)
|
||||
return "duplicates"
|
||||
versions = list(row.payload_versions or [])
|
||||
if not any(version.get("hash") == digest for version in versions):
|
||||
versions.append({"hash": digest, "payload": event.payload, "received_at": _iso(naive_utc_now())})
|
||||
row.payload_versions = versions
|
||||
old = _mapping(row.resolution.get("canonical"))
|
||||
# Attribution labels are optional. A bad/changed label must not
|
||||
# invalidate otherwise identical provider-measured runtime.
|
||||
merged = (
|
||||
_enrich(
|
||||
{key: value for key, value in old.items() if key != "metadata"},
|
||||
{key: value for key, value in canonical.items() if key != "metadata"},
|
||||
)
|
||||
if old and event.source == "provider"
|
||||
else None
|
||||
)
|
||||
if merged is not None:
|
||||
merged["metadata"] = canonical.get("metadata") or old.get("metadata", {})
|
||||
if merged is None or row.projection_status == "conflict":
|
||||
cls._conflict(session, row, "event_content_conflict")
|
||||
return "conflicts"
|
||||
if merged == old:
|
||||
return "duplicates"
|
||||
canonical = merged
|
||||
row.resolution = {"canonical": canonical}
|
||||
row.canonical_hash = _hash(canonical)
|
||||
else:
|
||||
row = AgentSandboxUsageEvent(
|
||||
provider="e2b",
|
||||
provider_project_id=project_id,
|
||||
source=event.source,
|
||||
source_event_id=event.id,
|
||||
event_type=event.type,
|
||||
sandbox_id=event.sandbox_id,
|
||||
provider_execution_id=event.execution_id,
|
||||
occurred_at=occurred,
|
||||
payload=event.payload,
|
||||
canonical_hash=digest,
|
||||
resolution={"canonical": canonical},
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
if event.source == "provider":
|
||||
cls._project_provider(session, row, canonical, start)
|
||||
else:
|
||||
row.projection_status = "applied"
|
||||
if event.type == "collector_checkpoint":
|
||||
row.occurred_at = _utc(event.payload["window_end"])
|
||||
row.purpose = str(event.payload["mode"])
|
||||
elif event.type == "collector_retention_gap":
|
||||
row.projection_status = "unresolved"
|
||||
row.projection_error_code = "provider_retention_gap"
|
||||
row.processed_at = naive_utc_now()
|
||||
session.flush()
|
||||
return "conflicts" if row.projection_status == "conflict" else "accepted"
|
||||
|
||||
@staticmethod
|
||||
def _validate_checkpoint(payload: Mapping[str, Any], start: datetime) -> None:
|
||||
lower = _utc(payload.get("window_start"))
|
||||
upper = _utc(payload.get("window_end"))
|
||||
scan = _utc(payload.get("scan_started_at"))
|
||||
if (
|
||||
payload.get("completed") is not True
|
||||
or payload.get("mode") not in {"incremental", "full"}
|
||||
or lower is None
|
||||
or upper is None
|
||||
or scan is None
|
||||
or not start <= lower <= upper <= scan
|
||||
or scan > naive_utc_now()
|
||||
or type(payload.get("pages")) is not int
|
||||
or payload["pages"] < 1
|
||||
or type(payload.get("events")) is not int
|
||||
or payload["events"] < 0
|
||||
):
|
||||
raise SandboxUsageError("invalid_collector_checkpoint")
|
||||
|
||||
@staticmethod
|
||||
def _verified_owner(session: Session, sandbox_id: str, metadata: dict[str, Any]) -> AgentWorkspaceBinding | None:
|
||||
"""Resolve optional labels through the existing tenant-owned resource chain.
|
||||
|
||||
Metadata alone is never ownership evidence. Retired rows are still valid
|
||||
historical associations until physical collection removes them.
|
||||
"""
|
||||
binding_id = metadata.get("dify.binding_id")
|
||||
tenant_id = metadata.get("dify.tenant_id")
|
||||
if not isinstance(binding_id, str) or not isinstance(tenant_id, str):
|
||||
return None
|
||||
try:
|
||||
binding_id, tenant_id = str(UUID(binding_id)), str(UUID(tenant_id))
|
||||
except ValueError:
|
||||
return None
|
||||
binding = session.scalar(
|
||||
sa.select(AgentWorkspaceBinding)
|
||||
.join(
|
||||
AgentWorkspace,
|
||||
sa.and_(
|
||||
AgentWorkspace.id == AgentWorkspaceBinding.workspace_id,
|
||||
AgentWorkspace.tenant_id == AgentWorkspaceBinding.tenant_id,
|
||||
AgentWorkspace.app_id == AgentWorkspaceBinding.app_id,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
App,
|
||||
sa.and_(
|
||||
App.id == AgentWorkspaceBinding.app_id,
|
||||
App.tenant_id == AgentWorkspaceBinding.tenant_id,
|
||||
),
|
||||
)
|
||||
.join(
|
||||
Agent,
|
||||
sa.and_(
|
||||
Agent.id == AgentWorkspaceBinding.agent_id,
|
||||
Agent.tenant_id == AgentWorkspaceBinding.tenant_id,
|
||||
),
|
||||
)
|
||||
.where(
|
||||
AgentWorkspaceBinding.id == binding_id,
|
||||
AgentWorkspaceBinding.tenant_id == tenant_id,
|
||||
AgentWorkspaceBinding.backend_binding_ref == sandbox_id,
|
||||
AgentWorkspace.backend_workspace_ref == sandbox_id,
|
||||
)
|
||||
)
|
||||
if binding is None:
|
||||
return None
|
||||
for key, expected in (
|
||||
("dify.agent_id", binding.agent_id),
|
||||
("dify.workspace_id", binding.workspace_id),
|
||||
):
|
||||
supplied = metadata.get(key)
|
||||
if supplied is not None and supplied != expected:
|
||||
return None
|
||||
return binding
|
||||
|
||||
@classmethod
|
||||
def _attribute_execution(cls, session: Session, execution: AgentSandboxExecution, metadata: dict[str, Any]) -> None:
|
||||
owner = cls._verified_owner(session, execution.sandbox_id, metadata)
|
||||
if owner is None or execution.attribution_status == "conflict":
|
||||
# Existing snapshots survive missing/deleted business resources or
|
||||
# malformed labels. No fallback to unverified allocation metadata.
|
||||
return
|
||||
expected = (owner.tenant_id, owner.app_id, owner.agent_id, owner.id, owner.workspace_id)
|
||||
retained = (
|
||||
execution.tenant_id,
|
||||
execution.app_id,
|
||||
execution.agent_id,
|
||||
execution.binding_id,
|
||||
execution.workspace_id,
|
||||
)
|
||||
if execution.binding_id is not None:
|
||||
if retained != expected:
|
||||
execution.attribution_status = "conflict"
|
||||
return
|
||||
other_owner = session.scalar(
|
||||
sa.select(AgentSandboxExecution.id)
|
||||
.where(
|
||||
AgentSandboxExecution.provider == execution.provider,
|
||||
AgentSandboxExecution.provider_project_id == execution.provider_project_id,
|
||||
AgentSandboxExecution.sandbox_id == execution.sandbox_id,
|
||||
AgentSandboxExecution.attribution_status == "resolved",
|
||||
AgentSandboxExecution.binding_id != owner.id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if other_owner is not None:
|
||||
execution.attribution_status = "conflict"
|
||||
return
|
||||
execution.tenant_id, execution.app_id, execution.agent_id = owner.tenant_id, owner.app_id, owner.agent_id
|
||||
execution.binding_id, execution.workspace_id = owner.id, owner.workspace_id
|
||||
execution.attribution_status = "resolved"
|
||||
|
||||
@classmethod
|
||||
def _project_provider(
|
||||
cls, session: Session, row: AgentSandboxUsageEvent, data: dict[str, Any], start: datetime
|
||||
) -> None:
|
||||
row.sandbox_id = data["sandbox_id"]
|
||||
row.provider_execution_id = data["execution_id"]
|
||||
started_at = _utc(data["started_at"])
|
||||
if data["version"] != "v2" or not row.sandbox_id or not row.provider_execution_id:
|
||||
row.projection_status = "unresolved"
|
||||
row.projection_error_code = "incomplete_provider_execution"
|
||||
return
|
||||
if started_at is not None and started_at < start:
|
||||
cls._conflict(session, row, "execution_start_scope_conflict")
|
||||
return
|
||||
excluded = session.scalar(
|
||||
sa.select(AgentSandboxUsageEvent.id)
|
||||
.where(
|
||||
AgentSandboxUsageEvent.provider_project_id == row.provider_project_id,
|
||||
AgentSandboxUsageEvent.source == "provider",
|
||||
AgentSandboxUsageEvent.provider_execution_id == row.provider_execution_id,
|
||||
AgentSandboxUsageEvent.projection_error_code == "execution_predates_activation",
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
if excluded is not None:
|
||||
if started_at is not None:
|
||||
cls._conflict(session, row, "execution_start_scope_conflict")
|
||||
else:
|
||||
row.projection_status = "applied"
|
||||
row.projection_error_code = "execution_predates_activation"
|
||||
return
|
||||
execution = session.scalar(
|
||||
sa.select(AgentSandboxExecution).where(
|
||||
AgentSandboxExecution.provider == "e2b",
|
||||
AgentSandboxExecution.provider_project_id == row.provider_project_id,
|
||||
AgentSandboxExecution.provider_execution_id == row.provider_execution_id,
|
||||
)
|
||||
)
|
||||
if execution is None:
|
||||
execution = AgentSandboxExecution(
|
||||
provider="e2b",
|
||||
provider_project_id=row.provider_project_id,
|
||||
sandbox_id=row.sandbox_id,
|
||||
provider_execution_id=row.provider_execution_id,
|
||||
started_at=started_at,
|
||||
started_at_source="provider_execution" if started_at else None,
|
||||
started_at_precision_ms=1000 if started_at else None,
|
||||
state="open",
|
||||
quality="pending",
|
||||
attribution_status="unresolved",
|
||||
)
|
||||
session.add(execution)
|
||||
session.flush()
|
||||
if execution.sandbox_id != row.sandbox_id or (
|
||||
execution.started_at is not None and started_at is not None and execution.started_at != started_at
|
||||
):
|
||||
cls._conflict(session, row, "execution_identity_conflict", execution)
|
||||
return
|
||||
if started_at is not None and execution.started_at is None:
|
||||
execution.started_at = started_at
|
||||
execution.started_at_source = "provider_execution"
|
||||
execution.started_at_precision_ms = 1000
|
||||
cls._attribute_execution(session, execution, _mapping(data["metadata"]))
|
||||
for field, previous in (
|
||||
("template_id", execution.template_id),
|
||||
("template_build_id", execution.template_build_id),
|
||||
("vcpu_count", execution.vcpu_count),
|
||||
("memory_mib", execution.memory_mib),
|
||||
):
|
||||
value = data[field]
|
||||
if previous is not None and value is not None and previous != value:
|
||||
cls._conflict(session, row, "execution_resources_conflict", execution)
|
||||
return
|
||||
if value is not None:
|
||||
setattr(execution, field, value)
|
||||
if row.event_type in _TERMINAL_TYPES:
|
||||
duration = data["duration_ms"]
|
||||
if (
|
||||
execution.metered_duration_ms is not None
|
||||
and duration is not None
|
||||
and execution.metered_duration_ms != duration
|
||||
):
|
||||
cls._conflict(session, row, "execution_duration_conflict", execution)
|
||||
return
|
||||
execution.state = "closed"
|
||||
if duration is not None:
|
||||
execution.metered_duration_ms = duration
|
||||
if execution.quality != "conflict" and all(
|
||||
value is not None
|
||||
for value in (
|
||||
execution.metered_duration_ms,
|
||||
execution.started_at,
|
||||
execution.vcpu_count,
|
||||
execution.memory_mib,
|
||||
)
|
||||
):
|
||||
execution.quality = "metered"
|
||||
execution.measurement_source = "provider_execution"
|
||||
if execution.terminal_event_id is None:
|
||||
execution.terminal_event_id = row.id
|
||||
execution.terminal_event_at = row.occurred_at
|
||||
execution.close_reason = row.event_type.rsplit(".", 1)[-1]
|
||||
execution.last_reconciled_at = naive_utc_now()
|
||||
execution.revision += 1
|
||||
if execution.quality == "conflict":
|
||||
row.projection_status = "conflict"
|
||||
row.projection_error_code = "execution_conflict"
|
||||
elif execution.quality == "metered" or row.event_type in _NONTERMINAL_TYPES:
|
||||
row.projection_status = "applied"
|
||||
row.projection_error_code = None
|
||||
else:
|
||||
row.projection_status = "unresolved"
|
||||
row.projection_error_code = "incomplete_provider_execution"
|
||||
|
||||
@staticmethod
|
||||
def _conflict(
|
||||
session: Session, row: AgentSandboxUsageEvent, code: str, execution: AgentSandboxExecution | None = None
|
||||
) -> None:
|
||||
row.projection_status = "conflict"
|
||||
row.projection_error_code = code
|
||||
if execution is None and row.provider_execution_id:
|
||||
execution = session.scalar(
|
||||
sa.select(AgentSandboxExecution).where(
|
||||
AgentSandboxExecution.provider_project_id == row.provider_project_id,
|
||||
AgentSandboxExecution.provider_execution_id == row.provider_execution_id,
|
||||
)
|
||||
)
|
||||
if execution:
|
||||
execution.quality = "conflict"
|
||||
execution.revision += 1
|
||||
@@ -0,0 +1,219 @@
|
||||
"""Authenticated HTTP contract backed by the real accounting service and DB."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from typing import TypedDict
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from flask import Flask
|
||||
from flask.testing import FlaskClient
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from controllers.inner_api import bp as inner_api_bp
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from models.agent_sandbox_usage import AgentSandboxUsageEvent
|
||||
from tests.unit_tests.config_override import config_overrides_context
|
||||
|
||||
PROJECT = "431de237-596f-4d59-8a85-20a9846bf243"
|
||||
|
||||
|
||||
class _EventPayload(TypedDict):
|
||||
id: str
|
||||
source: str
|
||||
type: str
|
||||
timestamp: str
|
||||
payload: dict[str, JsonValue]
|
||||
|
||||
|
||||
class _BatchPayload(TypedDict):
|
||||
project_id: str
|
||||
events: list[_EventPayload]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> Iterator[FlaskClient]:
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(inner_api_bp)
|
||||
with config_overrides_context(
|
||||
PLUGIN_DAEMON_KEY="test-daemon",
|
||||
INNER_API_KEY_FOR_PLUGIN="test-inner",
|
||||
AGENT_SANDBOX_METERING_ENABLED=True,
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID=PROJECT,
|
||||
AGENT_SANDBOX_METERING_START_AT="2026-09-20T00:00:00Z",
|
||||
):
|
||||
yield app.test_client()
|
||||
|
||||
|
||||
def payload() -> _BatchPayload:
|
||||
event_id = str(uuid4())
|
||||
return {
|
||||
"project_id": PROJECT,
|
||||
"events": [
|
||||
{
|
||||
"id": event_id,
|
||||
"source": "provider",
|
||||
"type": "sandbox.lifecycle.paused",
|
||||
"timestamp": "2026-09-20T00:01:00Z",
|
||||
"payload": {
|
||||
"id": event_id,
|
||||
"version": "v2",
|
||||
"type": "sandbox.lifecycle.paused",
|
||||
"timestamp": "2026-09-20T00:01:00Z",
|
||||
"sandboxId": "sandbox-test",
|
||||
"sandboxExecutionId": "execution-test",
|
||||
"sandboxTeamId": PROJECT,
|
||||
"eventData": {
|
||||
"execution": {
|
||||
"started_at": "2026-09-20T00:00:02Z",
|
||||
"execution_time": 12345,
|
||||
"memory_mb": 1024,
|
||||
"vcpu_count": 2,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_state_requires_inner_auth(client: FlaskClient) -> None:
|
||||
response = client.get(f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_state_exposes_persisted_scope_without_secret_or_environment(client: FlaskClient) -> None:
|
||||
response = client.get(
|
||||
f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}", headers={"X-Inner-Api-Key": "test-inner"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json == {
|
||||
"enabled": True,
|
||||
"project_id": PROJECT,
|
||||
"started_at": "2026-09-20T00:00:00Z",
|
||||
"checkpoint_at": None,
|
||||
"full_scan_at": None,
|
||||
"diagnostics": {
|
||||
"unresolved_events": 0,
|
||||
"conflict_events": 0,
|
||||
"open_executions": 0,
|
||||
"unattributed_executions": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("query", ["", "?project_id=", f"?project_id={PROJECT}&unexpected=value"])
|
||||
def test_state_rejects_invalid_query_before_activation(
|
||||
client: FlaskClient, sqlite_session_factory: sessionmaker[Session], query: str
|
||||
) -> None:
|
||||
response = client.get(f"/inner/api/agent/sandbox-usage/state{query}", headers={"X-Inner-Api-Key": "test-inner"})
|
||||
assert response.status_code == 400
|
||||
body = response.get_json()
|
||||
assert isinstance(body, dict)
|
||||
assert body["code"] == "sandbox_usage_invalid_request"
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxUsageEvent)) == 0
|
||||
|
||||
|
||||
def test_state_rejects_project_outside_allowlist_without_activation(
|
||||
client: FlaskClient, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
response = client.get(
|
||||
"/inner/api/agent/sandbox-usage/state?project_id=other-project", headers={"X-Inner-Api-Key": "test-inner"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
body = response.get_json()
|
||||
assert isinstance(body, dict)
|
||||
assert body["code"] == "sandbox_metering_project_not_allowed"
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxUsageEvent)) == 0
|
||||
|
||||
|
||||
def test_state_reports_start_change_conflict_and_preserves_activation(client: FlaskClient) -> None:
|
||||
url = f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}"
|
||||
headers = {"X-Inner-Api-Key": "test-inner"}
|
||||
original = client.get(url, headers=headers)
|
||||
assert original.status_code == 200
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-21T00:00:00Z"):
|
||||
changed = client.get(url, headers=headers)
|
||||
assert changed.status_code == 409
|
||||
body = changed.get_json()
|
||||
assert isinstance(body, dict)
|
||||
assert body["code"] == "sandbox_metering_start_is_immutable"
|
||||
restored = client.get(url, headers=headers)
|
||||
assert restored.status_code == 200
|
||||
assert restored.json == original.json
|
||||
|
||||
|
||||
def test_state_reports_invalid_start_as_configuration_error(client: FlaskClient) -> None:
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-20T00:00:00"):
|
||||
response = client.get(
|
||||
f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}", headers={"X-Inner-Api-Key": "test-inner"}
|
||||
)
|
||||
assert response.status_code == 503
|
||||
body = response.get_json()
|
||||
assert isinstance(body, dict)
|
||||
assert body["code"] == "sandbox_metering_start_not_configured"
|
||||
|
||||
|
||||
def test_events_ack_only_after_persisted_and_duplicate_is_idempotent(
|
||||
client: FlaskClient, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
body = payload()
|
||||
for expected in (
|
||||
{"accepted": 1, "duplicates": 0, "conflicts": 0, "ignored": 0},
|
||||
{"accepted": 0, "duplicates": 1, "conflicts": 0, "ignored": 0},
|
||||
):
|
||||
response = client.post(
|
||||
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json == expected
|
||||
with sqlite_session_factory() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(AgentSandboxUsageEvent)
|
||||
.where(AgentSandboxUsageEvent.source_event_id == body["events"][0]["id"])
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
|
||||
def test_ingestion_rejects_other_project_and_oversized_batch(client: FlaskClient) -> None:
|
||||
body = payload()
|
||||
body["project_id"] = "other-project"
|
||||
response = client.post(
|
||||
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
|
||||
)
|
||||
assert response.status_code == 403
|
||||
body = payload()
|
||||
body["events"] *= 101
|
||||
response = client.post(
|
||||
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_ingestion_payload_limit(client: FlaskClient) -> None:
|
||||
body = payload()
|
||||
body["events"][0]["payload"] = {"oversized": "x" * (1024 * 1024)}
|
||||
response = client.post(
|
||||
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
|
||||
)
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
def test_schema_and_http_exclude_business_operation_envelope_fields(client: FlaskClient) -> None:
|
||||
schema = inner_api_ns.models["SandboxUsageEvent"].__schema__
|
||||
assert set(schema["properties"]) == {"id", "source", "type", "timestamp", "sandbox_id", "execution_id", "payload"}
|
||||
assert schema["additionalProperties"] is False
|
||||
body = payload()
|
||||
response = client.post(
|
||||
"/inner/api/agent/sandbox-usage/events",
|
||||
json={**body, "events": [{**body["events"][0], "allocation_id": str(uuid4())}]},
|
||||
headers={"X-Inner-Api-Key": "test-inner"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -162,6 +162,7 @@ class TestCelerySSLConfiguration:
|
||||
|
||||
# Mock all the scheduler configs
|
||||
mock_config.CELERY_BEAT_SCHEDULER_TIME = 1
|
||||
mock_config.AGENT_SANDBOX_METERING_ENABLED = False
|
||||
mock_config.ENABLE_CONVERSATION_CLEANUP_TASK = False
|
||||
mock_config.CONVERSATION_CLEANUP_TASK_INTERVAL = 5
|
||||
mock_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK = False
|
||||
@@ -212,6 +213,7 @@ class TestCelerySSLConfiguration:
|
||||
mock_config.CELERY_TASK_ANNOTATIONS = {}
|
||||
|
||||
mock_config.CELERY_BEAT_SCHEDULER_TIME = 1
|
||||
mock_config.AGENT_SANDBOX_METERING_ENABLED = False
|
||||
mock_config.ENABLE_CONVERSATION_CLEANUP_TASK = True
|
||||
mock_config.CONVERSATION_CLEANUP_TASK_INTERVAL = 5
|
||||
mock_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK = False
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""The scheduled collector reuses existing Celery workers and bounded HTTP."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from datetime import timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from celery.contrib.testing.app import setup_default_app
|
||||
|
||||
from configs.extra.agent_backend_config import AgentBackendConfig
|
||||
from dify_app import DifyApp
|
||||
from extensions import ext_celery
|
||||
from schedule.collect_agent_sandbox_usage import collect_agent_sandbox_usage
|
||||
from tests.unit_tests.config_override import config_overrides_context
|
||||
|
||||
PROJECT = "431de237-596f-4d59-8a85-20a9846bf243"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def restore_celery_app_state() -> Iterator[None]:
|
||||
# init_app changes Celery's process-wide current/default app; close() alone
|
||||
# leaves later shared tasks pointing at the now-unregistered test app.
|
||||
with setup_default_app(collect_agent_sandbox_usage.app):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def configured_collection() -> Iterator[None]:
|
||||
with config_overrides_context(
|
||||
AGENT_SANDBOX_METERING_ENABLED=True,
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID=PROJECT,
|
||||
AGENT_BACKEND_BASE_URL="http://agent_backend:5001/",
|
||||
AGENT_BACKEND_API_TOKEN="backend-token",
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def response(payload: object, status: int = 200) -> httpx.Response:
|
||||
return httpx.Response(
|
||||
status, json=payload, request=httpx.Request("POST", "http://agent_backend:5001/internal/e2b/usage/collect")
|
||||
)
|
||||
|
||||
|
||||
def test_task_uses_existing_queue_deadlines_and_single_authenticated_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
post = MagicMock(return_value=response({"completed": True}))
|
||||
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
|
||||
assert collect_agent_sandbox_usage() is True
|
||||
post.assert_called_once_with(
|
||||
"http://agent_backend:5001/internal/e2b/usage/collect",
|
||||
headers={"Authorization": "Bearer backend-token"},
|
||||
json={"project_id": PROJECT},
|
||||
max_retries=0,
|
||||
timeout=260,
|
||||
)
|
||||
dispatch = MagicMock()
|
||||
monkeypatch.setattr(collect_agent_sandbox_usage.app, "send_task", dispatch)
|
||||
collect_agent_sandbox_usage.apply_async()
|
||||
assert dispatch.call_args.kwargs["queue"] == "ops_trace"
|
||||
assert collect_agent_sandbox_usage.soft_time_limit == 270
|
||||
assert collect_agent_sandbox_usage.time_limit == 300
|
||||
|
||||
|
||||
def test_disabled_job_makes_no_http_request(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
post = MagicMock()
|
||||
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_ENABLED=False):
|
||||
assert collect_agent_sandbox_usage() is False
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"missing", ["AGENT_BACKEND_BASE_URL", "AGENT_BACKEND_API_TOKEN", "AGENT_SANDBOX_METERING_PROJECT_ID"]
|
||||
)
|
||||
def test_incomplete_configuration_only_fails_collection_job(missing: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
post = MagicMock()
|
||||
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
|
||||
with config_overrides_context(**{missing: ""}):
|
||||
with pytest.raises(ValueError, match="requires backend URL, token, and project"):
|
||||
collect_agent_sandbox_usage()
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [{"completed": False}, {"completed": "true"}, {"unexpected": True}])
|
||||
def test_incomplete_or_invalid_scan_is_a_job_failure_without_retry(
|
||||
payload: dict[str, object], monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
post = MagicMock(return_value=response(payload))
|
||||
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
|
||||
with pytest.raises((ValueError, RuntimeError)):
|
||||
collect_agent_sandbox_usage()
|
||||
assert post.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [403, 503, 504])
|
||||
def test_backend_failure_does_not_trigger_http_retry(status: int, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
post = MagicMock(return_value=response({"detail": "unavailable"}, status))
|
||||
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
collect_agent_sandbox_usage()
|
||||
assert post.call_count == 1
|
||||
|
||||
|
||||
def test_transport_failure_is_logged_without_credentials_or_raw_message(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
post = MagicMock(side_effect=httpx.ReadTimeout("do-not-log-secret"))
|
||||
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
|
||||
with pytest.raises(httpx.ReadTimeout):
|
||||
collect_agent_sandbox_usage()
|
||||
assert post.call_count == 1
|
||||
assert "Background sandbox usage collection failed" in caplog.text
|
||||
assert "do-not-log-secret" not in caplog.text
|
||||
assert "backend-token" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
@pytest.mark.parametrize("interval", [90, "90"])
|
||||
def test_beat_registers_only_enabled_collection_with_expiring_schedule(
|
||||
enabled: bool, interval: int | str, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(ext_celery, "setup_workflow_warm_shutdown_handler", lambda: None)
|
||||
with config_overrides_context(
|
||||
AGENT_SANDBOX_METERING_ENABLED=enabled,
|
||||
AGENT_SANDBOX_METERING_INTERVAL_SECONDS=interval,
|
||||
DISABLE_TELEMETRY=True,
|
||||
):
|
||||
celery = ext_celery.init_app(DifyApp(__name__))
|
||||
try:
|
||||
assert "schedule.collect_agent_sandbox_usage" in celery.conf.imports
|
||||
schedule = celery.conf.beat_schedule.get("collect_agent_sandbox_usage")
|
||||
if enabled:
|
||||
assert schedule == {
|
||||
"task": "schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage",
|
||||
"schedule": timedelta(seconds=90),
|
||||
"options": {"expires": 90},
|
||||
}
|
||||
else:
|
||||
assert schedule is None
|
||||
finally:
|
||||
celery.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw_interval", ["0", "-1", "not-a-number", "1.5", "9" * 30])
|
||||
def test_invalid_interval_env_only_disables_optional_schedule(
|
||||
raw_interval: str, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
monkeypatch.setenv("AGENT_SANDBOX_METERING_INTERVAL_SECONDS", raw_interval)
|
||||
config = AgentBackendConfig()
|
||||
assert raw_interval == config.AGENT_SANDBOX_METERING_INTERVAL_SECONDS
|
||||
monkeypatch.setattr(ext_celery, "setup_workflow_warm_shutdown_handler", lambda: None)
|
||||
with config_overrides_context(
|
||||
AGENT_SANDBOX_METERING_INTERVAL_SECONDS=config.AGENT_SANDBOX_METERING_INTERVAL_SECONDS,
|
||||
ENABLE_CONVERSATION_CLEANUP_TASK=True,
|
||||
CONVERSATION_CLEANUP_TASK_INTERVAL=2,
|
||||
DISABLE_TELEMETRY=True,
|
||||
):
|
||||
app = DifyApp(__name__)
|
||||
celery = ext_celery.init_app(app)
|
||||
try:
|
||||
assert app.extensions["celery"] is celery
|
||||
assert "collect_agent_sandbox_usage" not in celery.conf.beat_schedule
|
||||
assert "schedule.collect_agent_sandbox_usage" in celery.conf.imports
|
||||
assert celery.conf.beat_schedule["conversation_cleanup_sweeper"] == {
|
||||
"task": "tasks.delete_conversation_task.sweep_deleted_conversations",
|
||||
"schedule": timedelta(minutes=2),
|
||||
}
|
||||
finally:
|
||||
celery.close()
|
||||
assert "Skipping sandbox usage schedule" in caplog.text
|
||||
assert all(raw_interval not in record.getMessage() for record in caplog.records)
|
||||
|
||||
|
||||
def test_manual_collection_does_not_depend_on_beat_interval(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
post = MagicMock(return_value=response({"completed": True}))
|
||||
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_INTERVAL_SECONDS="invalid"):
|
||||
assert collect_agent_sandbox_usage() is True
|
||||
post.assert_called_once()
|
||||
@@ -0,0 +1,720 @@
|
||||
"""Accounting invariants tested against real SQLAlchemy persistence."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import event as sqlalchemy_event
|
||||
from sqlalchemy.dialects import postgresql
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.sql import ClauseElement
|
||||
|
||||
from models.agent import (
|
||||
Agent,
|
||||
AgentConfigVersionKind,
|
||||
AgentScope,
|
||||
AgentSource,
|
||||
AgentWorkspace,
|
||||
AgentWorkspaceBinding,
|
||||
AgentWorkspaceOwnerType,
|
||||
)
|
||||
from models.agent_sandbox_usage import AgentSandboxExecution, AgentSandboxUsageEvent
|
||||
from models.model import App
|
||||
from services.agent.runtime_usage_service import SandboxUsageError, SandboxUsageEvent, SandboxUsageService
|
||||
from tests.unit_tests.config_override import config_overrides_context
|
||||
|
||||
PROJECT = "431de237-596f-4d59-8a85-20a9846bf243"
|
||||
START = "2026-09-20T00:00:00Z"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def metering_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
|
||||
monkeypatch.setattr("services.agent.runtime_usage_service.naive_utc_now", lambda: datetime(2026, 9, 21))
|
||||
with config_overrides_context(
|
||||
AGENT_SANDBOX_METERING_ENABLED=True,
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID=PROJECT,
|
||||
AGENT_SANDBOX_METERING_START_AT=START,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def provider_event(
|
||||
*,
|
||||
event_id: str = "event-1",
|
||||
execution_id: str = "execution-1",
|
||||
sandbox_id: str = "sandbox-1",
|
||||
duration: int | None = 12345,
|
||||
started_at: str | None = "2026-09-20T00:00:02Z",
|
||||
kind: str = "paused",
|
||||
metadata: dict[str, JsonValue] | None = None,
|
||||
) -> SandboxUsageEvent:
|
||||
return SandboxUsageEvent.model_validate(
|
||||
{
|
||||
"id": event_id,
|
||||
"source": "provider",
|
||||
"type": f"sandbox.lifecycle.{kind}",
|
||||
"sandbox_id": sandbox_id,
|
||||
"execution_id": execution_id,
|
||||
"payload": {
|
||||
"id": event_id,
|
||||
"version": "v2",
|
||||
"type": f"sandbox.lifecycle.{kind}",
|
||||
"timestamp": "2026-09-20T00:00:14.551745815Z",
|
||||
"sandboxId": sandbox_id,
|
||||
"sandboxExecutionId": execution_id,
|
||||
"sandboxTeamId": PROJECT,
|
||||
"sandboxTemplateId": "template-1",
|
||||
"sandboxBuildId": "build-1",
|
||||
"eventData": {
|
||||
"execution": {
|
||||
"started_at": started_at,
|
||||
"execution_time": duration,
|
||||
"memory_mb": 1024,
|
||||
"vcpu_count": 2,
|
||||
},
|
||||
"sandbox_metadata": metadata or {},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def ingest(*events: SandboxUsageEvent) -> dict[str, int]:
|
||||
return SandboxUsageService.ingest(project_id=PROJECT, events=list(events))
|
||||
|
||||
|
||||
def execution(factory: sessionmaker[Session]) -> AgentSandboxExecution:
|
||||
with factory() as session:
|
||||
return session.scalars(sa.select(AgentSandboxExecution)).one()
|
||||
|
||||
|
||||
def test_duplicate_terminal_and_api_webhook_aliases_count_once(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
event = provider_event()
|
||||
assert ingest(event)["accepted"] == 1
|
||||
alias = deepcopy(event.payload)
|
||||
for camel, snake in (
|
||||
("sandboxId", "sandbox_id"),
|
||||
("sandboxExecutionId", "sandbox_execution_id"),
|
||||
("sandboxTeamId", "sandbox_team_id"),
|
||||
("sandboxTemplateId", "sandbox_template_id"),
|
||||
("sandboxBuildId", "sandbox_build_id"),
|
||||
("eventData", "event_data"),
|
||||
):
|
||||
alias[snake] = alias.pop(camel)
|
||||
assert ingest(event.model_copy(update={"payload": alias}))["duplicates"] == 1
|
||||
assert ingest(provider_event(event_id="killed-event", kind="killed"))["accepted"] == 1
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.metered_duration_ms == 12345
|
||||
assert row.quality == "metered"
|
||||
assert row.started_at == datetime(2026, 9, 20, 0, 0, 2)
|
||||
assert row.terminal_event_at == datetime(2026, 9, 20, 0, 0, 14, 551745)
|
||||
|
||||
|
||||
def test_same_sandbox_resume_is_a_new_execution(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
ingest(provider_event(), provider_event(event_id="second", execution_id="execution-2", duration=6789))
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.sum(AgentSandboxExecution.metered_duration_ms))) == 19134
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 2
|
||||
|
||||
|
||||
def test_state_reports_pending_and_conflict_counts_without_owner_ids() -> None:
|
||||
created = provider_event(event_id="created", kind="created", duration=None, started_at=None)
|
||||
created.payload["eventData"] = dict[str, JsonValue]()
|
||||
ingest(created)
|
||||
assert SandboxUsageService.get_state(project_id=PROJECT)["diagnostics"] == {
|
||||
"unresolved_events": 0,
|
||||
"conflict_events": 0,
|
||||
"open_executions": 1,
|
||||
"unattributed_executions": 1,
|
||||
}
|
||||
ingest(provider_event())
|
||||
ingest(provider_event(duration=1))
|
||||
diagnostics = SandboxUsageService.get_state(project_id=PROJECT)["diagnostics"]
|
||||
assert diagnostics == {
|
||||
"unresolved_events": 0,
|
||||
"conflict_events": 1,
|
||||
"open_executions": 0,
|
||||
"unattributed_executions": 1,
|
||||
}
|
||||
|
||||
|
||||
def test_unknown_duration_stays_null_and_enrichment_is_replayable(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
missing = provider_event(duration=None)
|
||||
ingest(missing)
|
||||
assert ingest(missing)["duplicates"] == 1
|
||||
assert execution(sqlite_session_factory).metered_duration_ms is None
|
||||
assert execution(sqlite_session_factory).quality == "pending"
|
||||
assert ingest(provider_event())["accepted"] == 1
|
||||
assert execution(sqlite_session_factory).metered_duration_ms == 12345
|
||||
assert ingest(missing)["duplicates"] == 1
|
||||
with sqlite_session_factory() as session:
|
||||
event = session.scalars(
|
||||
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == "event-1")
|
||||
).one()
|
||||
assert event.payload["eventData"]["execution"]["execution_time"] is None
|
||||
assert event.payload_versions
|
||||
assert event.resolution["canonical"]["duration_ms"] == 12345
|
||||
|
||||
|
||||
def test_conflicting_event_keeps_both_payloads_and_quarantines_usage(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
ingest(provider_event())
|
||||
assert ingest(provider_event(duration=999))["conflicts"] == 1
|
||||
assert execution(sqlite_session_factory).quality == "conflict"
|
||||
assert execution(sqlite_session_factory).metered_duration_ms == 12345
|
||||
with sqlite_session_factory() as session:
|
||||
event = session.scalars(
|
||||
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == "event-1")
|
||||
).one()
|
||||
assert event.payload_versions[0]["payload"]["eventData"]["execution"]["execution_time"] == 999
|
||||
ingest(provider_event(event_id="new-terminal"))
|
||||
assert execution(sqlite_session_factory).quality == "conflict"
|
||||
|
||||
|
||||
def test_conflicting_executions_never_add_or_overwrite_duration(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
ingest(provider_event())
|
||||
assert ingest(provider_event(event_id="other-terminal", duration=1))["conflicts"] == 1
|
||||
assert execution(sqlite_session_factory).metered_duration_ms == 12345
|
||||
|
||||
|
||||
def test_late_start_does_not_reopen_closed_execution(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
ingest(provider_event())
|
||||
ingest(provider_event(event_id="start", kind="resumed", duration=None))
|
||||
row = execution(sqlite_session_factory)
|
||||
assert (row.state, row.quality, row.metered_duration_ms) == ("closed", "metered", 12345)
|
||||
|
||||
|
||||
def test_activation_survives_restarts_and_config_changes_are_rejected() -> None:
|
||||
state = SandboxUsageService.get_state(project_id=PROJECT)
|
||||
assert state["started_at"] == START
|
||||
assert SandboxUsageService.get_state(project_id=PROJECT) == state
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-21T00:00:00Z"):
|
||||
with pytest.raises(SandboxUsageError, match="immutable"):
|
||||
SandboxUsageService.get_state(project_id=PROJECT)
|
||||
|
||||
|
||||
def test_no_old_data_even_when_terminal_arrives_after_activation(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
assert ingest(provider_event(started_at="2026-09-19T23:59:59Z"))["ignored"] == 1
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(AgentSandboxUsageEvent)
|
||||
.where(AgentSandboxUsageEvent.source == "provider")
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_t0_filter_cannot_hide_contradictory_existing_metered_event(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
ingest(provider_event())
|
||||
assert ingest(provider_event(started_at="2026-09-19T23:59:59Z"))["conflicts"] == 1
|
||||
assert execution(sqlite_session_factory).quality == "conflict"
|
||||
|
||||
|
||||
def test_unknown_start_proven_before_t0_removes_pending_execution(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
unknown = provider_event(started_at=None, duration=None)
|
||||
ingest(unknown)
|
||||
assert ingest(provider_event(started_at="2026-09-19T23:59:59Z"))["ignored"] == 1
|
||||
ingest(provider_event(event_id="late-start", kind="created", started_at=None, duration=None))
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
|
||||
|
||||
|
||||
def test_unusable_provider_data_is_visible_but_not_metered(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
event = provider_event(started_at=None)
|
||||
assert ingest(event)["accepted"] == 1
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalars(sa.select(AgentSandboxExecution)).one().quality == "pending"
|
||||
row = session.scalars(
|
||||
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source == "provider")
|
||||
).one()
|
||||
assert row.projection_status == "unresolved"
|
||||
|
||||
|
||||
def test_created_without_execution_data_then_terminal_and_late_start(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
created = provider_event(event_id="created", kind="created", duration=None, started_at=None)
|
||||
created.payload["eventData"] = dict[str, JsonValue]()
|
||||
assert ingest(created)["accepted"] == 1
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.started_at is None
|
||||
assert row.state == "open"
|
||||
assert row.metered_duration_ms is None
|
||||
ingest(provider_event())
|
||||
assert execution(sqlite_session_factory).quality == "metered"
|
||||
assert ingest(created)["duplicates"] == 1
|
||||
late = created.model_copy(deep=True, update={"id": "late"})
|
||||
late.payload["id"] = "late"
|
||||
ingest(late)
|
||||
row = execution(sqlite_session_factory)
|
||||
assert (row.state, row.quality, row.metered_duration_ms) == ("closed", "metered", 12345)
|
||||
|
||||
|
||||
def test_missing_provider_project_is_rejected() -> None:
|
||||
event = provider_event()
|
||||
event.payload.pop("sandboxTeamId")
|
||||
with pytest.raises(SandboxUsageError, match="provider_project_mismatch"):
|
||||
ingest(event)
|
||||
|
||||
|
||||
def test_retention_gap_is_saved_without_advancing_checkpoint(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-01T00:00:00Z"):
|
||||
event = SandboxUsageEvent(
|
||||
id="gap",
|
||||
source="application",
|
||||
type="collector_retention_gap",
|
||||
timestamp=START,
|
||||
payload={
|
||||
"uncovered_start": "2026-09-01T00:00:00Z",
|
||||
"uncovered_end": "2026-09-13T00:00:00Z",
|
||||
"assumed_retention_seconds": 604800,
|
||||
},
|
||||
)
|
||||
assert ingest(event)["accepted"] == 1
|
||||
assert SandboxUsageService.get_state(project_id=PROJECT)["checkpoint_at"] is None
|
||||
with sqlite_session_factory() as session:
|
||||
row = session.scalars(
|
||||
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == "gap")
|
||||
).one()
|
||||
assert row.projection_status == "unresolved"
|
||||
assert row.projection_error_code == "provider_retention_gap"
|
||||
|
||||
|
||||
def test_checkpoint_only_advances_after_completed_scan() -> None:
|
||||
event = SandboxUsageEvent(
|
||||
id="checkpoint-1",
|
||||
source="application",
|
||||
type="collector_checkpoint",
|
||||
timestamp=START,
|
||||
payload={
|
||||
"scan_started_at": START,
|
||||
"window_start": START,
|
||||
"window_end": START,
|
||||
"completed": True,
|
||||
"mode": "full",
|
||||
"pages": 1,
|
||||
"events": 0,
|
||||
},
|
||||
)
|
||||
ingest(event)
|
||||
state = SandboxUsageService.get_state(project_id=PROJECT)
|
||||
assert state["checkpoint_at"] == START
|
||||
assert state["full_scan_at"] == START
|
||||
with pytest.raises(SandboxUsageError, match="invalid_collector_checkpoint"):
|
||||
ingest(event.model_copy(update={"id": "partial", "payload": {**event.payload, "completed": False}}))
|
||||
|
||||
|
||||
def test_forbidden_project_and_reserved_application_events_fail_without_partial_batch(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with pytest.raises(SandboxUsageError, match="not_allowed"):
|
||||
SandboxUsageService.ingest(project_id="other-project", events=[provider_event()])
|
||||
reserved = SandboxUsageEvent(id="forged", source="application", type="allocation_registered", payload={})
|
||||
with pytest.raises(SandboxUsageError, match="unsupported_application_event"):
|
||||
ingest(provider_event(), reserved)
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
|
||||
|
||||
|
||||
def test_provider_team_must_match_configured_project() -> None:
|
||||
event = provider_event()
|
||||
event.payload["sandboxTeamId"] = "other-project"
|
||||
with pytest.raises(SandboxUsageError, match="provider_project_mismatch"):
|
||||
ingest(event)
|
||||
|
||||
|
||||
def test_disabled_ingestion_does_not_ack(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_ENABLED=False):
|
||||
assert SandboxUsageService.get_state(project_id=PROJECT)["enabled"] is False
|
||||
with pytest.raises(SandboxUsageError, match="disabled"):
|
||||
ingest(provider_event())
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxUsageEvent)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("timestamp", "error"),
|
||||
[
|
||||
(123, "invalid_timestamp"),
|
||||
("not-a-time", "invalid_timestamp"),
|
||||
("2026-09-20T00:00:14", "timestamp_requires_timezone"),
|
||||
],
|
||||
)
|
||||
def test_invalid_provider_time_rolls_back_entire_batch(
|
||||
timestamp: JsonValue, error: str, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
bad = provider_event(event_id="bad-time", execution_id="bad-execution")
|
||||
bad.payload["timestamp"] = timestamp
|
||||
with pytest.raises(SandboxUsageError, match=error):
|
||||
ingest(provider_event(), bad)
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("quantity", [-1, True, 1.5, 2**63])
|
||||
def test_invalid_duration_is_rejected_before_any_accounting(quantity: JsonValue) -> None:
|
||||
event = provider_event()
|
||||
data = event.payload["eventData"]
|
||||
assert isinstance(data, dict)
|
||||
details = data["execution"]
|
||||
assert isinstance(details, dict)
|
||||
details["execution_time"] = quantity
|
||||
with pytest.raises(SandboxUsageError, match="invalid_provider_quantity"):
|
||||
ingest(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["id", "sandboxId", "sandboxExecutionId"])
|
||||
def test_raw_identity_cannot_disagree_with_delivery_envelope(field: str) -> None:
|
||||
event = provider_event()
|
||||
event.payload[field] = "different-id"
|
||||
with pytest.raises(SandboxUsageError, match="provider_envelope_mismatch"):
|
||||
ingest(event)
|
||||
|
||||
|
||||
def test_invalid_provider_identifier_without_envelope_hint_is_rejected() -> None:
|
||||
event = provider_event().model_copy(update={"sandbox_id": None})
|
||||
event.payload["sandboxId"] = ""
|
||||
with pytest.raises(SandboxUsageError, match="invalid_provider_identifier"):
|
||||
ingest(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured_start", "error"),
|
||||
[
|
||||
("invalid", "sandbox_metering_start_not_configured"),
|
||||
("2026-09-20T00:00:00.001Z", "sandbox_metering_start_requires_whole_second"),
|
||||
],
|
||||
)
|
||||
def test_invalid_activation_configuration_cannot_create_ledger_scope(configured_start: str, error: str) -> None:
|
||||
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT=configured_start):
|
||||
with pytest.raises(SandboxUsageError, match=error) as result:
|
||||
SandboxUsageService.get_state(project_id=PROJECT)
|
||||
assert result.value.status_code == 503
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("key", "value"),
|
||||
[
|
||||
("dify.usage_allocation_id", 42),
|
||||
("dify.usage_allocation_id", "invalid-uuid"),
|
||||
("dify.binding_id", 42),
|
||||
("dify.binding_id", "invalid-uuid"),
|
||||
],
|
||||
)
|
||||
def test_invalid_owner_metadata_does_not_discard_metered_usage(
|
||||
key: str, value: JsonValue, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
assert ingest(provider_event(metadata={key: value}))["accepted"] == 1
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.quality == "metered"
|
||||
assert row.metered_duration_ms == 12345
|
||||
assert row.tenant_id is None
|
||||
assert row.attribution_status == "unresolved"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("change", ["sandbox", "started_at", "resources"])
|
||||
def test_execution_identity_and_resource_conflicts_preserve_original_usage(
|
||||
change: str, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
ingest(provider_event())
|
||||
different = provider_event(event_id="conflicting-terminal")
|
||||
if change == "sandbox":
|
||||
different = provider_event(event_id="conflicting-terminal", sandbox_id="other-sandbox")
|
||||
elif change == "started_at":
|
||||
different = provider_event(event_id="conflicting-terminal", started_at="2026-09-20T00:00:03Z")
|
||||
else:
|
||||
data = different.payload["eventData"]
|
||||
assert isinstance(data, dict)
|
||||
details = data["execution"]
|
||||
assert isinstance(details, dict)
|
||||
details["vcpu_count"] = 4
|
||||
assert ingest(different)["conflicts"] == 1
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.quality == "conflict"
|
||||
assert row.sandbox_id == "sandbox-1"
|
||||
assert row.vcpu_count == 2
|
||||
assert row.metered_duration_ms == 12345
|
||||
|
||||
|
||||
def test_unsupported_provider_contract_is_visible_without_fabricated_execution(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
event = provider_event()
|
||||
event.payload["version"] = "unsupported"
|
||||
assert ingest(event)["accepted"] == 1
|
||||
with sqlite_session_factory() as session:
|
||||
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
|
||||
row = session.scalars(
|
||||
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == event.id)
|
||||
).one()
|
||||
assert row.projection_status == "unresolved"
|
||||
assert row.projection_error_code == "incomplete_provider_execution"
|
||||
|
||||
|
||||
def business_owner(
|
||||
session: Session,
|
||||
*,
|
||||
binding_id: str | None = None,
|
||||
tenant_id: str | None = None,
|
||||
sandbox_id: str = "sandbox-1",
|
||||
) -> AgentWorkspaceBinding:
|
||||
tenant_id = tenant_id or str(uuid4())
|
||||
app_id, agent_id, workspace_id = (str(uuid4()) for _ in range(3))
|
||||
session.add_all(
|
||||
[
|
||||
App(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Metering test",
|
||||
description="",
|
||||
mode="agent",
|
||||
enable_site=False,
|
||||
enable_api=False,
|
||||
max_active_requests=None,
|
||||
),
|
||||
Agent(
|
||||
id=agent_id, tenant_id=tenant_id, name=str(uuid4()), scope=AgentScope.ROSTER, source=AgentSource.ROSTER
|
||||
),
|
||||
AgentWorkspace(
|
||||
id=workspace_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||
owner_id=str(uuid4()),
|
||||
owner_scope_key="root",
|
||||
backend_workspace_ref=sandbox_id,
|
||||
),
|
||||
]
|
||||
)
|
||||
binding = AgentWorkspaceBinding(
|
||||
id=binding_id or str(uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
agent_id=agent_id,
|
||||
workspace_id=workspace_id,
|
||||
agent_config_version_id=str(uuid4()),
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
backend_binding_ref=sandbox_id,
|
||||
)
|
||||
session.add(binding)
|
||||
session.commit()
|
||||
return binding
|
||||
|
||||
|
||||
def owner_metadata(binding: AgentWorkspaceBinding) -> dict[str, JsonValue]:
|
||||
return {
|
||||
"dify.binding_id": binding.id,
|
||||
"dify.tenant_id": binding.tenant_id,
|
||||
"dify.agent_id": binding.agent_id,
|
||||
"dify.workspace_id": binding.workspace_id,
|
||||
}
|
||||
|
||||
|
||||
def test_provider_usage_without_business_rows_remains_metered(sqlite_session_factory: sessionmaker[Session]) -> None:
|
||||
ingest(provider_event(metadata={"dify.binding_id": str(uuid4()), "dify.tenant_id": str(uuid4())}))
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.quality == "metered"
|
||||
assert row.metered_duration_ms == 12345
|
||||
assert row.attribution_status == "unresolved"
|
||||
assert row.tenant_id is None
|
||||
assert row.allocation_id is None
|
||||
|
||||
|
||||
def test_background_attribution_uses_verified_business_chain(
|
||||
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
binding = business_owner(sqlite_session)
|
||||
ingest(provider_event(metadata=owner_metadata(binding)))
|
||||
row = execution(sqlite_session_factory)
|
||||
assert (row.tenant_id, row.app_id, row.agent_id, row.binding_id, row.workspace_id) == (
|
||||
binding.tenant_id,
|
||||
binding.app_id,
|
||||
binding.agent_id,
|
||||
binding.id,
|
||||
binding.workspace_id,
|
||||
)
|
||||
assert row.attribution_status == "resolved"
|
||||
assert row.allocation_id is None
|
||||
with sqlite_session_factory() as session:
|
||||
assert (
|
||||
session.scalar(
|
||||
sa.select(sa.func.count())
|
||||
.select_from(AgentSandboxUsageEvent)
|
||||
.where(AgentSandboxUsageEvent.event_type == "allocation_registered")
|
||||
)
|
||||
== 0
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_provider_replay_can_resolve_business_rows_committed_later(
|
||||
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
binding_id, tenant_id = str(uuid4()), str(uuid4())
|
||||
event = provider_event(metadata={"dify.binding_id": binding_id, "dify.tenant_id": tenant_id})
|
||||
ingest(event)
|
||||
assert execution(sqlite_session_factory).attribution_status == "unresolved"
|
||||
binding = business_owner(sqlite_session, binding_id=binding_id, tenant_id=tenant_id)
|
||||
assert ingest(event)["duplicates"] == 1
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.binding_id == binding.id
|
||||
assert row.attribution_status == "resolved"
|
||||
assert row.metered_duration_ms == 12345
|
||||
|
||||
|
||||
@pytest.mark.parametrize("missing", ["binding", "workspace", "app", "agent"])
|
||||
def test_deleted_business_rows_do_not_erase_retained_owner_or_usage(
|
||||
missing: str, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
binding = business_owner(sqlite_session)
|
||||
event = provider_event(metadata=owner_metadata(binding))
|
||||
ingest(event)
|
||||
if missing == "binding":
|
||||
sqlite_session.delete(binding)
|
||||
elif missing == "workspace":
|
||||
sqlite_session.execute(sa.delete(AgentWorkspace).where(AgentWorkspace.id == binding.workspace_id))
|
||||
elif missing == "app":
|
||||
sqlite_session.execute(sa.delete(App).where(App.id == binding.app_id))
|
||||
else:
|
||||
sqlite_session.execute(sa.delete(Agent).where(Agent.id == binding.agent_id))
|
||||
sqlite_session.commit()
|
||||
assert ingest(event)["duplicates"] == 1
|
||||
row = execution(sqlite_session_factory)
|
||||
assert (row.binding_id, row.tenant_id) == (binding.id, binding.tenant_id)
|
||||
assert row.attribution_status == "resolved"
|
||||
assert row.metered_duration_ms == 12345
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"broken_link",
|
||||
[
|
||||
"tenant_metadata",
|
||||
"agent_metadata",
|
||||
"workspace_metadata",
|
||||
"binding_sandbox",
|
||||
"workspace_sandbox",
|
||||
"app_tenant",
|
||||
"agent_tenant",
|
||||
],
|
||||
)
|
||||
def test_unverified_business_chain_never_guesses_an_owner(
|
||||
broken_link: str, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
binding = business_owner(sqlite_session)
|
||||
metadata = owner_metadata(binding)
|
||||
if broken_link == "tenant_metadata":
|
||||
metadata["dify.tenant_id"] = str(uuid4())
|
||||
elif broken_link in {"agent_metadata", "workspace_metadata"}:
|
||||
metadata[f"dify.{broken_link.removesuffix('_metadata')}_id"] = str(uuid4())
|
||||
elif broken_link == "binding_sandbox":
|
||||
binding.backend_binding_ref = "different-sandbox"
|
||||
elif broken_link == "workspace_sandbox":
|
||||
sqlite_session.execute(
|
||||
sa.update(AgentWorkspace)
|
||||
.where(AgentWorkspace.id == binding.workspace_id)
|
||||
.values(backend_workspace_ref="different-sandbox")
|
||||
)
|
||||
elif broken_link == "app_tenant":
|
||||
sqlite_session.execute(sa.update(App).where(App.id == binding.app_id).values(tenant_id=str(uuid4())))
|
||||
else:
|
||||
sqlite_session.execute(sa.update(Agent).where(Agent.id == binding.agent_id).values(tenant_id=str(uuid4())))
|
||||
sqlite_session.commit()
|
||||
ingest(provider_event(metadata=metadata))
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.quality == "metered"
|
||||
assert row.tenant_id is None
|
||||
assert row.attribution_status == "unresolved"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_value", [None, 42, "invalid-uuid"])
|
||||
def test_bad_attribution_labels_do_not_invalidate_measured_runtime(
|
||||
bad_value: JsonValue, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
binding = business_owner(sqlite_session)
|
||||
ingest(provider_event(metadata=owner_metadata(binding)))
|
||||
changed = owner_metadata(binding)
|
||||
changed["dify.tenant_id"] = bad_value
|
||||
assert ingest(provider_event(metadata=changed))["accepted"] == 1
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.quality == "metered"
|
||||
assert row.attribution_status == "resolved"
|
||||
assert row.tenant_id == binding.tenant_id
|
||||
assert row.metered_duration_ms == 12345
|
||||
|
||||
|
||||
def test_owner_conflict_does_not_reassign_history_or_discard_project_usage(
|
||||
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
first, second = business_owner(sqlite_session), business_owner(sqlite_session)
|
||||
ingest(provider_event(metadata=owner_metadata(first)))
|
||||
ingest(provider_event(event_id="other-owner", metadata=owner_metadata(second)))
|
||||
row = execution(sqlite_session_factory)
|
||||
assert row.attribution_status == "conflict"
|
||||
assert row.binding_id == first.id
|
||||
assert row.tenant_id == first.tenant_id
|
||||
assert row.quality == "metered"
|
||||
assert row.metered_duration_ms == 12345
|
||||
|
||||
|
||||
def test_same_sandbox_cannot_change_owner_across_executions(
|
||||
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
first, second = business_owner(sqlite_session), business_owner(sqlite_session)
|
||||
ingest(provider_event(metadata=owner_metadata(first)))
|
||||
resumed = provider_event(event_id="new-execution", execution_id="execution-2", metadata=owner_metadata(second))
|
||||
ingest(resumed)
|
||||
# A subsequent replay with the original owner cannot erase the conflicting
|
||||
# association. Both physical executions remain counted at project level.
|
||||
ingest(provider_event(event_id="later-event", execution_id="execution-2", metadata=owner_metadata(first)))
|
||||
with sqlite_session_factory() as session:
|
||||
rows = list(
|
||||
session.scalars(sa.select(AgentSandboxExecution).order_by(AgentSandboxExecution.provider_execution_id))
|
||||
)
|
||||
assert [(row.attribution_status, row.binding_id) for row in rows] == [
|
||||
("resolved", first.id),
|
||||
("conflict", None),
|
||||
]
|
||||
assert [row.quality for row in rows] == ["metered", "metered"]
|
||||
assert sum(row.metered_duration_ms or 0 for row in rows) == 24690
|
||||
|
||||
|
||||
@pytest.mark.parametrize("event_type", ["operation_requested", "operation_observed", "allocation_registered"])
|
||||
def test_application_operations_are_no_longer_accepted(event_type: str) -> None:
|
||||
event = SandboxUsageEvent(id="legacy-operation", source="application", type=event_type, payload={})
|
||||
with pytest.raises(SandboxUsageError, match="unsupported_application_event"):
|
||||
ingest(event)
|
||||
|
||||
|
||||
def test_steady_state_lookup_only_selects_without_locking_activation(sqlite_session: Session) -> None:
|
||||
engine = sqlite_session.get_bind()
|
||||
assert isinstance(engine, Engine)
|
||||
initial = SandboxUsageService.get_state(project_id=PROJECT)
|
||||
statements: list[str] = []
|
||||
|
||||
def observe(_connection: Connection, statement: ClauseElement, *_args: object) -> None:
|
||||
statements.append(str(statement.compile(dialect=postgresql.dialect())))
|
||||
|
||||
sqlalchemy_event.listen(engine, "before_execute", observe)
|
||||
try:
|
||||
assert SandboxUsageService.get_state(project_id=PROJECT) == initial
|
||||
finally:
|
||||
sqlalchemy_event.remove(engine, "before_execute", observe)
|
||||
assert statements
|
||||
assert all(statement.lstrip().startswith("SELECT") for statement in statements)
|
||||
assert all("FOR UPDATE" not in statement for statement in statements)
|
||||
@@ -637,3 +637,67 @@ def test_workspace_collection_final_delete_failure_propagates(
|
||||
|
||||
assert exc_info.value is error
|
||||
client.destroy_execution_binding_sync.assert_called_once()
|
||||
|
||||
|
||||
def test_binding_creation_ignores_unavailable_metering_configuration(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
AGENT_SANDBOX_METERING_ENABLED=True,
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID="",
|
||||
AGENT_SANDBOX_METERING_START_AT="invalid",
|
||||
)
|
||||
client = _backend_client()
|
||||
monkeypatch.setattr(AgentWorkspaceService, "_client", lambda: nullcontext(client))
|
||||
binding = AgentWorkspaceService.create_binding(
|
||||
session=sqlite_session,
|
||||
scope=_scope(),
|
||||
agent_id="agent-1",
|
||||
base_home_snapshot_id=None,
|
||||
agent_config_version_id="config-1",
|
||||
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||
)
|
||||
sqlite_session.commit()
|
||||
assert binding.backend_binding_ref == "binding-ref"
|
||||
client.create_execution_binding_sync.assert_called_once()
|
||||
|
||||
|
||||
def test_binding_lookups_ignore_bad_metering_configuration_and_remain_tenant_scoped(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
AGENT_SANDBOX_METERING_ENABLED=True,
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID="",
|
||||
AGENT_SANDBOX_METERING_START_AT="invalid",
|
||||
)
|
||||
workspace, binding = _workspace(), _binding()
|
||||
sqlite_session.add_all([workspace, binding])
|
||||
sqlite_session.commit()
|
||||
assert (
|
||||
AgentWorkspaceService.get_active_binding(
|
||||
session=sqlite_session,
|
||||
tenant_id=binding.tenant_id,
|
||||
binding_id=binding.id,
|
||||
expected_owner_scope=_scope(),
|
||||
)
|
||||
is binding
|
||||
)
|
||||
assert (
|
||||
AgentWorkspaceService.resolve_active_binding_for_scope(
|
||||
session=sqlite_session,
|
||||
scope=_scope(),
|
||||
agent_id=binding.agent_id,
|
||||
)
|
||||
is binding
|
||||
)
|
||||
assert (
|
||||
AgentWorkspaceService.get_active_binding(
|
||||
session=sqlite_session,
|
||||
tenant_id="another-tenant",
|
||||
binding_id=binding.id,
|
||||
expected_owner_scope=_scope(),
|
||||
)
|
||||
is None
|
||||
)
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
This guide describes how to run the MVP Dify Agent API server. The server is
|
||||
implemented in `dify-agent/src/dify_agent/server/app.py` and uses Redis for run
|
||||
records and per-run event streams only.
|
||||
records and per-run event streams. Optional E2B usage collection is triggered by
|
||||
the API's existing Celery Beat schedule through a separate one-shot endpoint.
|
||||
It adds no operation logging, polling loop, leader election or accounting context
|
||||
to business runtime execution.
|
||||
|
||||
## Default local startup
|
||||
|
||||
@@ -32,6 +35,9 @@ also reads `.env` and `dify-agent/.env` when present.
|
||||
OpenShell-specific settings are listed in the
|
||||
[OpenShell configuration reference](openshell.md#configuration).
|
||||
|
||||
Independent E2B execution accounting, activation and rollout checks are described
|
||||
in [E2B runtime metering](runtime-metering.md).
|
||||
|
||||
| Environment variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. |
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# E2B runtime metering
|
||||
|
||||
One ledger row represents one E2B `sandboxExecutionId`, from start/resume to
|
||||
pause/kill. Provider execution time and resource sizes determine compute usage;
|
||||
LLM token usage, message latency and local SDK-call duration do not.
|
||||
|
||||
## Scheduled collection and boundaries
|
||||
|
||||
The Dify API's existing Celery Beat schedule dispatches
|
||||
`schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage` every 60 seconds
|
||||
by default, through the existing `ops_trace` worker queue. The job calls
|
||||
`POST /internal/e2b/usage/collect` on the Agent backend using its existing Bearer
|
||||
service token and a `project_id` body. The E2B key stays in the Agent backend.
|
||||
|
||||
The provider-specific endpoint performs one bounded scan, using request-scoped
|
||||
HTTP clients. It reads E2B lifecycle events and posts them to the Dify API's
|
||||
idempotent usage ingestion endpoint. It returns `{"completed": true}` only after
|
||||
all scanned pages and the checkpoint have been acknowledged. Partial scans return
|
||||
`completed=false`; provider/API errors fail this accounting request only.
|
||||
|
||||
There is no Agent-process polling loop, Redis leader election, log outbox,
|
||||
observation worker, or requested/observed operation reporting. Generic leases,
|
||||
Agent runs, files, snapshots and binding lookups do not participate in metering.
|
||||
Their timing, cancellation handling and SDK retries retain their normal behavior.
|
||||
Metering misconfiguration is checked when collection is invoked, rather than
|
||||
becoming a prerequisite for business runtime startup or binding operations.
|
||||
|
||||
The endpoint has a 240-second deadline. The scheduled HTTP caller has a
|
||||
260-second timeout, and the Celery task configures 270/300-second soft/hard limits.
|
||||
Pool support differs: gevent workers do not implement soft time limits, and
|
||||
blocking work cannot rely on their hard limit. The collection and HTTP deadlines
|
||||
are the primary bounds; verify the deployed worker pool before relying on Celery
|
||||
limits. See the [Celery time-limit documentation](https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits).
|
||||
Scheduled messages expire after one collection interval to avoid stale backlog.
|
||||
There is no automatic HTTP retry; future scheduled scans can redeliver provider
|
||||
IDs safely. Overlapping tasks may duplicate reads, but cannot multiply usage.
|
||||
|
||||
## Data and attribution
|
||||
|
||||
The API owns `agent_sandbox_usage_events` and `agent_sandbox_executions`.
|
||||
Ingestion persists provider facts and projects execution rows atomically; the
|
||||
execution key is `(provider, provider_project_id, provider_execution_id)`.
|
||||
A short project transaction lock protects ingestion. Binding creation/lookups do
|
||||
not register allocations, write accounting rows or acquire that lock.
|
||||
Steady-state usage-state reads do not take a project `FOR UPDATE` lock; activation
|
||||
can be initialized once when first needed.
|
||||
|
||||
E2B's existing sandbox metadata contains binding, tenant, Agent and workspace
|
||||
identifiers. During background ingestion, optional attribution checks those IDs
|
||||
against the existing Binding → Workspace → App/Agent tenant-owned chain and the
|
||||
physical sandbox reference. It never trusts labels alone. Missing, malformed or
|
||||
already-deleted business records leave attribution unresolved while preserving
|
||||
complete project-level provider usage. A previously verified ownership snapshot
|
||||
survives later business deletion. Conflicting ownership is not silently reassigned.
|
||||
|
||||
No accounting callback or transaction is added to the business path. There is no
|
||||
pre-create allocation journal. Older diagnostic/allocation columns and records
|
||||
are not destructively migrated in this revision; new accounting accepts only
|
||||
provider events and collector checkpoint/coverage-gap control records. Table
|
||||
retention is unchanged, with no new automatic cleanup policy.
|
||||
|
||||
## Configuration
|
||||
|
||||
Apply the existing usage-table migration before first enabling metering.
|
||||
Configure Dify API, Beat and the workers consuming `ops_trace` consistently:
|
||||
|
||||
```dotenv
|
||||
AGENT_SANDBOX_METERING_ENABLED=true
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID=<E2B project/team ID>
|
||||
AGENT_SANDBOX_METERING_START_AT=<whole-second UTC instant>
|
||||
AGENT_SANDBOX_METERING_INTERVAL_SECONDS=60
|
||||
```
|
||||
|
||||
The task reuses `AGENT_BACKEND_BASE_URL` and `AGENT_BACKEND_API_TOKEN`; the
|
||||
existing SSRF-safe HTTP policy must permit that internal service. No E2B key is
|
||||
copied into API or worker configuration.
|
||||
|
||||
Configure the Agent backend's one-shot endpoint:
|
||||
|
||||
```dotenv
|
||||
DIFY_AGENT_SANDBOX_METERING_ENABLED=true
|
||||
DIFY_AGENT_E2B_PROJECT_ID=<same E2B project/team ID>
|
||||
DIFY_AGENT_SANDBOX_METERING_OVERLAP_SECONDS=900
|
||||
DIFY_AGENT_SANDBOX_METERING_FULL_SCAN_INTERVAL_SECONDS=3600
|
||||
DIFY_AGENT_SANDBOX_METERING_MAX_PAGES=1000
|
||||
```
|
||||
|
||||
It reuses the existing E2B key, `DIFY_AGENT_API_TOKEN`, inner API URL and inner API
|
||||
key. The new endpoint requires a nonempty Bearer-token configuration even when
|
||||
legacy control-plane routes permit unauthenticated local development.
|
||||
`DIFY_AGENT_SANDBOX_METERING_POLL_INTERVAL_SECONDS` is no longer used: the interval
|
||||
belongs to Celery Beat on the API side. Both metering feature flags default off.
|
||||
|
||||
Activation T0 is persisted and immutable. Only provider executions started at or
|
||||
after T0 are metered; starting before T0 and ending after it does not include an
|
||||
old execution. Resuming an older sandbox after T0 creates a new execution and is
|
||||
included. There is no historical backfill and no environment column; databases
|
||||
separate environments. A project ID also scopes and allowlists ingestion.
|
||||
|
||||
## Failure semantics
|
||||
|
||||
- Only complete provider terminal execution data populates metered duration. A
|
||||
local `finally`, SDK expiry deadline or business success is not a billing clock.
|
||||
- Missing terminal fields remain pending; conflicts remain visible. Late starts
|
||||
cannot reopen closed executions, and duplicate facts do not add duration twice.
|
||||
- Every scan starts provider offset pagination from zero, overlaps prior coverage
|
||||
and periodically rescans the retained window. Failed, cancelled or page-bounded
|
||||
scans never claim a completed checkpoint. SQL idempotency protects redelivery.
|
||||
- The assumed default provider event retention is seven days. Older uncovered
|
||||
intervals are recorded as gaps rather than reconstructed or counted as zero.
|
||||
- A failed scheduled task affects accounting only. No accounting network request
|
||||
is added to create/connect/pause/kill, lease helpers, runner or file operations.
|
||||
|
||||
The API's private ingestion endpoints remain:
|
||||
`GET /inner/api/agent/sandbox-usage/state?project_id=...` and
|
||||
`POST /inner/api/agent/sandbox-usage/events`, authenticated with `X-Inner-Api-Key`.
|
||||
Event batches are limited to 100 records and 1 MiB, acknowledged after commit.
|
||||
They are for the isolated collector, not business-operation logging.
|
||||
|
||||
## Querying usage
|
||||
|
||||
For complete `quality='metered'` executions:
|
||||
|
||||
```text
|
||||
sandbox_hours = sum(metered_duration_ms) / 3600000
|
||||
vcpu_hours = sum(metered_duration_ms * vcpu_count) / 3600000
|
||||
ram_gib_hours = sum(metered_duration_ms * memory_mib) / (3600000 * 1024)
|
||||
```
|
||||
|
||||
Project totals include complete executions with unresolved attribution; report
|
||||
that count separately. Tenant/app reports require verified resolved attribution.
|
||||
For UTC windows `[A, B)`, clip execution intervals using start plus provider
|
||||
duration. Start time can have second precision, so cross-boundary allocation is
|
||||
not claimed to be millisecond-accurate; complete-window totals conserve duration.
|
||||
|
||||
## Rollout checks
|
||||
|
||||
1. Verify Beat's schedule, task registration and an existing worker consuming
|
||||
`ops_trace`, along with image revisions, flags, project and immutable T0.
|
||||
2. Verify the authenticated one-shot endpoint, real scheduled execution and
|
||||
advancing checkpoints. The Agent must not start a metering poller or leader.
|
||||
3. Keep a real business binding until collection resolves its owner, then retire
|
||||
it and confirm previously copied accounting remains. Also verify complete
|
||||
project usage for test sandboxes without business records.
|
||||
4. Exercise model success/failure/cancellation, files/list/read/download/errors,
|
||||
snapshots/restoration and resource cleanup. Isolated bad accounting settings
|
||||
or failing accounting-service stubs must not make business lookups fail.
|
||||
5. Compare physical execution IDs, duration and resources against actual E2B
|
||||
events; replay scans/events and confirm totals do not increase.
|
||||
6. Verify old outbox and collector-leader keys are absent after deployment. Do
|
||||
not alter run-store keys or stop shared services to inject accounting faults.
|
||||
|
||||
Disable the metering flags and restart the relevant processes to stop collection;
|
||||
business runtime behavior remains the same. Keep the ledger and original T0.
|
||||
The independent stdout-only sandbox file-error fix is tracked separately from
|
||||
this accounting integration.
|
||||
@@ -5,7 +5,9 @@ instances for plugin-daemon and Dify API inner calls, route wiring, and a
|
||||
process-local scheduler. Run execution happens in background ``asyncio`` tasks
|
||||
rather than request handlers, so client disconnects do not cancel the agent
|
||||
runtime. Redis persists run records and per-run event streams with configured
|
||||
retention only; it is not used as a job queue. Agenton layers and providers
|
||||
retention. Optional provider accounting is exposed as a separate one-shot HTTP
|
||||
endpoint driven by Celery Beat; it starts no tasks during lifespan startup and
|
||||
adds no reporting to business operations. Redis is not used as a run job queue. Agenton layers and providers
|
||||
stay state-only: they borrow the lifespan-owned clients through the runner and
|
||||
receive runtime-backend and Shell settings through provider construction rather
|
||||
than reading environment variables themselves. The standard server mounts the
|
||||
@@ -29,6 +31,7 @@ from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler
|
||||
from dify_agent.server.auth import create_bearer_token_dependency
|
||||
from dify_agent.server.observability import configure_server_observability
|
||||
from dify_agent.server.routes.e2b_usage import create_e2b_usage_router
|
||||
from dify_agent.server.routes.runs import create_runs_router
|
||||
from dify_agent.server.routes.execution_bindings import create_execution_bindings_router
|
||||
from dify_agent.server.routes.home_snapshots import create_home_snapshots_router
|
||||
@@ -146,6 +149,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
control_plane_router.include_router(create_home_snapshots_router(lambda: home_snapshot_service))
|
||||
control_plane_router.include_router(create_binding_files_router(lambda: binding_file_service))
|
||||
app.include_router(control_plane_router)
|
||||
app.include_router(create_e2b_usage_router(resolved_settings))
|
||||
app.include_router(
|
||||
create_agent_stub_router(
|
||||
token_codec=agent_stub_token_codec,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Provider-specific client for E2B usage facts and completed-scan checkpoints.
|
||||
|
||||
Only the scheduled collector uses this client. Business operations do not report
|
||||
accounting events or borrow this connection pool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_MAX_BATCH_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class E2BUsageState(BaseModel):
|
||||
enabled: bool
|
||||
project_id: str
|
||||
started_at: datetime | None = None
|
||||
checkpoint_at: datetime | None = None
|
||||
full_scan_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
|
||||
class _IngestResult(BaseModel):
|
||||
accepted: int = Field(ge=0)
|
||||
duplicates: int = Field(ge=0)
|
||||
conflicts: int = Field(ge=0)
|
||||
ignored: int = Field(ge=0)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class E2BUsageApiClient:
|
||||
"""Use a collection-scoped inner client; credentials never reach E2B."""
|
||||
|
||||
client: httpx.AsyncClient
|
||||
base_url: str
|
||||
api_key: str
|
||||
project_id: str
|
||||
|
||||
async def get_state(self) -> E2BUsageState:
|
||||
response = await self.client.get(
|
||||
f"{self.base_url.rstrip('/')}/inner/api/agent/sandbox-usage/state",
|
||||
params={"project_id": self.project_id},
|
||||
headers={"X-Inner-Api-Key": self.api_key},
|
||||
timeout=15.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
state = E2BUsageState.model_validate(response.json())
|
||||
if state.project_id != self.project_id:
|
||||
raise ValueError("sandbox usage state project mismatch")
|
||||
for value in (state.started_at, state.checkpoint_at, state.full_scan_at):
|
||||
if value is not None and value.utcoffset() is None:
|
||||
raise ValueError("sandbox usage state requires timezone-aware timestamps")
|
||||
if state.enabled and state.started_at is None:
|
||||
raise ValueError("enabled sandbox usage state requires a fixed activation time")
|
||||
return state
|
||||
|
||||
async def post_events(self, events: list[dict[str, Any]]) -> None:
|
||||
if not events or len(events) > 100:
|
||||
raise ValueError("sandbox usage batches require 1 to 100 events")
|
||||
# The API limits the whole HTTP body, not just its event count. Splitting
|
||||
# here covers provider pages. Repeated scheduled scans use stable
|
||||
# provider IDs, so partial success cannot multiply execution usage.
|
||||
batch: list[dict[str, Any]] = []
|
||||
for event in events:
|
||||
candidate = [*batch, event]
|
||||
body = self._encode_batch(candidate)
|
||||
if len(body) > _MAX_BATCH_BYTES:
|
||||
if not batch:
|
||||
raise ValueError("sandbox usage event exceeds API request size")
|
||||
await self._post_batch(self._encode_batch(batch), len(batch))
|
||||
batch = [event]
|
||||
if len(self._encode_batch(batch)) > _MAX_BATCH_BYTES:
|
||||
raise ValueError("sandbox usage event exceeds API request size")
|
||||
else:
|
||||
batch = candidate
|
||||
if batch:
|
||||
await self._post_batch(self._encode_batch(batch), len(batch))
|
||||
|
||||
def _encode_batch(self, events: list[dict[str, Any]]) -> bytes:
|
||||
return json.dumps(
|
||||
{"project_id": self.project_id, "events": events},
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
|
||||
async def _post_batch(self, body: bytes, count: int) -> None:
|
||||
response = await self.client.post(
|
||||
f"{self.base_url.rstrip('/')}/inner/api/agent/sandbox-usage/events",
|
||||
headers={"X-Inner-Api-Key": self.api_key, "Content-Type": "application/json"},
|
||||
content=body,
|
||||
timeout=15.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = _IngestResult.model_validate(response.json())
|
||||
if result.accepted + result.duplicates + result.conflicts + result.ignored != count:
|
||||
raise ValueError("sandbox usage acknowledgement does not cover the entire batch")
|
||||
if result.conflicts:
|
||||
logger.error("sandbox usage ingestion reported conflicts", extra={"conflicts": result.conflicts})
|
||||
|
||||
|
||||
def utc_now() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
@@ -0,0 +1,160 @@
|
||||
"""A single bounded E2B event scan, invoked by the existing Celery Beat schedule.
|
||||
|
||||
Offsets are used within one bounded scan only. Every scan starts at zero, with
|
||||
an overlap window and periodic rescan of the provider retention window. Neither
|
||||
redelivery nor overlapping scheduled scans can inflate the API's idempotent
|
||||
execution ledger. A partial scan never advances coverage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
|
||||
from dify_agent.server.e2b_usage_client import E2BUsageApiClient, utc_now
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_PROVIDER_RETENTION = timedelta(days=7)
|
||||
_EVENTS_URL = "https://api.e2b.app/events/sandboxes"
|
||||
|
||||
|
||||
def _field(event: dict[str, Any], camel: str, snake: str) -> Any:
|
||||
return event.get(camel, event.get(snake))
|
||||
|
||||
|
||||
def _timestamp(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("provider event timestamp is invalid")
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
if parsed.utcoffset() is None:
|
||||
raise ValueError("provider event timestamp must have a timezone")
|
||||
return parsed
|
||||
|
||||
|
||||
def provider_event(raw: dict[str, Any], *, project_id: str) -> dict[str, Any]:
|
||||
"""Preserve the raw provider payload; API owns normalization and T0 filtering."""
|
||||
project = _field(raw, "sandboxTeamId", "sandbox_team_id")
|
||||
if project != project_id:
|
||||
raise ValueError("E2B event project mismatch or missing project")
|
||||
event_id = raw.get("id")
|
||||
event_type = raw.get("type")
|
||||
if not isinstance(event_id, str) or not event_id or not isinstance(event_type, str):
|
||||
raise ValueError("E2B event missing stable identity")
|
||||
_timestamp(raw.get("timestamp"))
|
||||
return {
|
||||
"id": event_id,
|
||||
"source": "provider",
|
||||
"type": event_type,
|
||||
"timestamp": raw.get("timestamp"),
|
||||
"sandbox_id": _field(raw, "sandboxId", "sandbox_id"),
|
||||
"execution_id": _field(raw, "sandboxExecutionId", "sandbox_execution_id"),
|
||||
"payload": raw,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class E2BUsageCollector:
|
||||
provider_client: httpx.AsyncClient
|
||||
usage_client: E2BUsageApiClient
|
||||
api_key: str
|
||||
project_id: str
|
||||
overlap_seconds: int = 900
|
||||
full_scan_interval_seconds: int = 3600
|
||||
max_pages: int = 1000
|
||||
clock: Callable[[], datetime] = utc_now
|
||||
|
||||
async def collect_once(self) -> bool:
|
||||
state = await self.usage_client.get_state()
|
||||
if not state.enabled:
|
||||
return False
|
||||
if state.started_at is None or state.project_id != self.project_id:
|
||||
raise ValueError("sandbox usage activation/project state is invalid")
|
||||
now = self.clock()
|
||||
if state.started_at > now:
|
||||
return False
|
||||
retention_floor = now - _PROVIDER_RETENTION
|
||||
full = (
|
||||
state.full_scan_at is None or (now - state.full_scan_at).total_seconds() >= self.full_scan_interval_seconds
|
||||
)
|
||||
anchor = state.started_at if full else (state.checkpoint_at or state.started_at)
|
||||
lower = max(state.started_at, retention_floor, anchor - timedelta(seconds=self.overlap_seconds))
|
||||
if (state.checkpoint_at or state.started_at) < retention_floor:
|
||||
# The API checkpoint also records window_start, so a retention gap is
|
||||
# visible instead of being misrepresented as complete T0 coverage.
|
||||
logger.error("sandbox usage collection gap exceeds provider retention")
|
||||
await self.usage_client.post_events(
|
||||
[
|
||||
{
|
||||
"id": str(uuid4()),
|
||||
"source": "application",
|
||||
"type": "collector_retention_gap",
|
||||
"timestamp": now.isoformat(),
|
||||
"payload": {
|
||||
"uncovered_start": (state.checkpoint_at or state.started_at).isoformat(),
|
||||
"uncovered_end": retention_floor.isoformat(),
|
||||
"assumed_retention_seconds": int(_PROVIDER_RETENTION.total_seconds()),
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
count = 0
|
||||
complete = False
|
||||
pages = 0
|
||||
for page in range(self.max_pages):
|
||||
response = await self.provider_client.get(
|
||||
_EVENTS_URL,
|
||||
headers={"X-API-Key": self.api_key},
|
||||
params={"limit": 100, "offset": page * 100, "orderAsc": "false"},
|
||||
timeout=15.0,
|
||||
)
|
||||
response.raise_for_status()
|
||||
raw_events = response.json()
|
||||
if not isinstance(raw_events, list) or len(raw_events) > 100:
|
||||
raise ValueError("E2B events response must be a page of up to 100 events")
|
||||
pages += 1
|
||||
events: list[dict[str, Any]] = []
|
||||
all_older = bool(raw_events)
|
||||
for raw in raw_events:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("invalid E2B event")
|
||||
event = provider_event(raw, project_id=self.project_id)
|
||||
occurred = _timestamp(raw.get("timestamp"))
|
||||
if occurred is None or occurred >= lower:
|
||||
all_older = False
|
||||
events.append(event)
|
||||
if events:
|
||||
await self.usage_client.post_events(events)
|
||||
count += len(events)
|
||||
if len(raw_events) < 100 or all_older:
|
||||
complete = True
|
||||
break
|
||||
if not complete:
|
||||
logger.warning("sandbox usage scan reached page bound; checkpoint unchanged", extra={"pages": pages})
|
||||
return False
|
||||
await self.usage_client.post_events(
|
||||
[
|
||||
{
|
||||
"id": str(uuid4()),
|
||||
"source": "application",
|
||||
"type": "collector_checkpoint",
|
||||
"timestamp": now.isoformat(),
|
||||
"payload": {
|
||||
"scan_started_at": now.isoformat(),
|
||||
"window_start": lower.isoformat(),
|
||||
"window_end": now.isoformat(),
|
||||
"completed": True,
|
||||
"mode": "full" if full else "incremental",
|
||||
"pages": pages,
|
||||
"events": count,
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,99 @@
|
||||
"""One-shot provider accounting endpoint, invoked by the API's Celery task.
|
||||
|
||||
The E2B credential stays in its existing service. Collection owns separate HTTP
|
||||
clients, has no Redis leader or recurring task, and never touches runtime leases.
|
||||
Invalid optional accounting configuration fails only this endpoint.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from dify_agent.server.auth import create_bearer_token_dependency
|
||||
from dify_agent.server.e2b_usage_client import E2BUsageApiClient
|
||||
from dify_agent.server.e2b_usage_collector import E2BUsageCollector
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_COLLECTION_TIMEOUT_SECONDS = 240.0
|
||||
|
||||
|
||||
class E2BUsageCollectRequest(BaseModel):
|
||||
project_id: str = Field(min_length=1, max_length=128)
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class E2BUsageCollectResponse(BaseModel):
|
||||
completed: bool
|
||||
|
||||
|
||||
class _CollectionOptions(BaseModel):
|
||||
overlap_seconds: int = Field(ge=1)
|
||||
full_scan_interval_seconds: int = Field(ge=1)
|
||||
max_pages: int = Field(ge=1, le=10000)
|
||||
|
||||
|
||||
def create_e2b_usage_router(settings: ServerSettings) -> APIRouter:
|
||||
async def require_configured_auth() -> None:
|
||||
# Other legacy control-plane routes may permit a missing token. New
|
||||
# collection requests must never expose the project key without auth.
|
||||
if not settings.api_token:
|
||||
raise HTTPException(status_code=503, detail="e2b_usage_collection_auth_unconfigured")
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/internal/e2b/usage",
|
||||
dependencies=[Depends(require_configured_auth), create_bearer_token_dependency(settings.api_token)],
|
||||
)
|
||||
|
||||
@router.post("/collect", response_model=E2BUsageCollectResponse)
|
||||
async def collect(request: E2BUsageCollectRequest) -> E2BUsageCollectResponse:
|
||||
project_id = settings.e2b_project_id.strip()
|
||||
if (
|
||||
not settings.sandbox_metering_enabled
|
||||
or settings.runtime_backend != "e2b"
|
||||
or not settings.e2b_api_key
|
||||
or not project_id
|
||||
or not settings.inner_api_key
|
||||
):
|
||||
raise HTTPException(status_code=503, detail="e2b_usage_collection_unavailable")
|
||||
if request.project_id != project_id:
|
||||
raise HTTPException(status_code=403, detail="e2b_usage_project_mismatch")
|
||||
try:
|
||||
options = _CollectionOptions.model_validate(
|
||||
{
|
||||
"overlap_seconds": settings.sandbox_metering_overlap_seconds,
|
||||
"full_scan_interval_seconds": settings.sandbox_metering_full_scan_interval_seconds,
|
||||
"max_pages": settings.sandbox_metering_max_pages,
|
||||
}
|
||||
)
|
||||
async with (
|
||||
asyncio.timeout(_COLLECTION_TIMEOUT_SECONDS),
|
||||
httpx.AsyncClient(timeout=15.0, trust_env=False) as provider_client,
|
||||
httpx.AsyncClient(timeout=15.0, trust_env=False) as inner_client,
|
||||
):
|
||||
collector = E2BUsageCollector(
|
||||
provider_client=provider_client,
|
||||
usage_client=E2BUsageApiClient(
|
||||
client=inner_client,
|
||||
base_url=settings.inner_api_url,
|
||||
api_key=settings.inner_api_key,
|
||||
project_id=project_id,
|
||||
),
|
||||
api_key=settings.e2b_api_key,
|
||||
project_id=project_id,
|
||||
overlap_seconds=options.overlap_seconds,
|
||||
full_scan_interval_seconds=options.full_scan_interval_seconds,
|
||||
max_pages=options.max_pages,
|
||||
)
|
||||
return E2BUsageCollectResponse(completed=await collector.collect_once())
|
||||
except TimeoutError as exc:
|
||||
logger.warning("E2B usage collection exceeded its deadline")
|
||||
raise HTTPException(status_code=504, detail="e2b_usage_collection_timed_out") from exc
|
||||
except Exception as exc:
|
||||
logger.warning("E2B usage collection failed", extra={"error_type": type(exc).__name__})
|
||||
raise HTTPException(status_code=503, detail="e2b_usage_collection_failed") from exc
|
||||
|
||||
return router
|
||||
@@ -88,6 +88,13 @@ class ServerSettings(BaseSettings):
|
||||
le=E2B_MAX_ACTIVE_TIMEOUT_SECONDS,
|
||||
)
|
||||
e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535)
|
||||
sandbox_metering_enabled: bool = False
|
||||
e2b_project_id: str = ""
|
||||
# Optional accounting validates its numeric configuration at invocation,
|
||||
# so a bad metering value cannot prevent ordinary runtime startup.
|
||||
sandbox_metering_overlap_seconds: int | str = 900
|
||||
sandbox_metering_full_scan_interval_seconds: int | str = 3600
|
||||
sandbox_metering_max_pages: int | str = 1000
|
||||
openshell_gateway_endpoint: str | None = None
|
||||
openshell_workspace: str = "default"
|
||||
openshell_bearer_token: str | None = None
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import posixpath
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
from typing import cast
|
||||
@@ -191,7 +191,7 @@ class _ControlPlane:
|
||||
|
||||
def _mock_http(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
handler: Callable[[httpx.Request], httpx.Response],
|
||||
handler: Callable[[httpx.Request], httpx.Response | Awaitable[httpx.Response]],
|
||||
) -> list[httpx.AsyncClient]:
|
||||
original_async_client = httpx.AsyncClient
|
||||
transport = httpx.MockTransport(handler)
|
||||
@@ -1082,3 +1082,117 @@ async def test_e2b_acquire_preserves_health_failure_when_close_and_pause_fail(
|
||||
assert client.calls == 3
|
||||
assert data_plane.close_calls == 1
|
||||
assert sandbox.pauses == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_create_task_cancellation_during_initialization_kills_sandbox(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def blocked_make_dir(_self: _Files, _path: str) -> bool:
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(_Files, "make_dir", blocked_make_dir)
|
||||
control = _ControlPlane()
|
||||
backend = _binding_backend(control)
|
||||
task = asyncio.create_task(
|
||||
backend.create_binding(
|
||||
ExecutionBindingCreateSpec(
|
||||
tenant_id="tenant",
|
||||
agent_id="agent",
|
||||
binding_id="binding",
|
||||
workspace_id="workspace",
|
||||
existing_workspace_ref=None,
|
||||
)
|
||||
)
|
||||
)
|
||||
async with asyncio.timeout(2):
|
||||
await entered.wait()
|
||||
task.cancel("cancel initialization")
|
||||
with pytest.raises(asyncio.CancelledError, match="cancel initialization"):
|
||||
await task
|
||||
assert len(control.created) == 1
|
||||
assert control.sandboxes["sandbox-1"].killed == 1
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acquire_task_cancellation_during_health_closes_transport_and_pauses(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def handler(_request: httpx.Request) -> httpx.Response:
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
return httpx.Response(200, json={"status": "ok"})
|
||||
|
||||
clients = _mock_http(monkeypatch, handler)
|
||||
backend, sandbox = _connected_backend()
|
||||
task = asyncio.create_task(backend.acquire(sandbox.sandbox_id))
|
||||
async with asyncio.timeout(2):
|
||||
await entered.wait()
|
||||
task.cancel("cancel acquisition")
|
||||
with pytest.raises(asyncio.CancelledError, match="cancel acquisition"):
|
||||
await task
|
||||
assert clients[0].is_closed
|
||||
assert sandbox.pauses == [True]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_release_task_cancellation_keeps_pause_retries_and_original_cancel(
|
||||
sleep_delays: list[float],
|
||||
) -> None:
|
||||
entered = asyncio.Event()
|
||||
|
||||
class BlockingCloseDataPlane:
|
||||
async def close(self) -> None:
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
backend, sandbox = _connected_backend()
|
||||
sandbox.pause_errors = [_transport_error(e2b_httpx.ReadTimeout), _transport_error(e2b_httpx.ReadTimeout)]
|
||||
lease = E2BRuntimeLease(
|
||||
sandbox=sandbox, # pyright: ignore[reportArgumentType]
|
||||
data_plane=cast(ShellctlRuntimeLease, cast(object, BlockingCloseDataPlane())),
|
||||
)
|
||||
task = asyncio.create_task(backend.release(lease))
|
||||
async with asyncio.timeout(2):
|
||||
await entered.wait()
|
||||
task.cancel("cancel release")
|
||||
with pytest.raises(asyncio.CancelledError, match="cancel release"):
|
||||
await task
|
||||
assert sandbox.pauses == [True, True]
|
||||
assert sleep_delays == [0.25]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_acquire_primary_health_error_survives_task_cancel_during_compensation(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
# Preserve the pre-metering best-effort compensation contract: a failure in
|
||||
# cleanup must not replace the primary acquisition error.
|
||||
entered = asyncio.Event()
|
||||
|
||||
async def blocked_pause(sandbox: _Sandbox, keep_memory: bool = True) -> bool:
|
||||
sandbox.pauses.append(keep_memory)
|
||||
entered.set()
|
||||
await asyncio.Event().wait()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(_Sandbox, "pause", blocked_pause)
|
||||
clients = _mock_http(
|
||||
monkeypatch,
|
||||
lambda _request: httpx.Response(401, json={"error": {"code": "unauthorized", "message": "bad token"}}),
|
||||
)
|
||||
backend, sandbox = _connected_backend()
|
||||
task = asyncio.create_task(backend.acquire(sandbox.sandbox_id))
|
||||
async with asyncio.timeout(2):
|
||||
await entered.wait()
|
||||
task.cancel("cancel compensation")
|
||||
with pytest.raises(BindingAcquireError, match="bad token"):
|
||||
await task
|
||||
assert clients[0].is_closed
|
||||
assert sandbox.pauses == [True]
|
||||
@@ -461,3 +461,35 @@ def test_server_settings_use_generic_outbound_http_args_for_shared_clients() ->
|
||||
assert "outbound_http_max_connections" in model_fields
|
||||
assert "outbound_http_max_keepalive_connections" in model_fields
|
||||
assert "outbound_http_keepalive_expiry" in model_fields
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_optional_metering_never_starts_a_collector_or_blocks_runtime_startup(
|
||||
monkeypatch: pytest.MonkeyPatch, enabled: bool
|
||||
) -> None:
|
||||
import dify_agent.server.routes.e2b_usage as usage_route
|
||||
|
||||
_patch_app_lifecycle(monkeypatch)
|
||||
|
||||
def unexpected_collector(*args: object, **kwargs: object) -> None:
|
||||
raise AssertionError("collector must only be constructed by an explicit scheduled request")
|
||||
|
||||
monkeypatch.setattr(usage_route, "E2BUsageCollector", unexpected_collector)
|
||||
settings = ServerSettings(
|
||||
_env_file=None,
|
||||
sandbox_metering_enabled=enabled,
|
||||
runtime_backend="local",
|
||||
api_token="control-token",
|
||||
e2b_project_id="",
|
||||
e2b_api_key=None,
|
||||
inner_api_key=None,
|
||||
)
|
||||
with TestClient(create_app(settings)) as client:
|
||||
assert client.get("/openapi.json").status_code == 200
|
||||
response = client.post(
|
||||
"/internal/e2b/usage/collect",
|
||||
headers={"Authorization": "Bearer control-token"},
|
||||
json={"project_id": "project"},
|
||||
)
|
||||
assert response.status_code == 503
|
||||
assert FakeRunScheduler.created[-1].shutdown_called
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from dify_agent.server.e2b_usage_client import E2BUsageApiClient
|
||||
|
||||
|
||||
def _event(event_id: str = "event-1") -> dict[str, Any]:
|
||||
return {"id": event_id, "source": "provider", "type": "sandbox.lifecycle.paused", "payload": {}}
|
||||
|
||||
|
||||
def _ack(request: httpx.Request) -> httpx.Response:
|
||||
count = len(json.loads(request.content)["events"])
|
||||
return httpx.Response(200, json={"accepted": count, "duplicates": 0, "conflicts": 0, "ignored": 0})
|
||||
|
||||
|
||||
def test_client_rejects_wrong_project_state_and_incomplete_ack() -> None:
|
||||
async def scenario() -> None:
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
return httpx.Response(
|
||||
200, json={"enabled": True, "project_id": "wrong", "started_at": "2026-09-20T00:00:00Z"}
|
||||
)
|
||||
return httpx.Response(200, json={"accepted": 0, "duplicates": 0, "conflicts": 0, "ignored": 0})
|
||||
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
client = E2BUsageApiClient(http, "http://inner", "inner", "project")
|
||||
with pytest.raises(ValueError, match="project mismatch"):
|
||||
await client.get_state()
|
||||
with pytest.raises(ValueError, match="entire batch"):
|
||||
await client.post_events([_event()])
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_large_batches_respect_api_byte_limit_and_keep_stable_ids_on_retry() -> None:
|
||||
async def scenario() -> None:
|
||||
bodies: list[list[dict[str, Any]]] = []
|
||||
fail_second = True
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert len(request.content) <= 1024 * 1024
|
||||
assert request.headers["Content-Type"] == "application/json"
|
||||
batch = json.loads(request.content)["events"]
|
||||
bodies.append(batch)
|
||||
if fail_second and len(bodies) == 2:
|
||||
return httpx.Response(503)
|
||||
return httpx.Response(200, json={"accepted": len(batch), "duplicates": 0, "conflicts": 0, "ignored": 0})
|
||||
|
||||
events = [{**_event(str(i)), "payload": {"provider_metadata": "x" * 60000}} for i in range(40)]
|
||||
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
|
||||
client = E2BUsageApiClient(http, "http://inner", "inner", "project")
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await client.post_events(events)
|
||||
fail_second = False
|
||||
await client.post_events(events)
|
||||
assert bodies[0] == bodies[2]
|
||||
assert [event["id"] for batch in bodies[2:] for event in batch] == [str(i) for i in range(40)]
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from dify_agent.server.e2b_usage_collector import E2BUsageCollector, provider_event
|
||||
from dify_agent.server.e2b_usage_client import E2BUsageApiClient
|
||||
|
||||
NOW = datetime(2026, 9, 20, 1, tzinfo=UTC)
|
||||
T0 = NOW - timedelta(hours=1)
|
||||
|
||||
|
||||
def _provider(event_id: str = "event-1", timestamp: datetime = NOW) -> dict[str, Any]:
|
||||
return {
|
||||
"version": "v2",
|
||||
"id": event_id,
|
||||
"type": "sandbox.lifecycle.paused",
|
||||
"sandboxId": "sandbox-1",
|
||||
"sandboxTeamId": "project",
|
||||
"sandboxExecutionId": "execution-1",
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"eventData": {
|
||||
"execution": {"execution_time": 1234, "memory_mb": 1024, "vcpu_count": 2, "started_at": T0.isoformat()}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _state(**kwargs: Any) -> dict[str, Any]:
|
||||
return {"enabled": True, "project_id": "project", "started_at": T0.isoformat(), **kwargs}
|
||||
|
||||
|
||||
def test_actual_v2_camel_case_shape_is_preserved_and_project_checked() -> None:
|
||||
raw = _provider()
|
||||
raw["timestamp"] = "2026-09-20T01:00:00.551745815Z"
|
||||
event = provider_event(raw, project_id="project")
|
||||
assert event["id"] == raw["id"]
|
||||
assert event["execution_id"] == "execution-1"
|
||||
assert event["payload"] == raw
|
||||
with pytest.raises(ValueError, match="project mismatch"):
|
||||
provider_event(raw, project_id="other")
|
||||
|
||||
|
||||
def test_complete_scan_acks_all_pages_before_checkpoint_and_resets_offset() -> None:
|
||||
async def scenario() -> None:
|
||||
offsets: list[int] = []
|
||||
batches: list[list[dict[str, Any]]] = []
|
||||
|
||||
async def provider(request: httpx.Request) -> httpx.Response:
|
||||
assert request.headers["X-API-Key"] == "provider-only"
|
||||
assert "X-Inner-Api-Key" not in request.headers
|
||||
assert request.url.params["orderAsc"] == "false"
|
||||
offset = int(request.url.params["offset"])
|
||||
offsets.append(offset)
|
||||
if offset == 0:
|
||||
return httpx.Response(200, json=[_provider(str(i)) for i in range(100)])
|
||||
return httpx.Response(200, json=[_provider("old", T0 - timedelta(seconds=1))])
|
||||
|
||||
async def inner(request: httpx.Request) -> httpx.Response:
|
||||
assert "X-API-Key" not in request.headers
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json=_state())
|
||||
batch = json.loads(request.content)["events"]
|
||||
batches.append(batch)
|
||||
return httpx.Response(200, json={"accepted": len(batch), "duplicates": 0, "conflicts": 0, "ignored": 0})
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(provider)) as p,
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(inner)) as i,
|
||||
):
|
||||
collector = E2BUsageCollector(
|
||||
p,
|
||||
E2BUsageApiClient(i, "http://inner", "inner", "project"),
|
||||
"provider-only",
|
||||
"project",
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
assert await collector.collect_once()
|
||||
assert len(batches) == 2
|
||||
assert len(batches[0]) == 100
|
||||
checkpoint = batches[1][0]
|
||||
assert checkpoint["type"] == "collector_checkpoint"
|
||||
assert checkpoint["payload"]["window_start"] == T0.isoformat()
|
||||
assert checkpoint["payload"]["pages"] == 2
|
||||
assert await collector.collect_once()
|
||||
assert offsets == [0, 100, 0, 100]
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", ["max_pages", "api_failure", "wrong_project"])
|
||||
def test_incomplete_scan_never_advances_checkpoint(failure: str) -> None:
|
||||
async def scenario() -> None:
|
||||
events: list[dict[str, Any]] = []
|
||||
|
||||
async def provider(request: httpx.Request) -> httpx.Response:
|
||||
raw = _provider()
|
||||
if failure == "wrong_project":
|
||||
raw["sandboxTeamId"] = "other"
|
||||
return httpx.Response(200, json=[raw] * 100)
|
||||
|
||||
async def inner(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json=_state())
|
||||
if failure == "api_failure":
|
||||
return httpx.Response(503)
|
||||
batch = json.loads(request.content)["events"]
|
||||
events.extend(batch)
|
||||
return httpx.Response(200, json={"accepted": len(batch), "duplicates": 0, "conflicts": 0, "ignored": 0})
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(provider)) as p,
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(inner)) as i,
|
||||
):
|
||||
collector = E2BUsageCollector(
|
||||
p,
|
||||
E2BUsageApiClient(i, "http://inner", "inner", "project"),
|
||||
"provider",
|
||||
"project",
|
||||
max_pages=1,
|
||||
clock=lambda: NOW,
|
||||
)
|
||||
if failure == "max_pages":
|
||||
assert not await collector.collect_once()
|
||||
elif failure == "api_failure":
|
||||
with pytest.raises(httpx.HTTPStatusError):
|
||||
await collector.collect_once()
|
||||
else:
|
||||
with pytest.raises(ValueError, match="project mismatch"):
|
||||
await collector.collect_once()
|
||||
assert all(event["type"] != "collector_checkpoint" for event in events)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_incremental_overlap_and_retention_gap_are_explicit() -> None:
|
||||
async def scenario() -> None:
|
||||
state = _state(checkpoint_at=(NOW - timedelta(minutes=5)).isoformat(), full_scan_at=NOW.isoformat())
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
async def provider(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=[])
|
||||
|
||||
async def inner(request: httpx.Request) -> httpx.Response:
|
||||
if request.method == "GET":
|
||||
return httpx.Response(200, json=state)
|
||||
batch = json.loads(request.content)["events"]
|
||||
sent.extend(batch)
|
||||
return httpx.Response(200, json={"accepted": len(batch), "duplicates": 0, "conflicts": 0, "ignored": 0})
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(provider)) as p,
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(inner)) as i,
|
||||
):
|
||||
collector = E2BUsageCollector(
|
||||
p, E2BUsageApiClient(i, "http://inner", "inner", "project"), "provider", "project", clock=lambda: NOW
|
||||
)
|
||||
await collector.collect_once()
|
||||
assert sent[-1]["payload"]["window_start"] == (NOW - timedelta(minutes=20)).isoformat()
|
||||
state.update(started_at=(NOW - timedelta(days=10)).isoformat(), checkpoint_at=None, full_scan_at=None)
|
||||
await collector.collect_once()
|
||||
assert sent[-2]["type"] == "collector_retention_gap"
|
||||
assert sent[-1]["payload"]["window_start"] == (NOW - timedelta(days=7)).isoformat()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_disabled_or_future_activation_does_not_contact_e2b() -> None:
|
||||
async def scenario() -> None:
|
||||
async def provider(request: httpx.Request) -> httpx.Response:
|
||||
raise AssertionError("provider must not be queried before activation")
|
||||
|
||||
state = _state(enabled=False)
|
||||
|
||||
async def inner(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json=state)
|
||||
|
||||
async with (
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(provider)) as p,
|
||||
httpx.AsyncClient(transport=httpx.MockTransport(inner)) as i,
|
||||
):
|
||||
collector = E2BUsageCollector(
|
||||
p, E2BUsageApiClient(i, "http://inner", "inner", "project"), "provider", "project", clock=lambda: NOW
|
||||
)
|
||||
assert not await collector.collect_once()
|
||||
state.update(enabled=True, started_at=(NOW + timedelta(hours=1)).isoformat())
|
||||
assert not await collector.collect_once()
|
||||
|
||||
asyncio.run(scenario())
|
||||
@@ -0,0 +1,176 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
import dify_agent.server.routes.e2b_usage as route_module
|
||||
from dify_agent.server.routes.e2b_usage import create_e2b_usage_router
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
def _settings(**overrides: Any) -> ServerSettings:
|
||||
config = {
|
||||
"_env_file": None,
|
||||
"sandbox_metering_enabled": True,
|
||||
"runtime_backend": "e2b",
|
||||
"api_token": "control-only",
|
||||
"e2b_api_key": "provider-only",
|
||||
"e2b_project_id": "project",
|
||||
"inner_api_key": "inner-only",
|
||||
"inner_api_url": "http://inner",
|
||||
}
|
||||
return ServerSettings(**(config | overrides))
|
||||
|
||||
|
||||
def _app(settings: ServerSettings) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(create_e2b_usage_router(settings))
|
||||
return app
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("settings", "headers", "project", "status"),
|
||||
[
|
||||
(_settings(), {}, "project", 401),
|
||||
(_settings(), {"Authorization": "Bearer wrong"}, "project", 401),
|
||||
(_settings(api_token=None), {}, "project", 503),
|
||||
(_settings(sandbox_metering_enabled=False), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(_settings(e2b_project_id=" "), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(_settings(e2b_api_key=None), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(_settings(inner_api_key=None), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(_settings(runtime_backend="local"), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(_settings(sandbox_metering_max_pages=0), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(_settings(sandbox_metering_max_pages="invalid"), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(_settings(sandbox_metering_overlap_seconds=-1), {"Authorization": "Bearer control-only"}, "project", 503),
|
||||
(
|
||||
_settings(sandbox_metering_full_scan_interval_seconds=""),
|
||||
{"Authorization": "Bearer control-only"},
|
||||
"project",
|
||||
503,
|
||||
),
|
||||
(_settings(), {"Authorization": "Bearer control-only"}, "wrong-project", 403),
|
||||
(_settings(), {"Authorization": "Bearer control-only"}, "", 422),
|
||||
],
|
||||
)
|
||||
def test_collection_auth_and_configuration_errors_are_local_to_endpoint(
|
||||
settings: ServerSettings, headers: dict[str, str], project: str, status: int, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def unexpected_collector(*args: object, **kwargs: object) -> None:
|
||||
raise AssertionError("invalid collection must not query the provider")
|
||||
|
||||
monkeypatch.setattr(route_module, "E2BUsageCollector", unexpected_collector)
|
||||
with TestClient(_app(settings)) as client:
|
||||
response = client.post("/internal/e2b/usage/collect", headers=headers, json={"project_id": project})
|
||||
assert response.status_code == status
|
||||
assert "provider-only" not in response.text and "inner-only" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [None, "provider", "ack", "bounded"])
|
||||
def test_explicit_request_scans_once_and_only_completed_scan_commits_checkpoint(
|
||||
monkeypatch: pytest.MonkeyPatch, failure: str | None
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
batches: list[list[dict[str, Any]]] = []
|
||||
clients: list[httpx.AsyncClient] = []
|
||||
now = datetime.now(UTC)
|
||||
event = {
|
||||
"version": "v2",
|
||||
"id": "event-1",
|
||||
"type": "sandbox.lifecycle.paused",
|
||||
"sandboxTeamId": "project",
|
||||
"sandboxId": "sandbox",
|
||||
"sandboxExecutionId": "execution",
|
||||
"timestamp": now.isoformat(),
|
||||
"eventData": {},
|
||||
}
|
||||
|
||||
async def transport(request: httpx.Request) -> httpx.Response:
|
||||
calls.append(request.url.host)
|
||||
if request.url.host == "api.e2b.app":
|
||||
assert request.headers["X-API-Key"] == "provider-only"
|
||||
assert "X-Inner-Api-Key" not in request.headers
|
||||
if failure == "provider":
|
||||
return httpx.Response(503)
|
||||
return httpx.Response(200, json=[event] * (100 if failure == "bounded" else 1))
|
||||
assert request.url.host == "inner"
|
||||
assert request.headers["X-Inner-Api-Key"] == "inner-only"
|
||||
assert "X-API-Key" not in request.headers
|
||||
if request.method == "GET":
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"enabled": True, "project_id": "project", "started_at": (now - timedelta(hours=1)).isoformat()},
|
||||
)
|
||||
batch = json.loads(request.content)["events"]
|
||||
batches.append(batch)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={"accepted": 0 if failure == "ack" else len(batch), "duplicates": 0, "conflicts": 0, "ignored": 0},
|
||||
)
|
||||
|
||||
original_client = httpx.AsyncClient
|
||||
|
||||
def client_factory(**kwargs: Any) -> httpx.AsyncClient:
|
||||
assert kwargs["trust_env"] is False
|
||||
result = original_client(transport=httpx.MockTransport(transport), **kwargs)
|
||||
clients.append(result)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(route_module.httpx, "AsyncClient", client_factory)
|
||||
with TestClient(_app(_settings(sandbox_metering_max_pages="1", sandbox_metering_overlap_seconds="900"))) as client:
|
||||
assert calls == [] # no startup polling, leadership or accounting IO
|
||||
response = client.post(
|
||||
"/internal/e2b/usage/collect",
|
||||
headers={"Authorization": "Bearer control-only"},
|
||||
json={"project_id": "project"},
|
||||
)
|
||||
assert len(clients) == 2 and all(client.is_closed for client in clients)
|
||||
assert calls.count("api.e2b.app") == 1
|
||||
checkpoints = [event for batch in batches for event in batch if event["type"] == "collector_checkpoint"]
|
||||
if failure in {"provider", "ack"}:
|
||||
assert response.status_code == 503
|
||||
assert checkpoints == []
|
||||
else:
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"completed": failure != "bounded"}
|
||||
assert len(checkpoints) == (0 if failure == "bounded" else 1)
|
||||
|
||||
|
||||
def test_collection_deadline_closes_clients_without_checkpoint(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
clients: list[httpx.AsyncClient] = []
|
||||
posts: list[httpx.Request] = []
|
||||
|
||||
async def transport(request: httpx.Request) -> httpx.Response:
|
||||
if request.url.host == "inner":
|
||||
if request.method == "POST":
|
||||
posts.append(request)
|
||||
return httpx.Response(
|
||||
200, json={"enabled": True, "project_id": "project", "started_at": datetime.now(UTC).isoformat()}
|
||||
)
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("unreachable")
|
||||
|
||||
original_client = httpx.AsyncClient
|
||||
|
||||
def client_factory(**kwargs: Any) -> httpx.AsyncClient:
|
||||
result = original_client(transport=httpx.MockTransport(transport), **kwargs)
|
||||
clients.append(result)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(route_module.httpx, "AsyncClient", client_factory)
|
||||
monkeypatch.setattr(route_module, "_COLLECTION_TIMEOUT_SECONDS", 0.02)
|
||||
with TestClient(_app(_settings())) as client:
|
||||
response = client.post(
|
||||
"/internal/e2b/usage/collect",
|
||||
headers={"Authorization": "Bearer control-only"},
|
||||
json={"project_id": "project"},
|
||||
)
|
||||
assert response.status_code == 504
|
||||
assert posts == []
|
||||
assert len(clients) == 2 and all(client.is_closed for client in clients)
|
||||
@@ -524,3 +524,47 @@ def test_server_settings_rejects_non_array_shell_redact_patterns(monkeypatch: py
|
||||
|
||||
with pytest.raises(ValueError, match="must be a JSON array"):
|
||||
_ = settings.get_shell_redact_patterns()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[{"runtime_backend": "local"}, {"e2b_api_key": None}, {"e2b_project_id": " "}, {"inner_api_key": None}],
|
||||
)
|
||||
def test_metering_configuration_is_checked_only_by_collection_endpoint(overrides: dict[str, object]) -> None:
|
||||
config: dict[str, object] = {
|
||||
"sandbox_metering_enabled": True,
|
||||
"runtime_backend": "e2b",
|
||||
"e2b_api_key": "provider-key",
|
||||
"e2b_project_id": "project",
|
||||
"inner_api_key": "inner-key",
|
||||
"_env_file": None,
|
||||
}
|
||||
config.update(overrides)
|
||||
# Optional accounting must not prevent unrelated runtime startup. Missing
|
||||
# project/credentials/backend compatibility are checked by the one-shot route.
|
||||
settings = ServerSettings(**config)
|
||||
assert settings.sandbox_metering_enabled
|
||||
|
||||
|
||||
def test_metering_defaults_off_and_reads_env_when_explicitly_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert not ServerSettings(_env_file=None).sandbox_metering_enabled
|
||||
monkeypatch.setenv("DIFY_AGENT_SANDBOX_METERING_ENABLED", "true")
|
||||
monkeypatch.setenv("DIFY_AGENT_RUNTIME_BACKEND", "e2b")
|
||||
monkeypatch.setenv("DIFY_AGENT_E2B_API_KEY", "provider-key")
|
||||
monkeypatch.setenv("DIFY_AGENT_E2B_PROJECT_ID", "project")
|
||||
monkeypatch.setenv("DIFY_AGENT_INNER_API_KEY", "inner-key")
|
||||
settings = ServerSettings(_env_file=None)
|
||||
assert settings.sandbox_metering_enabled
|
||||
assert settings.sandbox_metering_max_pages == 1000
|
||||
assert settings.sandbox_metering_overlap_seconds == 900
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "-1", "not-a-number"])
|
||||
@pytest.mark.parametrize("enabled", ["true", "false"])
|
||||
def test_invalid_optional_metering_number_does_not_block_settings_startup(
|
||||
monkeypatch: pytest.MonkeyPatch, value: str, enabled: str
|
||||
) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_SANDBOX_METERING_ENABLED", enabled)
|
||||
monkeypatch.setenv("DIFY_AGENT_SANDBOX_METERING_MAX_PAGES", value)
|
||||
settings = ServerSettings(_env_file=None)
|
||||
assert settings.sandbox_metering_max_pages == value
|
||||
@@ -49,6 +49,17 @@ DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox
|
||||
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
|
||||
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
|
||||
|
||||
# Independent sandbox usage ledger. API owns fixed activation time T0; no backfill.
|
||||
# Enable only with matching API metering project settings and events API access.
|
||||
# Celery Beat in the API triggers a protected one-shot collection endpoint.
|
||||
# No operation logging, Agent polling loop, or collector Redis leader.
|
||||
# Collection requires the existing DIFY_AGENT_API_TOKEN to be configured.
|
||||
DIFY_AGENT_SANDBOX_METERING_ENABLED=false
|
||||
DIFY_AGENT_E2B_PROJECT_ID=
|
||||
DIFY_AGENT_SANDBOX_METERING_OVERLAP_SECONDS=900
|
||||
DIFY_AGENT_SANDBOX_METERING_FULL_SCAN_INTERVAL_SECONDS=3600
|
||||
DIFY_AGENT_SANDBOX_METERING_MAX_PAGES=1000
|
||||
|
||||
# OpenShell backend (DIFY_AGENT_RUNTIME_BACKEND=openshell): sandboxes run on a
|
||||
# self-hosted NVIDIA OpenShell gateway. Setup and validation:
|
||||
# dify-agent/docs/dify-agent/guide/openshell.md
|
||||
|
||||
@@ -127,6 +127,13 @@ AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
|
||||
# Client timeout for Agent backend calls that may carry a Home Snapshot transfer.
|
||||
# Must stay above DIFY_AGENT_ENTERPRISE_SANDBOX_SNAPSHOT_TIMEOUT on the Agent backend.
|
||||
AGENT_BACKEND_HOME_SNAPSHOT_TIMEOUT_SECONDS=45
|
||||
# Independent E2B accounting, collected only by API Celery Beat/workers.
|
||||
# Uses the existing ops_trace queue; business binding operations never call metering.
|
||||
# Enable after migrations. START_AT is an immutable, whole-second UTC T0.
|
||||
AGENT_SANDBOX_METERING_ENABLED=false
|
||||
AGENT_SANDBOX_METERING_PROJECT_ID=
|
||||
AGENT_SANDBOX_METERING_START_AT=
|
||||
AGENT_SANDBOX_METERING_INTERVAL_SECONDS=60
|
||||
# Outer execution limit for Agent Apps. Agent Backend may stop the internal
|
||||
# model/tool loop sooner when DIFY_AGENT_RUN_TIMEOUT_SECONDS is lower.
|
||||
APP_MAX_EXECUTION_TIME=3600
|
||||
|
||||
@@ -2978,14 +2978,6 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/panel/test-run/preparation/data-source-options/option-card.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/panel/test-run/preparation/document-processing/index.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -3019,14 +3011,6 @@
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/publish-toast.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/rag-pipeline/components/rag-pipeline-children.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
@@ -4008,14 +3992,6 @@
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-base/components/option-card.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/hooks.tsx": {
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 4
|
||||
@@ -4938,12 +4914,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/workflow/variable-inspect/trigger.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 5
|
||||
},
|
||||
"jsx-a11y/no-static-element-interactions": {
|
||||
"count": 5
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
+4
-4
@@ -136,12 +136,12 @@ describe('ExternalDataToolModal', () => {
|
||||
fireEvent.click(screen.getByText('pick-extension'))
|
||||
fireEvent.click(screen.getByText('open-emoji-picker'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
const emojiButton = await screen.findByRole('gridcell', { name: 'Grinning face' })
|
||||
expect(emojiButton).toBeTruthy()
|
||||
fireEvent.click(emojiButton!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
fireEvent.click(screen.getByText(/(?:^|\.)operation\.save(?=$|:)/))
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('ExternalDataToolModal', () => {
|
||||
},
|
||||
enabled: true,
|
||||
icon: expect.any(String),
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
label: 'Search',
|
||||
type: 'api',
|
||||
variable: 'search_api',
|
||||
@@ -168,7 +168,7 @@ describe('ExternalDataToolModal', () => {
|
||||
},
|
||||
enabled: true,
|
||||
icon: expect.any(String),
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
label: 'Search',
|
||||
type: 'api',
|
||||
variable: 'search_api',
|
||||
|
||||
@@ -405,12 +405,12 @@ describe('CreateAppModal', () => {
|
||||
fireEvent.click(screen.getByText('app.types.chatbot'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.captionName' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
fireEvent.change(screen.getByPlaceholderText('app.newApp.appNamePlaceholder'), {
|
||||
target: { value: 'Keyboard App' },
|
||||
@@ -427,7 +427,7 @@ describe('CreateAppModal', () => {
|
||||
description: 'Created from shortcut',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
mode: AppModeEnum.CHAT,
|
||||
})
|
||||
})
|
||||
@@ -449,14 +449,14 @@ describe('CreateAppModal', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'app.newApp.captionName' }))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
|
||||
await userEvent.setup().keyboard('{Escape}')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
|
||||
ahooksMocks.keyPressHandlers.at(-1)?.()
|
||||
|
||||
|
||||
@@ -296,13 +296,13 @@ describe('SwitchAppModal', () => {
|
||||
|
||||
await user.click(screen.getByText('open-icon-picker'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
await user.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: 'app.switchStart' }))
|
||||
|
||||
@@ -313,7 +313,7 @@ describe('SwitchAppModal', () => {
|
||||
body: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
icon: '🚀',
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
@@ -326,13 +326,13 @@ describe('SwitchAppModal', () => {
|
||||
|
||||
await user.click(screen.getByText('open-icon-picker'))
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
|
||||
await user.keyboard('{Escape}')
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByText('app.removeOriginal'))
|
||||
expect(screen.getByRole('button', { name: 'common.operation.cancel' })).toBeInTheDocument()
|
||||
|
||||
@@ -7,7 +7,6 @@ import { createRef, useEffect, useState } from 'react'
|
||||
import Cropper from 'react-easy-crop'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ALLOW_FILE_EXTENSIONS } from '@/types/app'
|
||||
import { ImagePlus } from '../icons/src/vender/line/images'
|
||||
import { useDraggableUploader } from './hooks'
|
||||
import { checkIsAnimatedImage } from './utils'
|
||||
|
||||
@@ -59,7 +58,14 @@ const ImageInput: FC<UploaderProps> = ({ className, cropShape, onImageInput }) =
|
||||
|
||||
const handleShowImage = () => {
|
||||
if (isAnimatedImage) {
|
||||
return <img src={inputImage?.url} alt="" data-testid="animated-image" />
|
||||
return (
|
||||
<img
|
||||
src={inputImage?.url}
|
||||
alt=""
|
||||
className="h-full w-full object-contain"
|
||||
data-testid="animated-image"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -77,11 +83,12 @@ const ImageInput: FC<UploaderProps> = ({ className, cropShape, onImageInput }) =
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'w-full px-3 py-1.5')}>
|
||||
<div className={cn(className, 'w-full p-3')}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex aspect-square flex-col items-center justify-center rounded-lg border-[1.5px] border-dashed border-components-input-border-hover text-gray-500',
|
||||
isDragActive && 'border-primary-600',
|
||||
'relative flex h-60 flex-col items-center justify-center gap-2 overflow-hidden rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg px-4 py-3 text-center text-text-secondary',
|
||||
isDragActive &&
|
||||
'border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
|
||||
)}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
@@ -90,15 +97,18 @@ const ImageInput: FC<UploaderProps> = ({ className, cropShape, onImageInput }) =
|
||||
>
|
||||
{!inputImage ? (
|
||||
<>
|
||||
<ImagePlus className="pointer-events-none mb-3 h-7.5 w-7.5" />
|
||||
<div className="mb-0.5 text-sm font-medium">
|
||||
<span
|
||||
className="pointer-events-none i-ri-image-add-line size-5 text-text-tertiary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="system-sm-medium">
|
||||
<span className="pointer-events-none">
|
||||
{t(($) => $['imageInput.dropImageHere'], { ns: 'common' })}
|
||||
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-components-button-primary-bg"
|
||||
className="rounded-sm text-text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-accent-solid"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{t(($) => $['imageInput.browse'], { ns: 'common' })}
|
||||
@@ -113,7 +123,7 @@ const ImageInput: FC<UploaderProps> = ({ className, cropShape, onImageInput }) =
|
||||
data-testid="image-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="pointer-events-none">
|
||||
<div className="pointer-events-none system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['imageInput.supportedFormats'], { ns: 'common' })}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { Area } from 'react-easy-crop'
|
||||
import type { ImageFile } from '@/types/app'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockEmojiData } from '@/test/emoji-picker'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
@@ -165,9 +165,9 @@ describe('AppIconPicker', () => {
|
||||
renderPicker()
|
||||
|
||||
expect(screen.getByRole('dialog', { name: /emoji/i })).toBeInTheDocument()
|
||||
expect(await screen.findByRole('button', { name: /emoji/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /image/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/cancel/i))!.toBeInTheDocument()
|
||||
expect(await screen.findByRole('radio', { name: /emoji/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /image/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/tryYourLuck/i))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/ok/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -181,12 +181,66 @@ describe('AppIconPicker', () => {
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('should close when cancel is clicked', async () => {
|
||||
const { onOpenChange } = renderPicker()
|
||||
it('records only confirmed selections and restores them when reopened', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
const onOpenChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} />,
|
||||
)
|
||||
await user.click(
|
||||
within(screen.getByRole('region', { name: 'app.iconPicker.recommend' })).getByRole(
|
||||
'button',
|
||||
{ name: '😆' },
|
||||
),
|
||||
)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
expect(onSelect).toHaveBeenCalledWith({ type: 'emoji', icon: '😆', background: '#FEF3F2' })
|
||||
rerender(<AppIconPicker open={false} onOpenChange={onOpenChange} onSelect={onSelect} />)
|
||||
rerender(<AppIconPicker open onOpenChange={onOpenChange} onSelect={onSelect} />)
|
||||
const recent = await screen.findByRole('region', { name: 'app.iconPicker.recent' })
|
||||
await user.click(within(recent).getByRole('button', { name: '😆' }))
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
expect(onSelect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByText(/cancel/i))
|
||||
it('randomizes both emoji and background before confirmation', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
const user = userEvent.setup()
|
||||
const { onSelect } = renderPicker({ initialEmoji: { icon: '😃', background: '#FEF3F2' } })
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.tryYourLuck' }))
|
||||
expect(screen.getByRole('button', { name: '#FFF1F3' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
expect(onSelect).toHaveBeenCalledWith({ type: 'emoji', icon: '😆', background: '#FFF1F3' })
|
||||
})
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
it('shows styles after selecting an emoji and confirms the selected background', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { onSelect } = renderPicker()
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'app.iconPicker.chooseStyle' }),
|
||||
).not.toBeInTheDocument()
|
||||
await user.click(
|
||||
within(screen.getByRole('region', { name: 'app.iconPicker.recommend' })).getByRole(
|
||||
'button',
|
||||
{ name: '😃' },
|
||||
),
|
||||
)
|
||||
const style = screen.getByRole('button', { name: '#F0F2F5' })
|
||||
await user.click(style)
|
||||
expect(style).toHaveAttribute('aria-pressed', 'true')
|
||||
expect(screen.getByRole('gridcell', { name: 'Grinning face with big eyes' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
await user.click(screen.getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
expect(onSelect).toHaveBeenCalledWith({ type: 'emoji', icon: '😃', background: '#F0F2F5' })
|
||||
})
|
||||
|
||||
it('should switch between emoji and image tabs', async () => {
|
||||
@@ -195,7 +249,7 @@ describe('AppIconPicker', () => {
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
expect(screen.getByText(/drop.*here/i))!.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /emoji/i }))
|
||||
await userEvent.click(screen.getByRole('radio', { name: /emoji/i }))
|
||||
expect(screen.getByPlaceholderText(/search/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -247,7 +301,7 @@ describe('AppIconPicker', () => {
|
||||
await waitFor(() => {
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
type: 'emoji',
|
||||
icon: 'rabbit',
|
||||
icon: '🐰',
|
||||
background: '#E4FBCC',
|
||||
})
|
||||
})
|
||||
@@ -255,11 +309,14 @@ describe('AppIconPicker', () => {
|
||||
})
|
||||
|
||||
describe('Image Upload', () => {
|
||||
it('should return early when image tab is active and no file has been selected', async () => {
|
||||
it('hides confirmation actions in the empty image state', async () => {
|
||||
const { onSelect } = renderPicker()
|
||||
|
||||
await userEvent.click(screen.getByText(/image/i))
|
||||
await userEvent.click(screen.getByText(/ok/i))
|
||||
expect(screen.queryByRole('button', { name: 'app.iconPicker.ok' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'app.iconPicker.cancel' }),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
expect(mocks.handleLocalFileUpload).not.toHaveBeenCalled()
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
|
||||
@@ -4,16 +4,21 @@ import type { AppIconType, ImageFile } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { SegmentedControl, SegmentedControlItem } from '@langgenius/dify-ui/segmented-control'
|
||||
import { Separator } from '@langgenius/dify-ui/separator'
|
||||
import { RiImageCircleAiLine } from '@remixicon/react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
import { defaultEmojiBackground } from '../emoji-picker/constants'
|
||||
import { resolveEmoji } from '@/utils/emoji'
|
||||
import {
|
||||
defaultEmojiBackground,
|
||||
getRandomEmoji,
|
||||
getRandomEmojiBackground,
|
||||
} from '../emoji-picker/constants'
|
||||
import EmojiPickerInner from '../emoji-picker/Inner'
|
||||
import { addRecentEmoji, useRecentEmojis } from '../emoji-picker/storage'
|
||||
import { useLocalFileUploader } from '../image-uploader/hooks'
|
||||
import ImageInput from './ImageInput'
|
||||
import s from './style.module.css'
|
||||
import getCroppedImg from './utils'
|
||||
|
||||
export type AppIconEmojiSelection = {
|
||||
@@ -90,12 +95,12 @@ function AppIconPickerContent({
|
||||
{
|
||||
key: 'emoji',
|
||||
label: t(($) => $['iconPicker.emoji'], { ns: 'app' }),
|
||||
icon: <span className="text-lg">🤖</span>,
|
||||
icon: <span className="text-base">🤖</span>,
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
label: t(($) => $['iconPicker.image'], { ns: 'app' }),
|
||||
icon: <RiImageCircleAiLine className="size-4" />,
|
||||
icon: <span className="i-ri-image-circle-ai-line size-4" />,
|
||||
},
|
||||
]
|
||||
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
|
||||
@@ -105,11 +110,13 @@ function AppIconPickerContent({
|
||||
if (!initialEmoji?.icon) return undefined
|
||||
|
||||
return {
|
||||
emoji: initialEmoji.icon,
|
||||
emoji: resolveEmoji(initialEmoji.icon),
|
||||
background: initialEmoji.background ?? defaultEmojiBackground,
|
||||
}
|
||||
})
|
||||
|
||||
const [, setRecentEmojis] = useRecentEmojis()
|
||||
|
||||
const [uploading, setUploading] = useState<boolean>()
|
||||
|
||||
const { handleLocalFileUpload } = useLocalFileUploader({
|
||||
@@ -153,6 +160,7 @@ function AppIconPickerContent({
|
||||
const handleSelect = async () => {
|
||||
if (activeTab === 'emoji') {
|
||||
if (emoji) {
|
||||
setRecentEmojis((recent) => addRecentEmoji(recent, emoji.emoji))
|
||||
onSelect?.({
|
||||
type: 'emoji',
|
||||
icon: emoji.emoji,
|
||||
@@ -181,9 +189,8 @@ function AppIconPickerContent({
|
||||
<DialogContent
|
||||
backdropProps={{ forceRender: true }}
|
||||
className={cn(
|
||||
'w-full overflow-hidden! border-none text-left align-middle',
|
||||
s.container,
|
||||
'h-[min(462px,calc(100dvh-2rem))]! max-h-none! w-90.5! p-0!',
|
||||
'flex max-h-[calc(100dvh-2rem)] w-80.5 flex-col overflow-hidden p-0 text-left',
|
||||
activeTab === 'emoji' && 'h-[min(480px,calc(100dvh-2rem))]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -192,49 +199,80 @@ function AppIconPickerContent({
|
||||
</DialogTitle>
|
||||
|
||||
{showImageUpload && (
|
||||
<div className="w-full p-2 pb-0">
|
||||
<div className="flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary">
|
||||
<div
|
||||
className={cn(
|
||||
'w-full shrink-0 px-3 pt-3 pb-2',
|
||||
activeTab === 'image' && 'border-b border-divider-subtle',
|
||||
)}
|
||||
>
|
||||
<SegmentedControl
|
||||
className="flex w-full"
|
||||
aria-label={t(($) => $['iconPicker.emoji'], { ns: 'app' })}
|
||||
value={activeTab}
|
||||
onValueChange={(value) => {
|
||||
setActiveTab(value)
|
||||
setInputImageInfo(undefined)
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.key}
|
||||
className={cn(
|
||||
'flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 system-sm-medium text-text-tertiary',
|
||||
activeTab === tab.key &&
|
||||
'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key as AppIconType)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
<SegmentedControlItem key={tab.key} value={tab.key} className="flex-1">
|
||||
<span className="flex size-5 items-center justify-center" aria-hidden="true">
|
||||
{tab.icon}
|
||||
</span>
|
||||
<span className="p-0.5">{tab.label}</span>
|
||||
</SegmentedControlItem>
|
||||
))}
|
||||
</div>
|
||||
</SegmentedControl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'emoji' && (
|
||||
<EmojiPickerInner
|
||||
className={cn('flex-1 overflow-hidden pt-2')}
|
||||
emoji={initialEmoji?.icon}
|
||||
background={initialEmoji?.background ?? undefined}
|
||||
className={cn('flex-1 overflow-hidden', !showImageUpload && 'pt-3')}
|
||||
emoji={emoji?.emoji}
|
||||
background={emoji?.background}
|
||||
onSelect={(emoji, background) => setEmoji({ emoji, background })}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'image' && (
|
||||
<ImageInput className={cn('flex-1 overflow-hidden')} onImageInput={handleImageInput} />
|
||||
<ImageInput className="min-h-0 overflow-y-auto" onImageInput={handleImageInput} />
|
||||
)}
|
||||
|
||||
<Separator decorative className="m-0 h-[0.5px]" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t(($) => $['iconPicker.cancel'], { ns: 'app' })}
|
||||
</Button>
|
||||
{(activeTab === 'emoji' || inputImageInfo) && (
|
||||
<>
|
||||
<Separator decorative className="m-0 h-[0.5px]" />
|
||||
<div className="flex w-full shrink-0 items-center justify-center gap-2 bg-components-panel-bg-blur p-3 backdrop-blur-sm">
|
||||
{activeTab === 'emoji' ? (
|
||||
<Button
|
||||
className="min-w-0 flex-1"
|
||||
onClick={() =>
|
||||
setEmoji({
|
||||
emoji: getRandomEmoji(emoji?.emoji),
|
||||
background: getRandomEmojiBackground(emoji?.background),
|
||||
})
|
||||
}
|
||||
>
|
||||
<span className="i-ri-dice-line size-4" aria-hidden="true" />
|
||||
{t(($) => $['iconPicker.tryYourLuck'], { ns: 'app' })}
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="min-w-0 flex-1" onClick={() => onOpenChange(false)}>
|
||||
{t(($) => $['iconPicker.cancel'], { ns: 'app' })}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button variant="primary" className="w-full" loading={uploading} onClick={handleSelect}>
|
||||
{t(($) => $['iconPicker.ok'], { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
className="min-w-0 flex-1"
|
||||
disabled={activeTab === 'emoji' ? !emoji : !inputImageInfo}
|
||||
loading={uploading}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
{t(($) => $['iconPicker.ok'], { ns: 'app' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
width: 362px;
|
||||
max-height: 552px;
|
||||
box-shadow:
|
||||
0px 12px 16px -4px rgba(16, 24, 40, 0.08),
|
||||
0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
border-radius: 12px;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { memo } from 'react'
|
||||
|
||||
type BadgeProps = {
|
||||
as?: 'div' | 'span'
|
||||
className?: string
|
||||
text?: ReactNode
|
||||
children?: ReactNode
|
||||
@@ -13,6 +14,7 @@ type BadgeProps = {
|
||||
}
|
||||
|
||||
const Badge = ({
|
||||
as: Component = 'div',
|
||||
className,
|
||||
text,
|
||||
children,
|
||||
@@ -22,7 +24,7 @@ const Badge = ({
|
||||
hasRedCornerMark,
|
||||
}: BadgeProps) => {
|
||||
return (
|
||||
<div
|
||||
<Component
|
||||
className={cn(
|
||||
'relative inline-flex items-center rounded-[5px] border border-divider-deep leading-3 whitespace-nowrap text-text-tertiary',
|
||||
size === 'xs' ? 'min-w-4 justify-center px-1 py-0.5' : 'h-5 px-1.25',
|
||||
@@ -32,10 +34,10 @@ const Badge = ({
|
||||
)}
|
||||
>
|
||||
{hasRedCornerMark && (
|
||||
<div className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-xs border border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-sm"></div>
|
||||
<span className="absolute -top-0.5 -right-0.5 h-1.5 w-1.5 rounded-xs border border-components-badge-status-light-error-border-inner bg-components-badge-status-light-error-bg shadow-sm"></span>
|
||||
)}
|
||||
{children || text}
|
||||
</div>
|
||||
</Component>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Core emoji grid with search and style swatches. Use this when embedding the selector inline without a modal frame.',
|
||||
'Core emoji grid with search, recent selections, and recommendations. Use this when embedding the selector inline without a modal frame.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -26,15 +26,14 @@ const InnerDemo = () => {
|
||||
return (
|
||||
<div className="flex h-130 flex-col gap-4 rounded-xl border border-divider-subtle bg-components-panel-bg p-6 shadow-lg">
|
||||
<EmojiPickerInner
|
||||
emoji={selection?.emoji}
|
||||
onSelect={(emoji, background) => setSelection({ emoji, background })}
|
||||
className="flex-1 overflow-hidden rounded-xl border border-divider-subtle bg-white"
|
||||
/>
|
||||
<div className="rounded-lg border border-divider-subtle bg-background-default-subtle p-3 text-xs text-text-secondary">
|
||||
<div className="font-medium text-text-primary">Latest selection</div>
|
||||
<pre className="mt-1 max-h-40 overflow-auto font-mono">
|
||||
{selection
|
||||
? JSON.stringify(selection, null, 2)
|
||||
: 'Tap an emoji to set background options.'}
|
||||
{selection ? JSON.stringify(selection, null, 2) : 'Tap an emoji to select it.'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
import type { EmojiPickerListEmojiProps } from 'frimousse'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { InputGroup, InputGroupAddon } from '@langgenius/dify-ui/input-group'
|
||||
import { Separator } from '@langgenius/dify-ui/separator'
|
||||
import { EmojiPicker } from 'frimousse'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { createContext, use, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveEmoji } from '@/utils/emoji'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { backgroundColors, defaultEmojiBackground } from './constants'
|
||||
import { defaultEmojiBackground, emojiStyles, recommendedEmojis } from './constants'
|
||||
import { useRecentEmojisValue } from './storage'
|
||||
|
||||
type IEmojiPickerInnerProps = {
|
||||
emoji?: string
|
||||
@@ -17,142 +17,190 @@ type IEmojiPickerInnerProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const emojiButtonClassName =
|
||||
'flex h-8 w-full min-w-0 max-w-8 flex-1 items-center justify-center rounded-lg text-2xl leading-none hover:bg-state-base-hover focus-visible:outline-2 focus-visible:outline-state-accent-solid data-[active]:bg-state-base-hover aria-pressed:ring-[1.5px] aria-pressed:ring-components-option-card-option-selected-border aria-pressed:ring-inset'
|
||||
|
||||
const SelectedEmojiContext = createContext('')
|
||||
|
||||
function EmojiButton({ emoji, ...props }: EmojiPickerListEmojiProps) {
|
||||
const selectedEmoji = use(SelectedEmojiContext)
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
aria-pressed={selectedEmoji === emoji.emoji}
|
||||
className={emojiButtonClassName}
|
||||
>
|
||||
{emoji.emoji}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const listComponents: NonNullable<React.ComponentProps<typeof EmojiPicker.List>['components']> = {
|
||||
CategoryHeader: ({ category, ...props }) => (
|
||||
<div
|
||||
{...props}
|
||||
className="bg-components-panel-bg px-3 pb-1 system-xs-medium-uppercase text-text-primary"
|
||||
className="bg-components-panel-bg px-3.5 py-1 system-xs-medium-uppercase text-text-tertiary"
|
||||
>
|
||||
{category.label}
|
||||
</div>
|
||||
),
|
||||
Row: ({ children, ...props }) => (
|
||||
<div {...props} className="scroll-my-1 gap-1 px-3 pb-1">
|
||||
<div {...props} className="scroll-my-1 gap-px px-3 pb-px">
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Emoji: ({ emoji, ...props }) => (
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className="flex h-10 min-w-0 flex-1 items-center justify-center rounded-lg text-xl focus-visible:outline-2 focus-visible:outline-offset-2 data-[active]:bg-state-base-hover"
|
||||
>
|
||||
{emoji.emoji}
|
||||
</button>
|
||||
),
|
||||
Emoji: EmojiButton,
|
||||
}
|
||||
|
||||
function EmojiPickerInner({ emoji, background, onSelect, className }: IEmojiPickerInnerProps) {
|
||||
const { t } = useTranslation()
|
||||
const [selectedEmoji, setSelectedEmoji] = useState(() => (emoji ? resolveEmoji(emoji) : ''))
|
||||
const [selectedBackground, setSelectedBackground] = useState(background || defaultEmojiBackground)
|
||||
const [showStyleColors, setShowStyleColors] = useState(!!emoji)
|
||||
const [search, setSearch] = useState('')
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const recentEmojis = useRecentEmojisValue()
|
||||
const selectedEmoji = emoji ? resolveEmoji(emoji) : ''
|
||||
const handleEmojiSelect = (value: string) =>
|
||||
onSelect?.(value, background || defaultEmojiBackground)
|
||||
|
||||
const styleColorsLabelId = React.useId()
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setShowStyleColors(true)
|
||||
onSelect?.(emoji, selectedBackground)
|
||||
}
|
||||
|
||||
const handleBackgroundSelect = (background: string) => {
|
||||
setSelectedBackground(background)
|
||||
if (selectedEmoji) onSelect?.(selectedEmoji, background)
|
||||
}
|
||||
const renderGroup = (label: string, emojis: string[]) => (
|
||||
<section aria-label={label} className="px-3 pb-2">
|
||||
<h3 className="px-0.5 py-1 system-xs-medium-uppercase text-text-tertiary">{label}</h3>
|
||||
<div className="grid grid-cols-9 gap-px">
|
||||
{emojis.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-label={value}
|
||||
aria-pressed={selectedEmoji === value}
|
||||
className={emojiButtonClassName}
|
||||
onClick={() => handleEmojiSelect(value)}
|
||||
>
|
||||
{value}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={cn(className, 'flex min-h-0 w-full min-w-0 flex-col')}>
|
||||
<EmojiPicker.Root
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
locale="en"
|
||||
columns={8}
|
||||
emojibaseUrl={`${basePath}/emoji/emojibase-17.0.0`}
|
||||
onEmojiSelect={({ emoji }) => handleEmojiSelect(emoji)}
|
||||
>
|
||||
<div className="px-3 pb-2">
|
||||
<InputGroup>
|
||||
<EmojiPicker.Search
|
||||
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
placeholder={t(($) => $['operation.search'], { ns: 'common' })}
|
||||
className="h-8 min-w-0 flex-1 bg-transparent px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-components-input-border-hover"
|
||||
/>
|
||||
<InputGroupAddon className="ps-3 pe-2">
|
||||
<span
|
||||
className="i-heroicons-magnifying-glass size-5 text-text-quaternary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<EmojiPicker.Root
|
||||
className={cn('flex min-h-0 w-full min-w-0 flex-col', className)}
|
||||
locale="en"
|
||||
columns={9}
|
||||
emojibaseUrl={`${basePath}/emoji/emojibase-17.0.0`}
|
||||
onEmojiSelect={({ emoji }) => handleEmojiSelect(emoji)}
|
||||
onKeyUp={(event) => {
|
||||
if (
|
||||
['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)
|
||||
) {
|
||||
scrollRef.current
|
||||
?.querySelector<HTMLElement>('[frimousse-emoji][data-active]')
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="shrink-0 border-b border-divider-subtle px-3 pb-2">
|
||||
<div className="flex h-8 items-center gap-1.5 rounded-lg border border-transparent bg-components-input-bg-normal px-2 focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active">
|
||||
<span
|
||||
className="i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<EmojiPicker.Search
|
||||
aria-label={t(($) => $['iconPicker.search'], { ns: 'app' })}
|
||||
placeholder={t(($) => $['iconPicker.search'], { ns: 'app' })}
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value)
|
||||
scrollRef.current?.scrollTo({ top: 0 })
|
||||
}}
|
||||
className="h-full min-w-0 flex-1 appearance-none border-0 bg-transparent p-0 system-sm-regular text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder [&::-webkit-search-cancel-button]:appearance-none"
|
||||
/>
|
||||
</div>
|
||||
<Separator decorative className="my-3 h-[0.5px]" />
|
||||
<EmojiPicker.Viewport className="relative h-50 min-h-0 w-full overflow-x-hidden overflow-y-auto">
|
||||
<EmojiPicker.Loading className="block p-3 text-text-tertiary">
|
||||
{t(($) => $.loading, { ns: 'common' })}
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty className="block p-3 text-text-tertiary">
|
||||
{t(($) => $.noData, { ns: 'common' })}
|
||||
</EmojiPicker.Empty>
|
||||
<EmojiPicker.List components={listComponents} />
|
||||
</EmojiPicker.Viewport>
|
||||
</EmojiPicker.Root>
|
||||
|
||||
{/* Color Select */}
|
||||
<div className={cn('flex shrink-0 items-center justify-between p-3 pb-0')}>
|
||||
<p id={styleColorsLabelId} className="mb-2 system-xs-medium-uppercase text-text-primary">
|
||||
Choose Style
|
||||
</p>
|
||||
{showStyleColors ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={styleColorsLabelId}
|
||||
aria-expanded="true"
|
||||
className="i-heroicons-chevron-down size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
onClick={() => setShowStyleColors(!showStyleColors)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-labelledby={styleColorsLabelId}
|
||||
aria-expanded="false"
|
||||
className="i-heroicons-chevron-up size-4 cursor-pointer border-none bg-transparent p-0 text-text-quaternary focus-visible:outline-2 focus-visible:outline-offset-2"
|
||||
onClick={() => setShowStyleColors(!showStyleColors)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{showStyleColors && (
|
||||
<div className="grid w-full shrink-0 grid-cols-8 gap-1 px-3">
|
||||
{backgroundColors.map((color) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={color}
|
||||
aria-label={color}
|
||||
className={cn(
|
||||
'cursor-pointer focus-visible:outline-2 focus-visible:outline-offset-2',
|
||||
'border-none bg-transparent p-0',
|
||||
'ring-components-input-border-hover ring-offset-1 hover:ring-1',
|
||||
'inline-flex h-10 w-full min-w-0 items-center justify-center rounded-lg',
|
||||
color === selectedBackground ? 'ring-1 ring-components-input-border-hover' : '',
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="region"
|
||||
aria-label={t(($) => $['iconPicker.emoji'], { ns: 'app' })}
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto overscroll-contain"
|
||||
style={{ scrollbarWidth: 'none' }}
|
||||
>
|
||||
<div className="py-2">
|
||||
{!search && (
|
||||
<div className="shrink-0">
|
||||
{!!recentEmojis.length &&
|
||||
renderGroup(
|
||||
t(($) => $['iconPicker.recent'], { ns: 'app' }),
|
||||
recentEmojis,
|
||||
)}
|
||||
onClick={() => {
|
||||
handleBackgroundSelect(color)
|
||||
}}
|
||||
{renderGroup(
|
||||
t(($) => $['iconPicker.recommend'], { ns: 'app' }),
|
||||
recommendedEmojis,
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<EmojiPicker.Viewport
|
||||
className="relative w-full"
|
||||
// The outer scroller owns all groups; let this viewport grow with its list.
|
||||
style={{
|
||||
contain: 'none',
|
||||
containIntrinsicSize: 'none',
|
||||
overflow: 'visible',
|
||||
scrollbarGutter: 'auto',
|
||||
willChange: 'auto',
|
||||
}}
|
||||
>
|
||||
<EmojiPicker.Loading className="block p-3 text-text-tertiary">
|
||||
{t(($) => $.loading, { ns: 'common' })}
|
||||
</EmojiPicker.Loading>
|
||||
<EmojiPicker.Empty className="block p-3 text-text-tertiary">
|
||||
{t(($) => $.noData, { ns: 'common' })}
|
||||
</EmojiPicker.Empty>
|
||||
<SelectedEmojiContext value={selectedEmoji}>
|
||||
<EmojiPicker.List components={listComponents} />
|
||||
</SelectedEmojiContext>
|
||||
</EmojiPicker.Viewport>
|
||||
</div>
|
||||
</div>
|
||||
{selectedEmoji && (
|
||||
<section
|
||||
aria-label={t(($) => $['iconPicker.chooseStyle'], { ns: 'app' })}
|
||||
className="shrink-0 border-t border-divider-subtle bg-components-panel-bg-blur px-3 pt-2 pb-3 backdrop-blur-sm"
|
||||
>
|
||||
<h3 className="px-0.5 py-1 system-xs-semibold-uppercase text-text-primary">
|
||||
{t(($) => $['iconPicker.chooseStyle'], { ns: 'app' })}
|
||||
</h3>
|
||||
<div className="grid grid-cols-6 justify-items-center gap-0.75 pt-0.5">
|
||||
{emojiStyles.map((style) => (
|
||||
<button
|
||||
key={style.background}
|
||||
type="button"
|
||||
aria-label={style.background}
|
||||
aria-pressed={
|
||||
(background || defaultEmojiBackground).toUpperCase() === style.background
|
||||
}
|
||||
className={cn(
|
||||
'flex aspect-square w-full max-w-11.5 items-center justify-center rounded-xl p-0.75 ring-inset focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-accent-solid',
|
||||
(background || defaultEmojiBackground).toUpperCase() === style.background && [
|
||||
'ring-[1.5px]',
|
||||
style.selectedClassName,
|
||||
],
|
||||
)}
|
||||
onClick={() => onSelect?.(selectedEmoji, style.background)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-8 max-w-full items-center justify-center rounded-lg p-1 text-xl',
|
||||
'flex aspect-square w-full items-center justify-center rounded-[10px] border-[0.5px] border-divider-regular text-2xl leading-none',
|
||||
style.backgroundClassName,
|
||||
)}
|
||||
style={{ background: color }}
|
||||
>
|
||||
{selectedEmoji}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</EmojiPicker.Root>
|
||||
)
|
||||
}
|
||||
export default EmojiPickerInner
|
||||
@@ -1,14 +1,13 @@
|
||||
import { page, userEvent } from 'vite-plus/test/browser'
|
||||
import { render } from 'vitest-browser-react'
|
||||
import AppIconPicker from '../../app-icon-picker'
|
||||
import { backgroundColors } from '../constants'
|
||||
import EmojiPickerInner from '../Inner'
|
||||
|
||||
// Real layout is needed to verify virtualized rows remain reachable using the keyboard.
|
||||
it('selects a searched emoji with the keyboard and keeps style controls usable', async () => {
|
||||
// Real layout verifies keyboard navigation scrolls the shared list to the active row.
|
||||
it('selects a searched emoji and scrolls to later rows with the keyboard', async () => {
|
||||
const onSelect = vi.fn()
|
||||
await render(
|
||||
<div style={{ width: 380 }}>
|
||||
<div style={{ width: 322, height: 480, display: 'flex' }}>
|
||||
<EmojiPickerInner onSelect={onSelect} />
|
||||
</div>,
|
||||
)
|
||||
@@ -16,9 +15,7 @@ it('selects a searched emoji with the keyboard and keeps style controls usable',
|
||||
await search.fill('rabbit face')
|
||||
await expect.element(page.getByRole('gridcell', { name: 'Rabbit face' })).toBeVisible()
|
||||
await userEvent.keyboard('{Enter}')
|
||||
await expect.poll(() => onSelect.mock.lastCall).toEqual(['🐰', '#FFEAD5'])
|
||||
await page.getByRole('button', { name: '#E4FBCC' }).click()
|
||||
expect(onSelect).toHaveBeenLastCalledWith('🐰', '#E4FBCC')
|
||||
await expect.poll(() => onSelect.mock.lastCall).toEqual(['🐰', '#FEF3F2'])
|
||||
await search.fill('')
|
||||
await expect
|
||||
.element(page.getByRole('gridcell', { name: 'Grinning face', exact: true }))
|
||||
@@ -27,57 +24,48 @@ it('selects a searched emoji with the keyboard and keeps style controls usable',
|
||||
await userEvent.keyboard(
|
||||
'{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}{Enter}',
|
||||
)
|
||||
expect(onSelect).toHaveBeenCalledTimes(3)
|
||||
expect(onSelect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
// The production dialog is narrower than the old fixed-width eight-column grid.
|
||||
// Verify every edge option is visible and clickable, rather than hiding overflow.
|
||||
it('fits emoji and style options inside the app icon dialog', async () => {
|
||||
const originalFontSize = document.documentElement.style.fontSize
|
||||
document.documentElement.style.fontSize = '16px'
|
||||
onTestFinished(() => {
|
||||
document.documentElement.style.fontSize = originalFontSize
|
||||
})
|
||||
// Guard against the scrollbar clipping the ninth column or the footer obscuring selectable rows.
|
||||
it('keeps the rightmost emoji clickable inside the app icon dialog', async () => {
|
||||
const onSelect = vi.fn()
|
||||
await render(
|
||||
<AppIconPicker
|
||||
open
|
||||
onOpenChange={() => {}}
|
||||
onSelect={onSelect}
|
||||
initialEmoji={{ icon: 'robot_face' }}
|
||||
/>,
|
||||
)
|
||||
const dialog = page.getByRole('dialog')
|
||||
const firstEmoji = page.getByRole('gridcell', { name: 'Grinning face', exact: true })
|
||||
await expect.element(firstEmoji).toBeVisible()
|
||||
const bounds = dialog.element().getBoundingClientRect()
|
||||
const options = [
|
||||
...dialog.element().querySelectorAll('[role="row"] [role="gridcell"]'),
|
||||
...backgroundColors.map((color) =>
|
||||
page.getByRole('button', { name: color, exact: true }).element(),
|
||||
),
|
||||
]
|
||||
for (const option of options) {
|
||||
const rect = option.getBoundingClientRect()
|
||||
expect(rect.left).toBeGreaterThanOrEqual(bounds.left)
|
||||
expect(rect.right).toBeLessThanOrEqual(bounds.right)
|
||||
}
|
||||
const footerTop = page
|
||||
.getByRole('button', { name: 'app.iconPicker.ok', exact: true })
|
||||
.element()
|
||||
.getBoundingClientRect().top
|
||||
for (const color of backgroundColors) {
|
||||
expect(
|
||||
page.getByRole('button', { name: color, exact: true }).element().getBoundingClientRect()
|
||||
.bottom,
|
||||
).toBeLessThanOrEqual(footerTop)
|
||||
}
|
||||
await page.getByRole('gridcell', { name: 'Face with tears of joy', exact: true }).click()
|
||||
await page.getByRole('button', { name: backgroundColors.at(-1)!, exact: true }).click()
|
||||
await render(<AppIconPicker open onOpenChange={() => {}} onSelect={onSelect} />)
|
||||
const option = page.getByRole('gridcell', { name: 'Slightly smiling face', exact: true })
|
||||
await expect.element(option).toBeVisible()
|
||||
const dialogBounds = page.getByRole('dialog').element().getBoundingClientRect()
|
||||
const optionBounds = option.element().getBoundingClientRect()
|
||||
expect(optionBounds.right).toBeLessThanOrEqual(dialogBounds.right)
|
||||
await option.click()
|
||||
await page.getByRole('button', { name: 'app.iconPicker.ok', exact: true }).click()
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
type: 'emoji',
|
||||
icon: '😂',
|
||||
background: backgroundColors.at(-1),
|
||||
})
|
||||
expect(onSelect).toHaveBeenCalledWith({ type: 'emoji', icon: '🙂', background: '#FEF3F2' })
|
||||
})
|
||||
|
||||
// The old layout pinned recommendations while only the category grid scrolled.
|
||||
it('scrolls recommendations and categories together while keeping search and actions fixed', async () => {
|
||||
await render(<AppIconPicker open onOpenChange={() => {}} initialEmoji={{ icon: '🤖' }} />)
|
||||
await expect
|
||||
.element(page.getByRole('gridcell', { name: 'Grinning face', exact: true }))
|
||||
.toBeVisible()
|
||||
const scroller = page.getByRole('region', { name: 'app.iconPicker.emoji', exact: true }).element()
|
||||
const recommendation = page.getByRole('region', { name: 'app.iconPicker.recommend' }).element()
|
||||
const search = page.getByRole('searchbox').element()
|
||||
const confirm = page.getByRole('button', { name: 'app.iconPicker.ok', exact: true }).element()
|
||||
const styles = page.getByRole('region', { name: 'app.iconPicker.chooseStyle' }).element()
|
||||
const stylesTop = styles.getBoundingClientRect().top
|
||||
const searchTop = search.getBoundingClientRect().top
|
||||
const confirmTop = confirm.getBoundingClientRect().top
|
||||
scroller.scrollTo({ top: 400 })
|
||||
await expect
|
||||
.poll(() => recommendation.getBoundingClientRect().bottom)
|
||||
.toBeLessThan(scroller.getBoundingClientRect().top)
|
||||
const category = page.getByText('Smileys & emotion', { exact: true }).element()
|
||||
expect(category.getBoundingClientRect().top).toBe(scroller.getBoundingClientRect().top)
|
||||
expect(styles.getBoundingClientRect().top).toBe(stylesTop)
|
||||
await page.getByRole('button', { name: '#F0F2F5', exact: true }).click()
|
||||
await expect
|
||||
.element(page.getByRole('button', { name: '#F0F2F5', exact: true }))
|
||||
.toHaveAttribute('aria-pressed', 'true')
|
||||
expect(search.getBoundingClientRect().top).toBe(searchTop)
|
||||
expect(confirm.getBoundingClientRect().top).toBe(confirmTop)
|
||||
})
|
||||
@@ -1,55 +1,36 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockEmojiData } from '@/test/emoji-picker'
|
||||
import EmojiPickerInner from '../Inner'
|
||||
|
||||
vi.mock('@/utils/var', () => ({ basePath: '/console' }))
|
||||
|
||||
mockEmojiData()
|
||||
|
||||
describe('EmojiPickerInner', () => {
|
||||
it('selects Unicode from self-hosted data and preserves the chosen background', async () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<EmojiPickerInner onSelect={onSelect} />)
|
||||
fireEvent.click(await screen.findByRole('gridcell', { name: 'Grinning face' }))
|
||||
expect(onSelect).toHaveBeenLastCalledWith('😀', '#FFEAD5')
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
expect(onSelect).toHaveBeenLastCalledWith('😀', '#E4FBCC')
|
||||
expect(fetch).toHaveBeenCalledTimes(2)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/console/emoji/emojibase-17.0.0/en/data.json',
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/console/emoji/emojibase-17.0.0/en/messages.json',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
it('selects Unicode from self-hosted data while preserving the existing background', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
render(<EmojiPickerInner background="#E4FBCC" onSelect={onSelect} />)
|
||||
await user.click(await screen.findByRole('gridcell', { name: 'Grinning face' }))
|
||||
expect(onSelect).toHaveBeenCalledWith('😀', '#E4FBCC')
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
'/console/emoji/emojibase-17.0.0/en/data.json',
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('filters search, selects a result, shows empty state and restores categories on clear', async () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<EmojiPickerInner onSelect={onSelect} />)
|
||||
await screen.findByRole('gridcell', { name: 'Grinning face' })
|
||||
const search = screen.getByRole('searchbox')
|
||||
fireEvent.change(search, { target: { value: 'rabbit' } })
|
||||
fireEvent.click(await screen.findByRole('gridcell', { name: 'Rabbit face' }))
|
||||
expect(onSelect).toHaveBeenLastCalledWith('🐰', '#FFEAD5')
|
||||
fireEvent.change(search, { target: { value: 'zzzz-no-emoji' } })
|
||||
await screen.findByText('common.noData')
|
||||
fireEvent.change(search, { target: { value: '' } })
|
||||
await screen.findByRole('gridcell', { name: 'Grinning face' })
|
||||
})
|
||||
|
||||
it('resolves a saved ID in style previews and only emits after a user action', async () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<EmojiPickerInner emoji="rabbit" onSelect={onSelect} />)
|
||||
const color = screen.getByRole('button', { name: '#E4FBCC' })
|
||||
expect(color).toHaveTextContent('🐰')
|
||||
expect(onSelect).not.toHaveBeenCalled()
|
||||
fireEvent.click(color)
|
||||
expect(onSelect).toHaveBeenCalledWith('🐰', '#E4FBCC')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose Style' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: '#E4FBCC' })).not.toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
it('filters search, selects a result, shows empty state and restores recommendations on clear', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
render(<EmojiPickerInner onSelect={onSelect} />)
|
||||
const search = screen.getByRole('searchbox')
|
||||
await user.type(search, 'rabbit')
|
||||
await user.click(await screen.findByRole('gridcell', { name: 'Rabbit face' }))
|
||||
expect(onSelect).toHaveBeenLastCalledWith('🐰', '#FEF3F2')
|
||||
expect(screen.queryByRole('region', { name: 'app.iconPicker.recommend' })).not.toBeInTheDocument()
|
||||
await user.clear(search)
|
||||
await user.type(search, 'zzzz-no-emoji')
|
||||
await screen.findByText('common.noData')
|
||||
await user.clear(search)
|
||||
expect(screen.getByRole('region', { name: 'app.iconPicker.recommend' })).toBeInTheDocument()
|
||||
await screen.findByRole('gridcell', { name: 'Grinning face' })
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { mockEmojiData } from '@/test/emoji-picker'
|
||||
import EmojiPicker from '../index'
|
||||
|
||||
@@ -23,8 +24,8 @@ describe('EmojiPicker', () => {
|
||||
render(<EmojiPicker open onOpenChange={mockOnOpenChange} />)
|
||||
})
|
||||
expect(screen.getByRole('dialog', { name: /Emoji/i }))!.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/Cancel/i))!.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search'))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/tryYourLuck/i))!.toBeInTheDocument()
|
||||
expect(screen.getByText(/OK/i))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -55,16 +56,20 @@ describe('EmojiPicker', () => {
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.any(String), expect.any(String))
|
||||
})
|
||||
|
||||
it('closes when Cancel is clicked', async () => {
|
||||
await act(async () => {
|
||||
render(<EmojiPicker open onOpenChange={mockOnOpenChange} />)
|
||||
})
|
||||
|
||||
const cancelButton = screen.getByText(/Cancel/i)
|
||||
await act(async () => {
|
||||
fireEvent.click(cancelButton)
|
||||
})
|
||||
|
||||
it('randomizes emoji and background without submitting until confirmed', async () => {
|
||||
vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
const user = userEvent.setup()
|
||||
render(<EmojiPicker open onOpenChange={mockOnOpenChange} onSelect={mockOnSelect} />)
|
||||
await user.click(screen.getByRole('button', { name: /tryYourLuck/i }))
|
||||
expect(mockOnSelect).not.toHaveBeenCalled()
|
||||
expect(mockOnOpenChange).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: /ok/i })).toBeEnabled()
|
||||
await user.click(screen.getByRole('button', { name: /ok/i }))
|
||||
expect(screen.getByRole('button', { name: '#FFF1F3' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true',
|
||||
)
|
||||
expect(mockOnSelect).toHaveBeenCalledWith('😃', '#FFF1F3')
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,23 +1,102 @@
|
||||
export const backgroundColors = [
|
||||
'#FFEAD5',
|
||||
'#E4FBCC',
|
||||
'#D3F8DF',
|
||||
'#E0F2FE',
|
||||
|
||||
'#E0EAFF',
|
||||
'#EFF1F5',
|
||||
'#FBE8FF',
|
||||
'#FCE7F6',
|
||||
|
||||
'#FEF7C3',
|
||||
'#E6F4D7',
|
||||
'#D5F5F6',
|
||||
'#D1E9FF',
|
||||
|
||||
'#D1E0FF',
|
||||
'#D5D9EB',
|
||||
'#ECE9FE',
|
||||
'#FFE4E8',
|
||||
export const emojiStyles = [
|
||||
{
|
||||
background: '#FEF3F2',
|
||||
backgroundClassName: 'bg-components-icon-bg-red-soft',
|
||||
selectedClassName: 'ring-util-colors-red-red-400',
|
||||
},
|
||||
{
|
||||
background: '#FFF1F3',
|
||||
backgroundClassName: 'bg-components-icon-bg-rose-soft',
|
||||
selectedClassName: 'ring-util-colors-rose-rose-400',
|
||||
},
|
||||
{
|
||||
background: '#FDF2FA',
|
||||
backgroundClassName: 'bg-components-icon-bg-pink-soft',
|
||||
selectedClassName: 'ring-util-colors-pink-pink-400',
|
||||
},
|
||||
{
|
||||
background: '#FFF4ED',
|
||||
backgroundClassName: 'bg-components-icon-bg-orange-dark-soft',
|
||||
selectedClassName: 'ring-util-colors-orange-dark-orange-dark-400',
|
||||
},
|
||||
{
|
||||
background: '#FEFBE8',
|
||||
backgroundClassName: 'bg-components-icon-bg-yellow-soft',
|
||||
selectedClassName: 'ring-util-colors-yellow-yellow-400',
|
||||
},
|
||||
{
|
||||
background: '#F3FEE7',
|
||||
backgroundClassName: 'bg-components-icon-bg-green-soft',
|
||||
selectedClassName: 'ring-util-colors-green-green-400',
|
||||
},
|
||||
{
|
||||
background: '#F0FDF9',
|
||||
backgroundClassName: 'bg-components-icon-bg-teal-soft',
|
||||
selectedClassName: 'ring-util-colors-teal-teal-400',
|
||||
},
|
||||
{
|
||||
background: '#F0F9FF',
|
||||
backgroundClassName: 'bg-components-icon-bg-blue-light-soft',
|
||||
selectedClassName: 'ring-util-colors-blue-light-blue-light-400',
|
||||
},
|
||||
{
|
||||
background: '#E9F0FF',
|
||||
backgroundClassName: 'bg-components-icon-bg-blue-soft',
|
||||
selectedClassName: 'ring-util-colors-blue-blue-400',
|
||||
},
|
||||
{
|
||||
background: '#F5F3FF',
|
||||
backgroundClassName: 'bg-components-icon-bg-violet-soft',
|
||||
selectedClassName: 'ring-util-colors-violet-violet-400',
|
||||
},
|
||||
{
|
||||
background: '#EEF4FF',
|
||||
backgroundClassName: 'bg-components-icon-bg-indigo-soft',
|
||||
selectedClassName: 'ring-util-colors-indigo-indigo-400',
|
||||
},
|
||||
{
|
||||
background: '#F0F2F5',
|
||||
backgroundClassName: 'bg-components-icon-bg-midnight-soft',
|
||||
selectedClassName: 'ring-util-colors-midnight-midnight-400',
|
||||
},
|
||||
]
|
||||
|
||||
export const defaultEmojiBackground = backgroundColors[0]!
|
||||
export const defaultEmojiBackground = emojiStyles[0]!.background
|
||||
|
||||
export const recommendedEmojis = [
|
||||
'😃',
|
||||
'😆',
|
||||
'🥹',
|
||||
'😅',
|
||||
'😂',
|
||||
'🤣',
|
||||
'🥲',
|
||||
'☺️',
|
||||
'😊',
|
||||
'😇',
|
||||
'🙂',
|
||||
'😍',
|
||||
'😘',
|
||||
'😙',
|
||||
'😝',
|
||||
'🤪',
|
||||
'🤓',
|
||||
'😎',
|
||||
'🥸',
|
||||
'🤩',
|
||||
'🥳',
|
||||
'😤',
|
||||
'🤯',
|
||||
'🥵',
|
||||
'🥶',
|
||||
]
|
||||
|
||||
export function getRandomEmoji(current?: string) {
|
||||
const candidates = recommendedEmojis.filter((emoji) => emoji !== current)
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]!
|
||||
}
|
||||
|
||||
export function getRandomEmojiBackground(current = defaultEmojiBackground) {
|
||||
const candidates = emojiStyles.filter((style) => style.background !== current.toUpperCase())
|
||||
return candidates[Math.floor(Math.random() * candidates.length)]!.background
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const meta = {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Modal-based emoji selector that powers the icon picker. Supports search, background swatches, and confirmation callbacks.',
|
||||
'Modal-based emoji selector that powers the icon picker. Supports search, recent selections, random selection, and confirmation callbacks.',
|
||||
},
|
||||
},
|
||||
nextjs: {
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Separator } from '@langgenius/dify-ui/separator'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { defaultEmojiBackground, getRandomEmoji, getRandomEmojiBackground } from './constants'
|
||||
import EmojiPickerInner from './Inner'
|
||||
import { addRecentEmoji, useRecentEmojis } from './storage'
|
||||
|
||||
type EmojiPickerProps = {
|
||||
open: boolean
|
||||
@@ -33,13 +35,13 @@ type EmojiPickerContentProps = {
|
||||
function EmojiPickerContent({ className, onOpenChange, onSelect }: EmojiPickerContentProps) {
|
||||
const { t } = useTranslation()
|
||||
const [selectedEmoji, setSelectedEmoji] = useState('')
|
||||
const [selectedBackground, setSelectedBackground] = useState<string>()
|
||||
const [selectedBackground, setSelectedBackground] = useState(defaultEmojiBackground)
|
||||
const [, setRecentEmojis] = useRecentEmojis()
|
||||
|
||||
return (
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'max-h-none w-full overflow-hidden! text-left align-middle',
|
||||
'flex max-h-138 flex-col rounded-xl border-[0.5px] border-divider-subtle p-0 shadow-xl',
|
||||
'flex h-[min(480px,calc(100dvh-2rem))] max-h-none w-80.5 flex-col overflow-hidden p-0 text-left',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -48,23 +50,33 @@ function EmojiPickerContent({ className, onOpenChange, onSelect }: EmojiPickerCo
|
||||
</DialogTitle>
|
||||
|
||||
<EmojiPickerInner
|
||||
className="pt-3"
|
||||
className="flex-1 overflow-hidden pt-3"
|
||||
emoji={selectedEmoji}
|
||||
background={selectedBackground}
|
||||
onSelect={(emoji, background) => {
|
||||
setSelectedEmoji(emoji)
|
||||
setSelectedBackground(background)
|
||||
}}
|
||||
/>
|
||||
<Separator decorative className="mt-3 mb-0 h-[0.5px]" />
|
||||
<div className="flex w-full items-center justify-center gap-2 p-3">
|
||||
<Button className="w-full" onClick={() => onOpenChange(false)}>
|
||||
{t(($) => $['iconPicker.cancel'], { ns: 'app' })}
|
||||
<Separator decorative className="m-0 h-[0.5px]" />
|
||||
<div className="flex w-full shrink-0 items-center justify-center gap-2 bg-components-panel-bg-blur p-3 backdrop-blur-sm">
|
||||
<Button
|
||||
className="min-w-0 flex-1"
|
||||
onClick={() => {
|
||||
setSelectedEmoji(getRandomEmoji(selectedEmoji))
|
||||
setSelectedBackground(getRandomEmojiBackground(selectedBackground))
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-dice-line size-4" aria-hidden="true" />
|
||||
{t(($) => $['iconPicker.tryYourLuck'], { ns: 'app' })}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={selectedEmoji === '' || !selectedBackground}
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
className="min-w-0 flex-1"
|
||||
onClick={() => {
|
||||
onSelect?.(selectedEmoji, selectedBackground!)
|
||||
setRecentEmojis((recent) => addRecentEmoji(recent, selectedEmoji))
|
||||
onSelect?.(selectedEmoji, selectedBackground)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createLocalStorageState } from 'foxact/create-local-storage-state'
|
||||
|
||||
export const [useRecentEmojis, useRecentEmojisValue] = createLocalStorageState<string[]>(
|
||||
'dify-icon-picker-recent-emojis',
|
||||
[],
|
||||
)
|
||||
|
||||
export function addRecentEmoji(recent: string[] | null, emoji: string) {
|
||||
return [emoji, ...(recent ?? []).filter((item) => item !== emoji)].slice(0, 9)
|
||||
}
|
||||
+22
-20
@@ -283,7 +283,7 @@ describe('EditPipelineInfo', () => {
|
||||
|
||||
// Open icon picker
|
||||
fireEvent.click(getIconButton())
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save correct icon_info when starting with image icon type', async () => {
|
||||
@@ -347,13 +347,13 @@ describe('EditPipelineInfo', () => {
|
||||
|
||||
// Open picker
|
||||
fireEvent.click(getIconButton())
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
|
||||
// Close without selection - should revert to original image icon
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
|
||||
await userEvent.setup().keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('EditPipelineInfo', () => {
|
||||
const emojiButton = await screen.findByRole('gridcell', { name: 'Grinning face' })
|
||||
expect(emojiButton).toBeTruthy()
|
||||
fireEvent.click(emojiButton!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
@@ -386,7 +386,7 @@ describe('EditPipelineInfo', () => {
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
icon: expect.any(String),
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
@@ -398,9 +398,10 @@ describe('EditPipelineInfo', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
|
||||
fireEvent.click(getIconButton())
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: /iconPicker\.image/ }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /iconPicker\.image/ })).toBeChecked()
|
||||
expect(screen.queryByRole('button', { name: /iconPicker\.ok/ })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -408,25 +409,25 @@ describe('EditPipelineInfo', () => {
|
||||
describe('AppIconPicker', () => {
|
||||
it('should not show picker initially', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open picker when icon is clicked', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
fireEvent.click(getIconButton())
|
||||
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close picker and update icon when emoji style is selected', async () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
fireEvent.click(getIconButton())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -434,19 +435,20 @@ describe('EditPipelineInfo', () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
fireEvent.click(getIconButton())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.image/ }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: /iconPicker\.image/ }))
|
||||
|
||||
expect(screen.getByRole('button', { name: /iconPicker\.ok/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: /iconPicker\.image/ })).toBeChecked()
|
||||
expect(screen.queryByRole('button', { name: /iconPicker\.ok/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should revert icon when picker is closed without selection', async () => {
|
||||
render(<EditPipelineInfo {...defaultProps} />)
|
||||
fireEvent.click(getIconButton())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
|
||||
await userEvent.setup().keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -460,7 +462,7 @@ describe('EditPipelineInfo', () => {
|
||||
|
||||
// Open picker and select new emoji
|
||||
fireEvent.click(getIconButton())
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
@@ -472,7 +474,7 @@ describe('EditPipelineInfo', () => {
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
icon: '📊',
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
@@ -492,7 +494,7 @@ describe('EditPipelineInfo', () => {
|
||||
const emojiButton = await screen.findByRole('gridcell', { name: 'Grinning face' })
|
||||
expect(emojiButton).toBeTruthy()
|
||||
fireEvent.click(emojiButton!)
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
const saveButton = screen.getByText(/operation\.save/i)
|
||||
@@ -504,7 +506,7 @@ describe('EditPipelineInfo', () => {
|
||||
icon_info: expect.objectContaining({
|
||||
icon_type: 'emoji',
|
||||
icon: expect.any(String),
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
|
||||
@@ -9,19 +9,22 @@ vi.mock('../card', () => ({
|
||||
}))
|
||||
|
||||
describe('ApiAccess', () => {
|
||||
it('opens the API access details', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ApiAccess expand apiEnabled />)
|
||||
it.each([true, false])(
|
||||
'opens named API access details by keyboard when expanded=%s',
|
||||
async (expand) => {
|
||||
const user = userEvent.setup()
|
||||
render(<ApiAccess expand={expand} apiEnabled />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: 'common.appMenus.apiAccess' })
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
expect(trigger.firstElementChild).toHaveClass('hover:bg-state-base-hover')
|
||||
const trigger = screen.getByRole('button', { name: 'common.appMenus.apiAccess' })
|
||||
expect(trigger).not.toHaveAttribute('data-popup-open')
|
||||
|
||||
await user.click(trigger)
|
||||
await user.tab()
|
||||
expect(trigger).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(trigger).toHaveAttribute('data-popup-open', '')
|
||||
expect(trigger.firstElementChild).toHaveClass('bg-state-base-hover')
|
||||
expect(trigger).toHaveAttribute('data-popup-open', '')
|
||||
|
||||
expect(screen.getByText('API enabled')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByText('API enabled')).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -24,7 +24,11 @@ const ApiAccess = ({ expand, apiEnabled }: ApiAccessProps) => {
|
||||
<button
|
||||
{...props}
|
||||
type="button"
|
||||
className={cn('w-full border-none bg-transparent p-0 text-left', props.className)}
|
||||
aria-label={!expand ? t(($) => $['appMenus.apiAccess'], { ns: 'common' }) : undefined}
|
||||
className={cn(
|
||||
'nokey w-full rounded-lg border-none bg-transparent p-0 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-state-accent-solid',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -300,7 +300,7 @@ describe('CreateAppModal', () => {
|
||||
})
|
||||
|
||||
describe('App Icon Picker', () => {
|
||||
it('should open and close the picker when cancel is clicked', async () => {
|
||||
it('should open and close the picker when Escape is pressed', async () => {
|
||||
await setup({
|
||||
appIconType: 'image',
|
||||
appIcon: 'file-123',
|
||||
@@ -310,14 +310,14 @@ describe('CreateAppModal', () => {
|
||||
const pickerDialog = openAppIconPicker()
|
||||
|
||||
expect(
|
||||
within(pickerDialog).getByRole('button', { name: 'app.iconPicker.cancel' }),
|
||||
within(pickerDialog).getByRole('button', { name: 'app.iconPicker.tryYourLuck' }),
|
||||
)!.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(within(pickerDialog).getByRole('button', { name: 'app.iconPicker.cancel' }))
|
||||
await userEvent.setup().keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'app.iconPicker.cancel' }),
|
||||
screen.queryByRole('dialog', { name: 'app.iconPicker.emoji' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -343,7 +343,7 @@ describe('CreateAppModal', () => {
|
||||
expect(payload).toMatchObject({
|
||||
icon_type: 'emoji',
|
||||
icon: '😀',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_background: '#FEF3F2',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -358,7 +358,7 @@ describe('CreateAppModal', () => {
|
||||
|
||||
const pickerDialog = openAppIconPicker()
|
||||
|
||||
fireEvent.click(within(pickerDialog).getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(within(pickerDialog).getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(within(pickerDialog).getByRole('button', { name: 'app.iconPicker.ok' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ }))
|
||||
@@ -371,7 +371,7 @@ describe('CreateAppModal', () => {
|
||||
expect(payload).toMatchObject({
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#E4FBCC',
|
||||
icon_background: '#F3FEE7',
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getDeclaredRouteNamespaces, getRouteNamespaces } from '@/i18n/route-namespaces'
|
||||
import { getRouteNamespaces } from '@/i18n/route-namespaces'
|
||||
import { getLocaleOnServer, getResources } from '@/i18n/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { basePath } from '@/utils/var'
|
||||
@@ -8,11 +8,7 @@ export async function I18nServerProvider({ children }: { children: React.ReactNo
|
||||
const locale = await getLocaleOnServer()
|
||||
const pathname = (await headers()).get('x-dify-pathname')
|
||||
const requiredNamespaces = getRouteNamespaces(pathname, basePath)
|
||||
const resource = await getResources(
|
||||
locale,
|
||||
requiredNamespaces,
|
||||
getDeclaredRouteNamespaces(pathname, basePath) !== undefined,
|
||||
)
|
||||
const resource = await getResources(locale, requiredNamespaces, true)
|
||||
|
||||
return (
|
||||
<I18nClientProvider locale={locale} resource={resource}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { Resource } from 'i18next'
|
||||
import type { Locale } from '@/i18n'
|
||||
import type { Namespace } from '@/i18n/resources'
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { I18nextProvider, useTranslation } from 'react-i18next'
|
||||
import { createI18nextInstance } from '@/i18n/client'
|
||||
import { getRouteNamespaces } from '@/i18n/route-namespaces'
|
||||
@@ -24,14 +24,12 @@ export function I18nClientProvider({
|
||||
const defaultNamespace = requiredNamespaces.includes('app') ? 'app' : 'common'
|
||||
return (
|
||||
<I18nextProvider i18n={i18n} defaultNS={defaultNamespace}>
|
||||
<Suspense fallback={null}>
|
||||
<RouteTranslations
|
||||
key={requiredNamespaces.join(':')}
|
||||
i18n={i18n}
|
||||
requiredNamespaces={requiredNamespaces}
|
||||
/>
|
||||
{children}
|
||||
</Suspense>
|
||||
<RouteTranslations
|
||||
key={requiredNamespaces.join(':')}
|
||||
i18n={i18n}
|
||||
requiredNamespaces={requiredNamespaces}
|
||||
/>
|
||||
{children}
|
||||
</I18nextProvider>
|
||||
)
|
||||
}
|
||||
|
||||
+9
-9
@@ -158,22 +158,22 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
it('should show icon picker when app icon clicked', async () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(getIconButton())
|
||||
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update icon when emoji style is selected', async () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(getIconButton())
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -181,19 +181,19 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(getIconButton())
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
|
||||
expect(screen.getByPlaceholderText('common.operation.search')).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close icon picker when cancel is clicked', async () => {
|
||||
it('should close icon picker when Escape is pressed', async () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
fireEvent.click(getIconButton())
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
|
||||
await userEvent.setup().keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,110 +1,33 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import PublishToast from '../publish-toast'
|
||||
|
||||
let mockPublishedAt = 0
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: (selector: (state: Record<string, unknown>) => unknown) => {
|
||||
return selector({ publishedAt: mockPublishedAt })
|
||||
},
|
||||
useStore: (selector: (state: { publishedAt: number }) => unknown) =>
|
||||
selector({ publishedAt: mockPublishedAt }),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('PublishToast', () => {
|
||||
beforeEach(() => {
|
||||
mockPublishedAt = 0
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render when publishedAt is 0', () => {
|
||||
mockPublishedAt = 0
|
||||
render(<PublishToast />)
|
||||
|
||||
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render toast title', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render toast description', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
expect(screen.getByText('pipeline.publishToast.desc')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render when publishedAt is set', () => {
|
||||
mockPublishedAt = Date.now()
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
expect(container.firstChild).toBeNull()
|
||||
})
|
||||
|
||||
it('should render info icon', () => {
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
const iconContainer = container.querySelector('.text-text-accent')
|
||||
expect(iconContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render close button', () => {
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
const closeButton = container.querySelector('.cursor-pointer')
|
||||
expect(closeButton).toBeInTheDocument()
|
||||
})
|
||||
it('does not show the unpublished notice for a published pipeline', () => {
|
||||
mockPublishedAt = 1
|
||||
render(<PublishToast />)
|
||||
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
describe('user interactions', () => {
|
||||
it('should hide toast when close button is clicked', () => {
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
const closeButton = container.querySelector('.cursor-pointer')
|
||||
expect(screen.getByText('pipeline.publishToast.title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should remain hidden after close button is clicked', () => {
|
||||
const { container, rerender } = render(<PublishToast />)
|
||||
|
||||
const closeButton = container.querySelector('.cursor-pointer')
|
||||
fireEvent.click(closeButton!)
|
||||
|
||||
rerender(<PublishToast />)
|
||||
|
||||
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('styling', () => {
|
||||
it('should have gradient overlay', () => {
|
||||
const { container } = render(<PublishToast />)
|
||||
|
||||
const gradientOverlay = container.querySelector('.bg-linear-to-r')
|
||||
expect(gradientOverlay).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have correct toast width', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.w-105')
|
||||
expect(toastContainer).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should have rounded-sm border', () => {
|
||||
render(<PublishToast />)
|
||||
|
||||
const toastContainer = screen.getByText('pipeline.publishToast.title').closest('.rounded-xl')
|
||||
expect(toastContainer).toBeInTheDocument()
|
||||
})
|
||||
it('allows keyboard users to dismiss the notice and keeps it dismissed on rerender', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(<PublishToast />)
|
||||
expect(screen.getByText('pipeline.publishToast.desc')).toBeInTheDocument()
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.close' })).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
|
||||
rerender(<PublishToast />)
|
||||
expect(screen.queryByText('pipeline.publishToast.title')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+12
-10
@@ -1,5 +1,6 @@
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import FieldItem from '../field-item'
|
||||
|
||||
@@ -35,10 +36,11 @@ describe('FieldItem', () => {
|
||||
expect(screen.getByText('workflow.nodes.start.required'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show edit and delete controls on hover and trigger both callbacks', () => {
|
||||
it('supports editing and deleting using the keyboard without hovering the row', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClickEdit = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const { container } = render(
|
||||
render(
|
||||
<FieldItem
|
||||
payload={createInputVar({ variable: 'custom_field' })}
|
||||
index={2}
|
||||
@@ -47,10 +49,12 @@ describe('FieldItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(container.firstChild!)
|
||||
const buttons = screen.getAllByRole('button')
|
||||
fireEvent.click(buttons[0]!)
|
||||
fireEvent.click(buttons[1]!)
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.edit' })).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.remove' })).toHaveFocus()
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(onClickEdit).toHaveBeenCalledWith('custom_field')
|
||||
expect(onRemove).toHaveBeenCalledWith(2)
|
||||
@@ -59,7 +63,7 @@ describe('FieldItem', () => {
|
||||
it('should keep the row readonly when readonly is enabled', () => {
|
||||
const onClickEdit = vi.fn()
|
||||
const onRemove = vi.fn()
|
||||
const { container } = render(
|
||||
render(
|
||||
<FieldItem
|
||||
readonly
|
||||
payload={createInputVar()}
|
||||
@@ -69,8 +73,6 @@ describe('FieldItem', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.mouseEnter(container.firstChild!)
|
||||
|
||||
expect(screen.queryAllByRole('button')).toHaveLength(0)
|
||||
expect(onClickEdit).not.toHaveBeenCalled()
|
||||
expect(onRemove).not.toHaveBeenCalled()
|
||||
|
||||
+39
@@ -1,6 +1,8 @@
|
||||
import type { SortableItem } from '../types'
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import FieldListContainer from '../field-list-container'
|
||||
|
||||
@@ -42,6 +44,43 @@ describe('FieldListContainer', () => {
|
||||
expect(onListSortChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('moves fields in both directions by clicking and disables movement past the ends', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onListSortChange = vi.fn()
|
||||
const EditableFields = () => {
|
||||
const [fields, setFields] = useState(() => [
|
||||
createInputVar('first'),
|
||||
createInputVar('second'),
|
||||
])
|
||||
const handleSort = (list: SortableItem[]) => {
|
||||
onListSortChange(list)
|
||||
setFields(list)
|
||||
}
|
||||
return (
|
||||
<FieldListContainer
|
||||
inputFields={fields}
|
||||
onListSortChange={handleSort}
|
||||
onRemoveField={vi.fn()}
|
||||
onEditField={vi.fn()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
render(<EditableFields />)
|
||||
|
||||
expect(screen.getAllByRole('button', { name: 'common.operation.moveUp' })[0]).toBeDisabled()
|
||||
expect(screen.getAllByRole('button', { name: 'common.operation.moveDown' })[1]).toBeDisabled()
|
||||
await user.click(screen.getAllByRole('button', { name: 'common.operation.moveDown' })[0]!)
|
||||
expect(onListSortChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ variable: 'second', id: 'second' }),
|
||||
expect.objectContaining({ variable: 'first', id: 'first' }),
|
||||
])
|
||||
await user.click(screen.getAllByRole('button', { name: 'common.operation.moveUp' })[1]!)
|
||||
expect(onListSortChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ variable: 'first', id: 'first' }),
|
||||
expect.objectContaining({ variable: 'second', id: 'second' }),
|
||||
])
|
||||
})
|
||||
|
||||
it('should honor readonly mode for the rendered field rows', () => {
|
||||
render(
|
||||
<FieldListContainer
|
||||
|
||||
+47
-21
@@ -4,10 +4,15 @@ import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { InputVar } from '@/models/pipeline'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { RiDeleteBinLine, RiDraggable, RiEditLine } from '@remixicon/react'
|
||||
import { useHover } from 'ahooks'
|
||||
import {
|
||||
RiArrowDownLine,
|
||||
RiArrowUpLine,
|
||||
RiDeleteBinLine,
|
||||
RiDraggable,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { InputField } from '@/app/components/base/icons/src/vender/pipeline'
|
||||
@@ -20,6 +25,8 @@ type FieldItemProps = {
|
||||
index: number
|
||||
onClickEdit: (id: string) => void
|
||||
onRemove: (index: number) => void
|
||||
onMoveUp?: () => void
|
||||
onMoveDown?: () => void
|
||||
}
|
||||
|
||||
const FieldItem = ({
|
||||
@@ -29,11 +36,12 @@ const FieldItem = ({
|
||||
onClickEdit,
|
||||
onRemove,
|
||||
dragHandleProps,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: FieldItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const ref = useRef(null)
|
||||
const isHovering = useHover(ref)
|
||||
const fieldNameId = useId()
|
||||
|
||||
const handleOnClickEdit = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -55,13 +63,10 @@ const FieldItem = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'group flex h-8 cursor-pointer items-center justify-between gap-x-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-1 pl-2 shadow-xs hover:shadow-sm',
|
||||
isHovering && !readonly ? 'cursor-all-scroll pr-1' : 'pr-2.5',
|
||||
'group flex min-h-8 items-center justify-between gap-x-1 rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-1 pr-1 pl-2 shadow-xs hover:shadow-sm',
|
||||
readonly && 'cursor-default',
|
||||
)}
|
||||
// onClick={handleOnClickEdit}
|
||||
>
|
||||
<div className="relative size-4 shrink-0">
|
||||
<InputField
|
||||
@@ -81,6 +86,7 @@ const FieldItem = ({
|
||||
</div>
|
||||
<div className="flex grow basis-0 items-center gap-x-1 overflow-hidden">
|
||||
<div
|
||||
id={fieldNameId}
|
||||
title={payload.variable}
|
||||
className="max-w-32.5 shrink-0 truncate system-sm-medium text-text-secondary"
|
||||
>
|
||||
@@ -98,16 +104,37 @@ const FieldItem = ({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isHovering && !readonly ? (
|
||||
{!readonly && (
|
||||
<div className="flex shrink-0 items-center gap-x-1">
|
||||
{dragHandleProps && (
|
||||
<>
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.moveUp'], { ns: 'common' })}
|
||||
aria-describedby={fieldNameId}
|
||||
disabled={!onMoveUp}
|
||||
onClick={onMoveUp}
|
||||
>
|
||||
<RiArrowUpLine aria-hidden="true" className="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.moveDown'], { ns: 'common' })}
|
||||
aria-describedby={fieldNameId}
|
||||
disabled={!onMoveDown}
|
||||
onClick={onMoveDown}
|
||||
>
|
||||
<RiArrowDownLine aria-hidden="true" className="size-4" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
aria-describedby={fieldNameId}
|
||||
aria-label={t(($) => $['operation.edit'], { ns: 'common' })}
|
||||
className="mr-1"
|
||||
onClick={handleOnClickEdit}
|
||||
>
|
||||
<RiEditLine aria-hidden="true" className="size-4 text-text-tertiary" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
aria-describedby={fieldNameId}
|
||||
aria-label={t(($) => $['operation.remove'], { ns: 'common' })}
|
||||
onClick={handleRemove}
|
||||
>
|
||||
@@ -117,17 +144,16 @@ const FieldItem = ({
|
||||
/>
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex shrink-0 items-center gap-x-2">
|
||||
{payload.required && (
|
||||
<Badge>{t(($) => $['nodes.start.required'], { ns: 'workflow' })}</Badge>
|
||||
)}
|
||||
<InputVarTypeIcon
|
||||
type={payload.type as unknown as InputVarType}
|
||||
className="size-3 text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex shrink-0 items-center gap-x-2">
|
||||
{payload.required && (
|
||||
<Badge>{t(($) => $['nodes.start.required'], { ns: 'workflow' })}</Badge>
|
||||
)}
|
||||
<InputVarTypeIcon
|
||||
type={payload.type as unknown as InputVarType}
|
||||
className="size-3 text-text-tertiary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+16
-2
@@ -55,11 +55,21 @@ const FieldListContainer = ({
|
||||
[isSorting, inputFields, onListSortChange],
|
||||
)
|
||||
|
||||
const moveField = (index: number, direction: -1 | 1) => {
|
||||
if (readonly || isSorting) return
|
||||
const nextIndex = index + direction
|
||||
if (nextIndex < 0 || nextIndex >= list.length) return
|
||||
const reordered = [...list]
|
||||
const [field] = reordered.splice(index, 1)
|
||||
reordered.splice(nextIndex, 0, field!)
|
||||
onListSortChange(reordered)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{announcement}
|
||||
<ReactSortable<SortableItem>
|
||||
className={cn(className)}
|
||||
className={cn('nokey', className)}
|
||||
list={list}
|
||||
setList={handleListSortChange}
|
||||
handle=".handle"
|
||||
@@ -69,13 +79,17 @@ const FieldListContainer = ({
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<FieldItem
|
||||
key={getItemKey(index)}
|
||||
key={item.variable}
|
||||
dragHandleProps={getHandleProps(index)}
|
||||
index={getItemKey(index)}
|
||||
readonly={readonly}
|
||||
payload={item}
|
||||
onRemove={onRemoveField}
|
||||
onClickEdit={onEditField}
|
||||
onMoveUp={!isSorting && index > 0 ? () => moveField(index, -1) : undefined}
|
||||
onMoveDown={
|
||||
!isSorting && index < items.length - 1 ? () => moveField(index, 1) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ReactSortable>
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('TestRun header', () => {
|
||||
it('should render the title and reset preparing state on close', () => {
|
||||
render(<Header />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(screen.getByText('datasetPipeline.testRun.title')).toBeInTheDocument()
|
||||
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
|
||||
@@ -49,7 +49,7 @@ describe('TestRun header', () => {
|
||||
})
|
||||
|
||||
render(<Header />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(mockSetIsPreparingDataSource).not.toHaveBeenCalled()
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
@@ -22,13 +23,14 @@ const Header = () => {
|
||||
<div className="grow pr-8 pl-1 system-xl-semibold text-text-primary">
|
||||
{t(($) => $['testRun.title'], { ns: 'datasetPipeline' })}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-8 shrink-0 items-center justify-center p-1.5"
|
||||
<IconButton
|
||||
size="lg"
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
className="shrink-0"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<RiCloseLine className="size-4 text-text-tertiary" />
|
||||
</button>
|
||||
<RiCloseLine aria-hidden="true" className="size-4 text-text-tertiary" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ const TestRunPanel = () => {
|
||||
const isPreparingDataSource = useStore((state) => state.isPreparingDataSource)
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-120 flex-col rounded-l-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-1">
|
||||
<div className="nokey relative flex h-full w-120 flex-col rounded-l-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-1">
|
||||
<Header />
|
||||
{isPreparingDataSource ? (
|
||||
<DataSourceProvider>
|
||||
|
||||
+16
-36
@@ -1,41 +1,21 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import StepIndicator from '../step-indicator'
|
||||
|
||||
describe('StepIndicator', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render all step labels and highlight the current step', () => {
|
||||
const { container } = render(
|
||||
<StepIndicator
|
||||
currentStep={2}
|
||||
steps={[
|
||||
{ label: 'Select source', value: 'source' },
|
||||
{ label: 'Process docs', value: 'process' },
|
||||
{ label: 'Run test', value: 'run' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Select source')).toBeInTheDocument()
|
||||
expect(screen.getByText('Process docs')).toBeInTheDocument()
|
||||
expect(screen.getByText('Run test')).toBeInTheDocument()
|
||||
expect(container.querySelector('.bg-state-accent-solid')).toBeInTheDocument()
|
||||
expect(screen.getByText('Process docs').parentElement).toHaveClass('text-state-accent-solid')
|
||||
})
|
||||
|
||||
it('should keep inactive steps in the tertiary state', () => {
|
||||
render(
|
||||
<StepIndicator
|
||||
currentStep={1}
|
||||
steps={[
|
||||
{ label: 'Select source', value: 'source' },
|
||||
{ label: 'Process docs', value: 'process' },
|
||||
]}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Process docs').parentElement).toHaveClass('text-text-tertiary')
|
||||
it('identifies the current step in the ordered preparation flow', () => {
|
||||
const steps = [
|
||||
{ label: 'Select source', value: 'source' },
|
||||
{ label: 'Process docs', value: 'process' },
|
||||
{ label: 'Run test', value: 'run' },
|
||||
]
|
||||
const { rerender } = render(<StepIndicator currentStep={2} steps={steps} />)
|
||||
const items = within(screen.getByRole('list')).getAllByRole('listitem')
|
||||
expect(items).toHaveLength(3)
|
||||
expect(items[1]).toHaveAttribute('aria-current', 'step')
|
||||
expect(items[1]).toHaveTextContent('Process docs')
|
||||
expect(items[0]).not.toHaveAttribute('aria-current')
|
||||
rerender(<StepIndicator currentStep={3} steps={steps} />)
|
||||
expect(items[1]).not.toHaveAttribute('aria-current')
|
||||
expect(items[2]).toHaveAttribute('aria-current', 'step')
|
||||
})
|
||||
})
|
||||
+13
-16
@@ -11,20 +11,8 @@ vi.mock('../../hooks', () => ({
|
||||
useDatasourceOptions: () => options,
|
||||
}))
|
||||
|
||||
vi.mock('../option-card', () => ({
|
||||
default: ({
|
||||
label,
|
||||
value,
|
||||
onClick,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onClick: (value: string) => void
|
||||
}) => (
|
||||
<button type="button" onClick={() => onClick(value)}>
|
||||
{label}
|
||||
</button>
|
||||
),
|
||||
vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({
|
||||
useToolIcon: () => undefined,
|
||||
}))
|
||||
|
||||
describe('DataSourceOptions', () => {
|
||||
@@ -39,10 +27,19 @@ describe('DataSourceOptions', () => {
|
||||
it('selects the datasource chosen by the user', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSelect = vi.fn()
|
||||
render(<DataSourceOptions dataSourceNodeId="website" onSelect={onSelect} />)
|
||||
const { rerender } = render(
|
||||
<DataSourceOptions dataSourceNodeId="website" onSelect={onSelect} />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Drive' }))
|
||||
await user.tab()
|
||||
expect(screen.getByRole('radio', { name: 'Website' })).toHaveFocus()
|
||||
expect(screen.getByRole('radio', { name: 'Website' })).toBeChecked()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(screen.getByRole('radio', { name: 'Drive' })).toHaveFocus()
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith({ nodeId: 'drive', nodeData: options[1]!.data })
|
||||
rerender(<DataSourceOptions dataSourceNodeId="drive" onSelect={onSelect} />)
|
||||
expect(screen.getByRole('radio', { name: 'Drive' })).toBeChecked()
|
||||
expect(screen.getByRole('radio', { name: 'Website' })).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import OptionCard from '../option-card'
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-tool-icon', () => ({
|
||||
useToolIcon: () => 'source-icon',
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/block-icon', () => ({
|
||||
default: ({ toolIcon }: { toolIcon: string }) => <div data-testid="block-icon">{toolIcon}</div>,
|
||||
}))
|
||||
|
||||
describe('OptionCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render the datasource label and icon', () => {
|
||||
render(
|
||||
<OptionCard
|
||||
label="Website Crawl"
|
||||
value="website"
|
||||
selected={false}
|
||||
nodeData={{ title: 'Website Crawl' } as DataSourceNodeType}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('block-icon')).toHaveTextContent('source-icon')
|
||||
expect(screen.getByText('Website Crawl')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onClick with the card value and apply selected styles', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<OptionCard
|
||||
label="Online Drive"
|
||||
value="online-drive"
|
||||
selected
|
||||
nodeData={{ title: 'Online Drive' } as DataSourceNodeType}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByText('Online Drive'))
|
||||
|
||||
expect(onClick).toHaveBeenCalledWith('online-drive')
|
||||
})
|
||||
})
|
||||
+10
-3
@@ -1,5 +1,7 @@
|
||||
import type { Datasource } from '../../types'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDatasourceOptions } from '../hooks'
|
||||
import OptionCard from './option-card'
|
||||
|
||||
@@ -10,6 +12,7 @@ type DataSourceOptionsProps = {
|
||||
|
||||
const DataSourceOptions = ({ dataSourceNodeId, onSelect }: DataSourceOptionsProps) => {
|
||||
const options = useDatasourceOptions()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handelSelect = useCallback(
|
||||
(value: string) => {
|
||||
@@ -29,7 +32,12 @@ const DataSourceOptions = ({ dataSourceNodeId, onSelect }: DataSourceOptionsProp
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="grid w-full grid-cols-4 gap-1">
|
||||
<RadioGroup
|
||||
value={dataSourceNodeId}
|
||||
onValueChange={handelSelect}
|
||||
aria-label={t(($) => $['testRun.steps.dataSource'], { ns: 'datasetPipeline' })}
|
||||
className="grid w-full grid-cols-4 gap-1"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<OptionCard
|
||||
key={option.value}
|
||||
@@ -37,10 +45,9 @@ const DataSourceOptions = ({ dataSourceNodeId, onSelect }: DataSourceOptionsProp
|
||||
value={option.value}
|
||||
nodeData={option.data}
|
||||
selected={dataSourceNodeId === option.value}
|
||||
onClick={handelSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+10
-12
@@ -1,7 +1,7 @@
|
||||
import type { DataSourceNodeType } from '@/app/components/workflow/nodes/data-source/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioItem } from '@langgenius/dify-ui/radio-group'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { useToolIcon } from '@/app/components/workflow/hooks/use-tool-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
@@ -11,27 +11,25 @@ type OptionCardProps = {
|
||||
value: string
|
||||
selected: boolean
|
||||
nodeData: DataSourceNodeType
|
||||
onClick?: (value: string) => void
|
||||
}
|
||||
|
||||
const OptionCard = ({ label, value, selected, nodeData, onClick }: OptionCardProps) => {
|
||||
const OptionCard = ({ label, value, selected, nodeData }: OptionCardProps) => {
|
||||
const toolIcon = useToolIcon(nodeData)
|
||||
|
||||
const handleClickCard = useCallback(() => {
|
||||
onClick?.(value)
|
||||
}, [value, onClick])
|
||||
|
||||
return (
|
||||
<div
|
||||
<RadioItem
|
||||
value={value}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col gap-1 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-2.5 shadow-shadow-shadow-3',
|
||||
'flex cursor-pointer flex-col gap-1 rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-2.5 text-left shadow-shadow-shadow-3 focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
selected
|
||||
? 'border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs inset-ring-[0.5px] inset-ring-components-option-card-option-selected-border'
|
||||
: 'hover:border-components-option-card-option-border-hover hover:shadow-xs',
|
||||
)}
|
||||
onClick={handleClickCard}
|
||||
>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border bg-background-default-dodge p-1">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-panel-border bg-background-default-dodge p-1"
|
||||
>
|
||||
<BlockIcon type={BlockEnum.DataSource} toolIcon={toolIcon} />
|
||||
</div>
|
||||
<div
|
||||
@@ -43,7 +41,7 @@ const OptionCard = ({ label, value, selected, nodeData, onClick }: OptionCardPro
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+8
-4
@@ -14,12 +14,16 @@ type StepIndicatorProps = {
|
||||
|
||||
const StepIndicator = ({ currentStep, steps }: StepIndicatorProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-x-2 px-4 pb-2">
|
||||
<ol className="flex items-center gap-x-2 px-4 pb-2">
|
||||
{steps.map((step, index) => {
|
||||
const isCurrentStep = index === currentStep - 1
|
||||
const isLastStep = index === steps.length - 1
|
||||
return (
|
||||
<div key={index} className="flex items-center gap-x-2">
|
||||
<li
|
||||
key={step.value}
|
||||
aria-current={isCurrentStep ? 'step' : undefined}
|
||||
className="flex items-center gap-x-2"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-x-1',
|
||||
@@ -38,10 +42,10 @@ const StepIndicator = ({ currentStep, steps }: StepIndicatorProps) => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -35,10 +35,18 @@ describe('Result', () => {
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Result preview' })).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'runLog.detail' }))
|
||||
await user.tab()
|
||||
expect(screen.getByRole('tab', { name: 'runLog.result', selected: true })).toHaveFocus()
|
||||
await user.keyboard('{ArrowRight}{Enter}')
|
||||
const detailTab = screen.getByRole('tab', { name: 'runLog.detail', selected: true })
|
||||
expect(detailTab).toHaveFocus()
|
||||
expect(screen.getByRole('tabpanel', { name: 'runLog.detail' })).toHaveAttribute(
|
||||
'id',
|
||||
detailTab.getAttribute('aria-controls'),
|
||||
)
|
||||
expect(screen.getByText('Result detail')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'runLog.tracing' }))
|
||||
await user.click(screen.getByRole('tab', { name: 'runLog.tracing' }))
|
||||
expect(screen.getByText('Tracing detail')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TabsPanel, Tabs as TabsRoot } from '@langgenius/dify-ui/tabs'
|
||||
import { memo, useState } from 'react'
|
||||
import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder'
|
||||
import ResultPanel from '@/app/components/workflow/run/result-panel'
|
||||
@@ -16,14 +17,10 @@ const Result = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex grow flex-col">
|
||||
<Tabs
|
||||
currentTab={currentTab}
|
||||
workflowRunningData={workflowRunningData}
|
||||
switchTab={switchTab}
|
||||
/>
|
||||
<TabsRoot value={currentTab} onValueChange={setCurrentTab} className="flex grow flex-col">
|
||||
<Tabs workflowRunningData={workflowRunningData} />
|
||||
<div className="flex h-0 grow flex-col overflow-y-auto">
|
||||
{currentTab === 'RESULT' && (
|
||||
<TabsPanel value="RESULT" className="flex grow flex-col">
|
||||
<ResultPreview
|
||||
isRunning={
|
||||
!workflowRunningData?.result ||
|
||||
@@ -33,8 +30,8 @@ const Result = () => {
|
||||
error={workflowRunningData?.result?.error}
|
||||
onSwitchToDetail={() => switchTab('DETAIL')}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'DETAIL' && (
|
||||
</TabsPanel>
|
||||
<TabsPanel value="DETAIL" className="flex grow flex-col">
|
||||
<ResultPanel
|
||||
inputs={workflowRunningData?.result?.inputs}
|
||||
outputs={workflowRunningData?.result?.outputs}
|
||||
@@ -47,25 +44,25 @@ const Result = () => {
|
||||
steps={workflowRunningData?.result?.total_steps}
|
||||
exceptionCounts={workflowRunningData?.result?.exceptions_count}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'DETAIL' && !workflowRunningData?.result && (
|
||||
<div className="flex grow items-center justify-center">
|
||||
<LoadingPlaceholder />
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'TRACING' && (
|
||||
{!workflowRunningData?.result && (
|
||||
<div className="flex grow items-center justify-center">
|
||||
<LoadingPlaceholder />
|
||||
</div>
|
||||
)}
|
||||
</TabsPanel>
|
||||
<TabsPanel value="TRACING" className="flex grow flex-col">
|
||||
<TracingPanel
|
||||
className="bg-background-section-burn"
|
||||
list={workflowRunningData?.tracing || []}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'TRACING' && !workflowRunningData?.tracing?.length && (
|
||||
<div className="flex grow items-center justify-center">
|
||||
<LoadingPlaceholder />
|
||||
</div>
|
||||
)}
|
||||
{!workflowRunningData?.tracing?.length && (
|
||||
<div className="flex grow items-center justify-center">
|
||||
<LoadingPlaceholder />
|
||||
</div>
|
||||
)}
|
||||
</TabsPanel>
|
||||
</div>
|
||||
</div>
|
||||
</TabsRoot>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
import type { WorkflowRunningData } from '@/app/components/workflow/types'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import Tab from '../tab'
|
||||
|
||||
const createWorkflowRunningData = (): WorkflowRunningData => ({
|
||||
task_id: 'task-1',
|
||||
message_id: 'message-1',
|
||||
conversation_id: 'conversation-1',
|
||||
result: {
|
||||
workflow_id: 'workflow-1',
|
||||
inputs: '{}',
|
||||
inputs_truncated: false,
|
||||
process_data: '{}',
|
||||
process_data_truncated: false,
|
||||
outputs: '{}',
|
||||
outputs_truncated: false,
|
||||
status: 'succeeded',
|
||||
elapsed_time: 10,
|
||||
total_tokens: 20,
|
||||
created_at: Date.now(),
|
||||
finished_at: Date.now(),
|
||||
steps: 1,
|
||||
total_steps: 1,
|
||||
},
|
||||
tracing: [],
|
||||
})
|
||||
|
||||
describe('Tab', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render an active tab and pass its value on click', () => {
|
||||
const onClick = vi.fn()
|
||||
render(
|
||||
<Tab
|
||||
isActive
|
||||
label="Preview"
|
||||
value="preview"
|
||||
workflowRunningData={createWorkflowRunningData()}
|
||||
onClick={onClick}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
|
||||
|
||||
expect(screen.getByRole('button')).toHaveClass('border-util-colors-blue-brand-blue-brand-600')
|
||||
expect(onClick).toHaveBeenCalledWith('preview')
|
||||
})
|
||||
|
||||
it('should disable the tab when workflow run data is unavailable', () => {
|
||||
render(<Tab isActive={false} label="Trace" value="trace" onClick={vi.fn()} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Trace' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Trace' })).toHaveClass('opacity-30')
|
||||
})
|
||||
})
|
||||
@@ -1,41 +1,35 @@
|
||||
import type { WorkflowRunningData } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { TabsList, TabsTab } from '@langgenius/dify-ui/tabs'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tab from './tab'
|
||||
|
||||
type TabsProps = {
|
||||
currentTab: string
|
||||
workflowRunningData?: WorkflowRunningData
|
||||
switchTab: (tab: string) => void
|
||||
}
|
||||
|
||||
const Tabs = ({ currentTab, workflowRunningData, switchTab }: TabsProps) => {
|
||||
const Tabs = ({ workflowRunningData }: TabsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const tabs = [
|
||||
{ value: 'RESULT', label: t(($) => $.result, { ns: 'runLog' }) },
|
||||
{ value: 'DETAIL', label: t(($) => $.detail, { ns: 'runLog' }) },
|
||||
{ value: 'TRACING', label: t(($) => $.tracing, { ns: 'runLog' }) },
|
||||
]
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-x-6 border-b-[0.5px] border-divider-subtle px-4">
|
||||
<Tab
|
||||
isActive={currentTab === 'RESULT'}
|
||||
label={t(($) => $.result, { ns: 'runLog' })}
|
||||
value="RESULT"
|
||||
workflowRunningData={workflowRunningData}
|
||||
onClick={switchTab}
|
||||
/>
|
||||
<Tab
|
||||
isActive={currentTab === 'DETAIL'}
|
||||
label={t(($) => $.detail, { ns: 'runLog' })}
|
||||
value="DETAIL"
|
||||
workflowRunningData={workflowRunningData}
|
||||
onClick={switchTab}
|
||||
/>
|
||||
<Tab
|
||||
isActive={currentTab === 'TRACING'}
|
||||
label={t(($) => $.tracing, { ns: 'runLog' })}
|
||||
value="TRACING"
|
||||
workflowRunningData={workflowRunningData}
|
||||
onClick={switchTab}
|
||||
/>
|
||||
</div>
|
||||
<TabsList
|
||||
aria-label={t(($) => $['testRun.title'], { ns: 'datasetPipeline' })}
|
||||
className="shrink-0 items-center gap-x-6 border-b-[0.5px] border-divider-subtle px-4"
|
||||
>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTab
|
||||
key={tab.value}
|
||||
value={tab.value}
|
||||
disabled={!workflowRunningData}
|
||||
className="py-3 system-sm-semibold-uppercase!"
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Tabs)
|
||||
export default Tabs
|
||||
@@ -1,35 +0,0 @@
|
||||
import type { WorkflowRunningData } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type TabProps = {
|
||||
isActive: boolean
|
||||
label: string
|
||||
value: string
|
||||
workflowRunningData?: WorkflowRunningData
|
||||
onClick: (value: string) => void
|
||||
}
|
||||
|
||||
const Tab = ({ isActive, label, value, workflowRunningData, onClick }: TabProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
onClick(value)
|
||||
}, [value, onClick])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'cursor-pointer border-b-2 border-transparent py-3 system-sm-semibold-uppercase text-text-tertiary',
|
||||
isActive && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
|
||||
!workflowRunningData && 'cursor-not-allowed! opacity-30',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
disabled={!workflowRunningData}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Tab)
|
||||
@@ -1,3 +1,4 @@
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { RiCloseLine, RiInformation2Fill } from '@remixicon/react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -25,12 +26,13 @@ const PublishToast = () => {
|
||||
{t(($) => $['publishToast.desc'], { ns: 'pipeline' })}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-auto flex size-6 cursor-pointer items-center justify-center"
|
||||
<IconButton
|
||||
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
|
||||
className="nokey pointer-events-auto shrink-0"
|
||||
onClick={() => setHideToast(true)}
|
||||
>
|
||||
<RiCloseLine className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
<RiCloseLine aria-hidden="true" className="size-4 text-text-tertiary" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
+4
-2
@@ -183,7 +183,9 @@ describe('RunMode', () => {
|
||||
it('should call handleStopRun with task_id when stop clicked', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('stop-icon').closest('button')!)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }),
|
||||
)
|
||||
|
||||
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
|
||||
})
|
||||
@@ -227,7 +229,7 @@ describe('RunMode', () => {
|
||||
it('should cancel preparing when close clicked', () => {
|
||||
render(<RunMode />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('close-icon').closest('button')!)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(mockSetIsPreparingDataSource).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(false)
|
||||
|
||||
@@ -59,7 +59,7 @@ export function RunMode({ text }: RunModeProps) {
|
||||
if (!canRun) return null
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-x-px">
|
||||
<div className="nokey flex items-center gap-x-px">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
@@ -102,22 +102,24 @@ export function RunMode({ text }: RunModeProps) {
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active',
|
||||
'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
)}
|
||||
aria-label={t(($) => $['debug.variableInspect.trigger.stop'], { ns: 'workflow' })}
|
||||
onClick={handleStop}
|
||||
>
|
||||
<StopCircle className="size-4 text-text-accent" />
|
||||
<StopCircle aria-hidden="true" className="size-4 text-text-accent" />
|
||||
</button>
|
||||
)}
|
||||
{isPreparingDataSource && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active',
|
||||
'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
)}
|
||||
aria-label={t(($) => $['operation.cancel'], { ns: 'common' })}
|
||||
onClick={handleCancelPreparingDataSource}
|
||||
>
|
||||
<RiCloseLine className="size-4 text-text-accent" />
|
||||
<RiCloseLine aria-hidden="true" className="size-4 text-text-accent" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { SsoProtocol } from '@dify/contracts/api/console/system-features/ty
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { zSsoProtocol } from '@dify/contracts/api/console/system-features/zod.gen'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { mockEmojiData } from '@/test/emoji-picker'
|
||||
@@ -690,7 +691,7 @@ describe('MCPModal', () => {
|
||||
fireEvent.click(appIconContainer)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search'))!.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -707,19 +708,19 @@ describe('MCPModal', () => {
|
||||
fireEvent.click(appIconContainer)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '#E4FBCC' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '#F3FEE7' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.ok/ }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('should close app icon picker and reset icon when close button is clicked', async () => {
|
||||
it('should close app icon picker and reset icon when Escape is pressed', async () => {
|
||||
render(<MCPModal {...defaultProps} />, { wrapper: createWrapper() })
|
||||
|
||||
// Open the icon picker
|
||||
@@ -731,13 +732,13 @@ describe('MCPModal', () => {
|
||||
fireEvent.click(appIconContainer)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('common.operation.search'))!.toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('app.iconPicker.search'))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /iconPicker\.cancel/ }))
|
||||
await userEvent.setup().keyboard('{Escape}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByPlaceholderText('common.operation.search')).not.toBeInTheDocument()
|
||||
expect(screen.queryByPlaceholderText('app.iconPicker.search')).not.toBeInTheDocument()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
+36
-16
@@ -46,24 +46,44 @@ describe('OptionCard', () => {
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support function-based wrapper, class, and icon props without enabling selection', () => {
|
||||
render(
|
||||
<OptionCard
|
||||
id="inactive"
|
||||
selectedId="qualified"
|
||||
title="Inactive card"
|
||||
enableSelect={false}
|
||||
wrapperClassName={(isActive) => (isActive ? 'wrapper-active' : 'wrapper-inactive')}
|
||||
className={(isActive) => (isActive ? 'body-active' : 'body-inactive')}
|
||||
icon={(isActive) => (
|
||||
<span data-testid="option-icon">{isActive ? 'active' : 'inactive'}</span>
|
||||
)}
|
||||
/>,
|
||||
it('should expose selection to keyboard users while keeping expanded inputs separate', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClick = vi.fn()
|
||||
const { rerender } = render(
|
||||
<OptionCard id="economical" title="Economical" onClick={onClick} />,
|
||||
)
|
||||
|
||||
expect(screen.getByText('Inactive card').closest('.wrapper-inactive')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('option-icon')).toHaveTextContent('inactive')
|
||||
expect(screen.getByText('Inactive card').closest('.body-inactive')).toBeInTheDocument()
|
||||
const choice = screen.getByRole('button', { name: 'Economical' })
|
||||
expect(choice).toHaveAttribute('aria-pressed', 'false')
|
||||
await user.tab()
|
||||
expect(choice).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(onClick).toHaveBeenCalledWith('economical')
|
||||
|
||||
rerender(
|
||||
<OptionCard id="economical" selectedId="economical" title="Economical" onClick={onClick}>
|
||||
<label>
|
||||
Keywords
|
||||
<input type="number" defaultValue={5} />
|
||||
</label>
|
||||
</OptionCard>,
|
||||
)
|
||||
|
||||
expect(choice).toHaveAttribute('aria-pressed', 'true')
|
||||
onClick.mockClear()
|
||||
await user.tab()
|
||||
const input = screen.getByRole('spinbutton', { name: 'Keywords' })
|
||||
expect(input).toHaveFocus()
|
||||
await user.clear(input)
|
||||
await user.type(input, '10')
|
||||
expect(input).toHaveValue(10)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep a nonselectable summary outside the tab order', () => {
|
||||
render(<OptionCard id="general" title="General" enableSelect={false} />)
|
||||
expect(screen.getByText('General')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
+46
-8
@@ -1,7 +1,15 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useKeyPress } from 'reactflow'
|
||||
import { ChunkStructureEnum } from '../../../types'
|
||||
import Selector from '../selector'
|
||||
|
||||
// Exercise the same document-level shortcut mounted by the workflow canvas.
|
||||
function CanvasPanShortcut() {
|
||||
useKeyPress('Space')
|
||||
return null
|
||||
}
|
||||
|
||||
const options = [
|
||||
{
|
||||
id: ChunkStructureEnum.general,
|
||||
@@ -21,17 +29,26 @@ const options = [
|
||||
|
||||
describe('ChunkStructureSelector', () => {
|
||||
it('should open the selector panel and close it after selecting an option', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
|
||||
render(<Selector options={options} value={ChunkStructureEnum.general} onChange={onChange} />)
|
||||
render(
|
||||
<>
|
||||
<CanvasPanShortcut />
|
||||
<Selector options={options} value={ChunkStructureEnum.general} onChange={onChange} />
|
||||
</>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.change' }))
|
||||
await user.click(screen.getByRole('button', { name: 'workflow.panel.change' }))
|
||||
|
||||
expect(
|
||||
screen.getByText('workflow.nodes.knowledgeBase.changeChunkStructure'),
|
||||
screen.getByRole('dialog', { name: 'workflow.nodes.knowledgeBase.changeChunkStructure' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByText('Parent child'))
|
||||
expect(screen.getByRole('button', { name: /General/, pressed: true })).toBeInTheDocument()
|
||||
const choice = screen.getByRole('button', { name: /Parent child/, pressed: false })
|
||||
choice.focus()
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(ChunkStructureEnum.parent_child)
|
||||
await waitFor(() => {
|
||||
@@ -41,7 +58,27 @@ describe('ChunkStructureSelector', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not open the selector when readonly is enabled', () => {
|
||||
it('should expose one custom trigger and open it from the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<>
|
||||
<CanvasPanShortcut />
|
||||
<Selector
|
||||
options={options}
|
||||
onChange={vi.fn()}
|
||||
trigger={<button type="button">Choose structure</button>}
|
||||
/>
|
||||
</>,
|
||||
)
|
||||
expect(screen.getAllByRole('button')).toHaveLength(1)
|
||||
await user.tab()
|
||||
expect(screen.getByRole('button', { name: 'Choose structure' })).toHaveFocus()
|
||||
await user.keyboard(' ')
|
||||
expect(await screen.findByRole('button', { name: /General/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not open the selector when readonly is enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<Selector
|
||||
options={options}
|
||||
@@ -51,8 +88,9 @@ describe('ChunkStructureSelector', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
const trigger = screen.getByText('custom-trigger').closest('[role="button"]')
|
||||
fireEvent.click(trigger!)
|
||||
const trigger = screen.getByRole('button', { name: 'custom-trigger' })
|
||||
expect(trigger).toBeDisabled()
|
||||
await user.click(trigger)
|
||||
|
||||
expect(
|
||||
screen.queryByText('workflow.nodes.knowledgeBase.changeChunkStructure'),
|
||||
|
||||
+12
-9
@@ -1,8 +1,8 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ReactElement } from 'react'
|
||||
import type { ChunkStructureEnum } from '../../types'
|
||||
import type { Option } from './type'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OptionCard from '../option-card'
|
||||
@@ -12,7 +12,7 @@ type SelectorProps = {
|
||||
value?: ChunkStructureEnum
|
||||
onChange: (key: ChunkStructureEnum) => void
|
||||
readonly?: boolean
|
||||
trigger?: ReactNode
|
||||
trigger?: ReactElement
|
||||
}
|
||||
const Selector = ({ options, value, onChange, readonly, trigger }: SelectorProps) => {
|
||||
const { t } = useTranslation()
|
||||
@@ -35,12 +35,15 @@ const Selector = ({ options, value, onChange, readonly, trigger }: SelectorProps
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
{/* Keep Space activation outside React Flow's document-level pan shortcut. */}
|
||||
{trigger ? (
|
||||
<PopoverTrigger nativeButton={false} render={<div />}>
|
||||
{trigger}
|
||||
</PopoverTrigger>
|
||||
<PopoverTrigger className="nokey" disabled={readonly} render={trigger} />
|
||||
) : (
|
||||
<PopoverTrigger render={<Button size="small" variant="ghost-accent" />}>
|
||||
<PopoverTrigger
|
||||
className="nokey"
|
||||
disabled={readonly}
|
||||
render={<Button size="small" variant="ghost-accent" />}
|
||||
>
|
||||
{t(($) => $['panel.change'], { ns: 'workflow' })}
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
@@ -51,9 +54,9 @@ const Selector = ({ options, value, onChange, readonly, trigger }: SelectorProps
|
||||
className="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className="w-101 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]">
|
||||
<div className="px-3 pt-3.5 system-sm-semibold text-text-primary">
|
||||
<PopoverTitle className="px-3 pt-3.5 system-sm-semibold text-text-primary">
|
||||
{t(($) => $['nodes.knowledgeBase.changeChunkStructure'], { ns: 'workflow' })}
|
||||
</div>
|
||||
</PopoverTitle>
|
||||
<div className="space-y-1 p-3 pt-2">
|
||||
{options.map((option) => (
|
||||
<OptionCard
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Toggle } from '@langgenius/dify-ui/toggle'
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@@ -62,7 +63,8 @@ const OptionCard = memo(
|
||||
const effectElement = useMemo(() => {
|
||||
if (effectColor) {
|
||||
return (
|
||||
<div
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'absolute -top-0.5 -left-0.5 hidden h-14 w-14 rounded-full',
|
||||
'group-hover:block',
|
||||
@@ -70,13 +72,15 @@ const OptionCard = memo(
|
||||
)}
|
||||
>
|
||||
{HEADER_EFFECT_MAP[effectColor]}
|
||||
</div>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}, [effectColor, isActive])
|
||||
|
||||
const Header = enableSelect ? Toggle : 'div'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -91,47 +95,62 @@ const OptionCard = memo(
|
||||
? wrapperClassName(isActive)
|
||||
: wrapperClassName),
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (!readonly && enableSelect && id) onClick?.(id)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
<Header
|
||||
{...(enableSelect
|
||||
? {
|
||||
pressed: isActive,
|
||||
disabled: readonly,
|
||||
onPressedChange: () => {
|
||||
if (id !== undefined) onClick?.(id)
|
||||
},
|
||||
onClick: (event: React.MouseEvent) => event.stopPropagation(),
|
||||
}
|
||||
: {})}
|
||||
className={cn(
|
||||
'relative flex rounded-t-xl p-2',
|
||||
'relative flex w-full rounded-t-xl p-2 text-left',
|
||||
// React Flow otherwise consumes Space before the native button can activate.
|
||||
enableSelect &&
|
||||
'nokey cursor-pointer focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden focus-visible:ring-inset disabled:cursor-not-allowed',
|
||||
className && (typeof className === 'function' ? className(isActive) : className),
|
||||
)}
|
||||
>
|
||||
{effectElement}
|
||||
{!!icon && (
|
||||
<div className="mr-1 flex h-4.5 w-4.5 shrink-0 items-center justify-center">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="mr-1 flex h-4.5 w-4.5 shrink-0 items-center justify-center"
|
||||
>
|
||||
{typeof icon === 'function' ? icon(isActive) : icon}
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
<div className="grow py-1 pt-px">
|
||||
<div className="flex items-center">
|
||||
<div className="flex grow items-center system-sm-medium text-text-secondary">
|
||||
<span className="block grow py-1 pt-px">
|
||||
<span className="flex items-center">
|
||||
<span className="flex grow items-center system-sm-medium text-text-secondary">
|
||||
{title}
|
||||
{isRecommended && (
|
||||
<Badge className="ml-1 h-4 border-text-accent-secondary text-text-accent-secondary">
|
||||
<Badge
|
||||
as="span"
|
||||
className="ml-1 h-4 border-text-accent-secondary text-text-accent-secondary"
|
||||
>
|
||||
{t(($) => $['stepTwo.recommend'], { ns: 'datasetCreation' })}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
{enableRadio && (
|
||||
<div
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 size-4 shrink-0 rounded-full border border-components-radio-border bg-components-radio-bg',
|
||||
isActive && 'border-[5px] border-components-radio-border-checked',
|
||||
)}
|
||||
></div>
|
||||
></span>
|
||||
)}
|
||||
</div>
|
||||
</span>
|
||||
{description && (
|
||||
<div className="mt-1 system-xs-regular text-text-tertiary">{description}</div>
|
||||
<span className="mt-1 block system-xs-regular text-text-tertiary">{description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</span>
|
||||
</Header>
|
||||
{!!(children && isActive) && (
|
||||
<div className="relative rounded-b-xl bg-components-panel-bg p-3">
|
||||
<ArrowShape className="absolute -top-2.75 left-3.5 h-4 w-4 text-components-panel-bg" />
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { VarInInspectType } from '@/types/workflow'
|
||||
import { createNode } from '../../__tests__/fixtures'
|
||||
import { baseRunningData, renderWorkflowFlowComponent } from '../../__tests__/workflow-test-env'
|
||||
import { BlockEnum, NodeRunningStatus, VarType, WorkflowRunningStatus } from '../../types'
|
||||
import VariableInspectTrigger from '../trigger'
|
||||
import { EVENT_WORKFLOW_STOP } from '../types'
|
||||
|
||||
type InspectVarsState = {
|
||||
conversationVars: VarInInspect[]
|
||||
@@ -88,10 +90,16 @@ describe('VariableInspectTrigger', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the panel from the normal trigger state', () => {
|
||||
it('should open the panel from the normal trigger state with the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { store } = renderTrigger()
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.normal'))
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.normal' }),
|
||||
).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(store.getState().showVariableInspectPanel).toBe(true)
|
||||
})
|
||||
@@ -108,7 +116,8 @@ describe('VariableInspectTrigger', () => {
|
||||
expect(store.getState().showVariableInspectPanel).toBe(false)
|
||||
})
|
||||
|
||||
it('should clear cached variables and reset the focused node', () => {
|
||||
it('should clear cached variables and reset the focused node with the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
inspectVarsState = {
|
||||
conversationVars: [
|
||||
createVariable({
|
||||
@@ -126,13 +135,36 @@ describe('VariableInspectTrigger', () => {
|
||||
},
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.debug.variableInspect.trigger.clear'))
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.clear' }),
|
||||
).toHaveFocus()
|
||||
await user.keyboard(' ')
|
||||
|
||||
expect(screen.getByText('workflow.debug.variableInspect.trigger.cached')).toBeInTheDocument()
|
||||
expect(mockDeleteAllInspectorVars).toHaveBeenCalledTimes(1)
|
||||
expect(store.getState().currentFocusNodeId).toBe('')
|
||||
})
|
||||
|
||||
it('exposes a named stop button that works from the keyboard', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderTrigger({
|
||||
initialStoreState: {
|
||||
workflowRunningData: baseRunningData({ result: { status: WorkflowRunningStatus.Running } }),
|
||||
},
|
||||
})
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
await user.tab()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'workflow.debug.variableInspect.trigger.stop' }),
|
||||
).toHaveFocus()
|
||||
await user.keyboard('{Enter}')
|
||||
expect(mockEmit).toHaveBeenCalledWith({ type: EVENT_WORKFLOW_STOP })
|
||||
})
|
||||
|
||||
it('should show the running state and open the panel while running', () => {
|
||||
const { store } = renderTrigger({
|
||||
nodes: [
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { FC } from 'react'
|
||||
import type { CommonNodeType } from '@/app/components/workflow/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { IconButton } from '@langgenius/dify-ui/icon-button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { RiLoader2Line, RiStopCircleFill } from '@remixicon/react'
|
||||
import { useMemo } from 'react'
|
||||
@@ -62,12 +64,16 @@ const VariableInspectTrigger: FC = () => {
|
||||
|
||||
if (showVariableInspectPanel) return null
|
||||
|
||||
// React Flow reserves Space for panning unless the target is inside .nokey.
|
||||
return (
|
||||
<div className={cn('flex shrink-0 flex-nowrap items-center gap-1 whitespace-nowrap')}>
|
||||
<div className={cn('nokey flex shrink-0 flex-nowrap items-center gap-1 whitespace-nowrap')}>
|
||||
{!isRunning && !currentVars.length && (
|
||||
<div
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-5 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-2xs-semibold-uppercase text-text-tertiary shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
|
||||
'flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-2xs-semibold-uppercase text-text-tertiary shadow-lg backdrop-blur-xs hover:bg-background-default-hover',
|
||||
nodesReadOnly &&
|
||||
'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
|
||||
)}
|
||||
@@ -77,11 +83,14 @@ const VariableInspectTrigger: FC = () => {
|
||||
}}
|
||||
>
|
||||
{t(($) => $['debug.variableInspect.trigger.normal'], { ns: 'workflow' })}
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
{!isRunning && currentVars.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-xs-medium text-text-accent shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent',
|
||||
nodesReadOnly &&
|
||||
@@ -93,8 +102,11 @@ const VariableInspectTrigger: FC = () => {
|
||||
}}
|
||||
>
|
||||
{t(($) => $['debug.variableInspect.trigger.cached'], { ns: 'workflow' })}
|
||||
</div>
|
||||
<div
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
disabled={nodesReadOnly}
|
||||
className={cn(
|
||||
'flex h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 system-xs-medium text-text-tertiary shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent hover:text-text-accent',
|
||||
nodesReadOnly &&
|
||||
@@ -106,30 +118,38 @@ const VariableInspectTrigger: FC = () => {
|
||||
}}
|
||||
>
|
||||
{t(($) => $['debug.variableInspect.trigger.clear'], { ns: 'workflow' })}
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isRunning && (
|
||||
<>
|
||||
<div
|
||||
<Button
|
||||
size="small"
|
||||
variant="secondary"
|
||||
className="flex h-6 shrink-0 cursor-pointer items-center gap-1 rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-2 system-xs-medium text-text-accent shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent"
|
||||
onClick={() => setShowVariableInspectPanel(true)}
|
||||
>
|
||||
<RiLoader2Line className="size-4 shrink-0 animate-spin" />
|
||||
<RiLoader2Line aria-hidden="true" className="size-4 shrink-0 animate-spin" />
|
||||
<span className="text-text-accent">
|
||||
{t(($) => $['debug.variableInspect.trigger.running'], { ns: 'workflow' })}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
{isPreviewRunning && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<div
|
||||
<IconButton
|
||||
aria-label={t(($) => $['debug.variableInspect.trigger.stop'], {
|
||||
ns: 'workflow',
|
||||
})}
|
||||
className="flex h-6 shrink-0 cursor-pointer items-center rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-1 shadow-lg backdrop-blur-xs hover:bg-components-actionbar-bg-accent"
|
||||
onClick={handleStop}
|
||||
>
|
||||
<RiStopCircleFill className="size-4 shrink-0 text-text-accent" />
|
||||
</div>
|
||||
<RiStopCircleFill
|
||||
aria-hidden="true"
|
||||
className="size-4 shrink-0 text-text-accent"
|
||||
/>
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
|
||||
+6
-5
@@ -84,17 +84,18 @@ shared by server selection, client navigation and production build validation. `
|
||||
|
||||
The server reads the pathname that `proxy.ts` overwrites on every request. Missing
|
||||
or unknown paths conservatively use the full registry. Base paths are supported.
|
||||
For sign-in, the serialized resources include the requested locale and, when it
|
||||
differs, English fallback resources for those same two namespaces. Unmigrated
|
||||
routes retain their existing fallback loading behavior.
|
||||
Serialized resources include the requested locale and English fallback for the
|
||||
selected namespaces, keeping translation initialization ready for SSR and hydration.
|
||||
Sign-in still includes only `common` and `login`.
|
||||
|
||||
Client initialization uses only the provided namespaces. The default namespace is
|
||||
`common` for sign-in, so a bare `useTranslation()` does not request `app`. The
|
||||
provider keeps the same i18next instance across rerenders and locale changes.
|
||||
A namespace-set change resets only the translation readiness component; the
|
||||
provider and business subtree retain their state, including active notifications.
|
||||
Suspense reveals children after the destination's namespaces are loaded. The current route's namespace list also
|
||||
controls language switching, without discarding previously loaded bundles.
|
||||
The loader suspends missing translations into the existing router/root boundaries;
|
||||
the provider adds no application-wide Suspense fallback. The current route's
|
||||
namespace list also controls language switching without discarding loaded bundles.
|
||||
|
||||
Server metadata requests initialize an empty instance and load their requested
|
||||
namespace. Existing server consumers without a namespace keep the full-catalog
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { createToast, createToastManager } from '@langgenius/dify-ui/toast'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { lazy, startTransition } from 'react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { I18nClientProvider } from '@/app/components/provider/i18n'
|
||||
import { I18nServerProvider } from '@/app/components/provider/i18n-server'
|
||||
import { AppToastHost } from '@/app/notifications/host'
|
||||
import { changeLanguage } from '../client'
|
||||
import { getDeclaredRouteNamespaces, getRouteNamespaces } from '../route-namespaces'
|
||||
@@ -9,6 +14,10 @@ import { getDeclaredRouteNamespaces, getRouteNamespaces } from '../route-namespa
|
||||
vi.unmock('react-i18next')
|
||||
const mocks = vi.hoisted(() => ({ pathname: '/signin', loadResource: vi.fn() }))
|
||||
vi.mock('@/next/navigation', () => ({ usePathname: () => mocks.pathname }))
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: async () => new Headers({ 'x-dify-pathname': mocks.pathname }),
|
||||
cookies: async () => ({ get: () => ({ value: 'zh-Hans' }) }),
|
||||
}))
|
||||
vi.mock('../load-resource', () => ({ loadI18nResource: mocks.loadResource }))
|
||||
|
||||
const makeResources = () => ({
|
||||
@@ -51,6 +60,16 @@ describe('route translation loading', () => {
|
||||
expect(mocks.loadResource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('includes localized content and English fallback in an unmigrated route server response', async () => {
|
||||
mocks.pathname = '/agents/example/configure'
|
||||
const page = await I18nServerProvider({
|
||||
children: <Label />,
|
||||
})
|
||||
const html = renderToString(page)
|
||||
expect(html).toContain('保存')
|
||||
expect(html).toContain('Cancel')
|
||||
})
|
||||
|
||||
it('loads only active namespaces when switching language and retains that language on rerender', async () => {
|
||||
const view = render(
|
||||
<I18nClientProvider locale="en-US" resource={{ 'en-US': resources['en-US'] }}>
|
||||
@@ -96,17 +115,24 @@ describe('route translation loading', () => {
|
||||
expect(mocks.loadResource.mock.calls.map(([, ns]) => ns).sort()).toEqual(['common', 'login'])
|
||||
})
|
||||
|
||||
it('preserves global notifications across namespace changes, including suspended navigation', async () => {
|
||||
it('retains committed content and shared state until destination translations are ready', async () => {
|
||||
function Destination() {
|
||||
const { t } = useTranslation('common')
|
||||
return <span>{t(($) => $['blocks.agent'], { ns: 'workflow' })}</span>
|
||||
}
|
||||
const user = userEvent.setup()
|
||||
const manager = createToastManager()
|
||||
const toast = createToast(manager)
|
||||
const content = () => (
|
||||
<I18nClientProvider locale="en-US" resource={{ 'en-US': resources['en-US'] }}>
|
||||
<AppToastHost manager={manager} timeout={0} />
|
||||
<Label />
|
||||
<input aria-label="Shared draft" />
|
||||
{mocks.pathname === '/signin' ? <Label /> : <Destination />}
|
||||
</I18nClientProvider>
|
||||
)
|
||||
const view = render(content())
|
||||
await screen.findByText('Save / Cancel')
|
||||
await user.type(screen.getByRole('textbox', { name: 'Shared draft' }), 'Keep me')
|
||||
act(() => {
|
||||
toast.success('Still relevant after navigation')
|
||||
})
|
||||
@@ -116,24 +142,71 @@ describe('route translation loading', () => {
|
||||
const pending = new Promise<void>((resolve) => {
|
||||
finishLoading = resolve
|
||||
})
|
||||
mocks.loadResource.mockImplementation(async () => {
|
||||
mocks.loadResource.mockImplementation(async (_locale, namespace) => {
|
||||
await pending
|
||||
return { default: {} }
|
||||
return { default: namespace === 'workflow' ? { 'blocks.agent': 'Agent' } : {} }
|
||||
})
|
||||
mocks.pathname = '/apps'
|
||||
view.rerender(content())
|
||||
startTransition(() => view.rerender(content()))
|
||||
await waitFor(() => expect(mocks.loadResource).toHaveBeenCalled())
|
||||
expect(screen.getByText('Save / Cancel')).toBeVisible()
|
||||
expect(screen.queryByText('blocks.agent')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Still relevant after navigation')).toBeVisible()
|
||||
await act(async () => {
|
||||
finishLoading()
|
||||
await pending
|
||||
})
|
||||
expect(await screen.findByText('Still relevant after navigation')).toBeVisible()
|
||||
expect(await screen.findByText('Agent')).toBeVisible()
|
||||
expect(screen.getByRole('textbox', { name: 'Shared draft' })).toHaveValue('Keep me')
|
||||
|
||||
mocks.pathname = '/signin'
|
||||
view.rerender(content())
|
||||
startTransition(() => view.rerender(content()))
|
||||
expect(await screen.findByText('Still relevant after navigation')).toBeVisible()
|
||||
})
|
||||
|
||||
it('preserves route state when navigating within the same namespace scope', async () => {
|
||||
mocks.pathname = '/apps'
|
||||
const user = userEvent.setup()
|
||||
const content = () => (
|
||||
<I18nClientProvider locale="en-US" resource={{ 'en-US': resources['en-US'] }}>
|
||||
<input aria-label="Draft" />
|
||||
</I18nClientProvider>
|
||||
)
|
||||
const view = render(content())
|
||||
await user.type(await screen.findByRole('textbox', { name: 'Draft' }), 'Keep this draft')
|
||||
mocks.pathname = '/agents'
|
||||
startTransition(() => view.rerender(content()))
|
||||
expect(screen.getByRole('textbox', { name: 'Draft' })).toHaveValue('Keep this draft')
|
||||
})
|
||||
|
||||
it('keeps server-rendered navigation visible while a client module hydrates', async () => {
|
||||
let finishLoading!: () => void
|
||||
const pending = new Promise<{ default: () => ReactNode }>((resolve) => {
|
||||
finishLoading = () => resolve({ default: () => <main>Page content</main> })
|
||||
})
|
||||
const ClientPage = lazy(() => pending)
|
||||
const content = (page: ReactNode) => (
|
||||
<I18nClientProvider locale="en-US" resource={{ 'en-US': resources['en-US'] }}>
|
||||
<nav aria-label="App navigation">Navigation</nav>
|
||||
{page}
|
||||
</I18nClientProvider>
|
||||
)
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = renderToString(content(<main>Page content</main>))
|
||||
document.body.appendChild(container)
|
||||
const view = render(content(<ClientPage />), { container, hydrate: true })
|
||||
view.rerender(content(<ClientPage />))
|
||||
|
||||
expect(screen.getByRole('navigation', { name: 'App navigation' })).toBeVisible()
|
||||
await act(async () => {
|
||||
finishLoading()
|
||||
await pending
|
||||
})
|
||||
expect(screen.getByRole('navigation', { name: 'App navigation' })).toBeVisible()
|
||||
expect(screen.getByRole('main')).toHaveTextContent('Page content')
|
||||
})
|
||||
|
||||
it('matches signin segments with a base path and keeps unknown routes complete', () => {
|
||||
expect(getRouteNamespaces('/console/signin/check-code', '/console')).toEqual([
|
||||
'common',
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "بعض خدمات البحث غير متوفرة",
|
||||
"gotoAnything.tryDifferentSearch": "جرب مصطلح بحث مختلف",
|
||||
"iconPicker.cancel": "إلغاء",
|
||||
"iconPicker.chooseStyle": "اختر النمط",
|
||||
"iconPicker.emoji": "رموز تعبيرية",
|
||||
"iconPicker.image": "صورة",
|
||||
"iconPicker.ok": "موافق",
|
||||
"iconPicker.recent": "الأخيرة",
|
||||
"iconPicker.recommend": "المقترحة",
|
||||
"iconPicker.search": "ابحث عن رمز تعبيري…",
|
||||
"iconPicker.tryYourLuck": "جرّب حظك",
|
||||
"importApp": "استيراد التطبيق",
|
||||
"importAppDescription": "حزمة تطبيق أو ملف DSL",
|
||||
"importAppFormats": "يدعم .ifpkg و.yaml و.yml",
|
||||
|
||||
@@ -476,6 +476,8 @@
|
||||
"operation.more": "المزيد",
|
||||
"operation.moreActions": "المزيد من الإجراءات",
|
||||
"operation.moreActionsFor": "المزيد من الإجراءات لـ {{name}}",
|
||||
"operation.moveDown": "نقل لأسفل",
|
||||
"operation.moveUp": "نقل لأعلى",
|
||||
"operation.no": "لا",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "لم يتم العثور على {{content}}",
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "Bəzi axtarış xidmətləri əlçatan deyil",
|
||||
"gotoAnything.tryDifferentSearch": "Başqa axtarış ifadəsi sınayın",
|
||||
"iconPicker.cancel": "Ləğv et",
|
||||
"iconPicker.chooseStyle": "Üslub seçin",
|
||||
"iconPicker.emoji": "Emoci",
|
||||
"iconPicker.image": "Şəkil",
|
||||
"iconPicker.ok": "Təsdiqlə",
|
||||
"iconPicker.recent": "Son istifadə olunanlar",
|
||||
"iconPicker.recommend": "Tövsiyə olunanlar",
|
||||
"iconPicker.search": "Emoji axtar…",
|
||||
"iconPicker.tryYourLuck": "Şansını sına",
|
||||
"importApp": "Tətbiq idxal et",
|
||||
"importAppDescription": "Tətbiq paketi və ya DSL faylı",
|
||||
"importAppFormats": ".ifpkg, .yaml və .yml dəstəklənir",
|
||||
|
||||
@@ -476,6 +476,8 @@
|
||||
"operation.more": "Daha çox",
|
||||
"operation.moreActions": "Daha çox əməliyyat",
|
||||
"operation.moreActionsFor": "{{name}} üçün daha çox əməliyyat",
|
||||
"operation.moveDown": "Aşağı köçür",
|
||||
"operation.moveUp": "Yuxarı köçür",
|
||||
"operation.no": "Xeyr",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "Heç bir {{content}} tapılmadı",
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "Einige Suchdienste sind nicht verfügbar",
|
||||
"gotoAnything.tryDifferentSearch": "Versuchen Sie es mit einem anderen Suchbegriff",
|
||||
"iconPicker.cancel": "Abbrechen",
|
||||
"iconPicker.chooseStyle": "Stil wählen",
|
||||
"iconPicker.emoji": "Emoji",
|
||||
"iconPicker.image": "Bild",
|
||||
"iconPicker.ok": "OK",
|
||||
"iconPicker.recent": "Zuletzt verwendet",
|
||||
"iconPicker.recommend": "Empfohlen",
|
||||
"iconPicker.search": "Emoji suchen…",
|
||||
"iconPicker.tryYourLuck": "Auf gut Glück",
|
||||
"importApp": "App importieren",
|
||||
"importAppDescription": "App-Paket oder DSL-Datei",
|
||||
"importAppFormats": "Unterstützt .ifpkg, .yaml und .yml",
|
||||
|
||||
@@ -476,6 +476,8 @@
|
||||
"operation.more": "Mehr",
|
||||
"operation.moreActions": "Weitere Aktionen",
|
||||
"operation.moreActionsFor": "Weitere Aktionen für {{name}}",
|
||||
"operation.moveDown": "Nach unten verschieben",
|
||||
"operation.moveUp": "Nach oben verschieben",
|
||||
"operation.no": "Nein",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "Es wurden keine {{content}} gefunden",
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "Some search services unavailable",
|
||||
"gotoAnything.tryDifferentSearch": "Try a different search term",
|
||||
"iconPicker.cancel": "Cancel",
|
||||
"iconPicker.chooseStyle": "Choose Style",
|
||||
"iconPicker.emoji": "Emoji",
|
||||
"iconPicker.image": "Image",
|
||||
"iconPicker.ok": "OK",
|
||||
"iconPicker.recent": "Recent",
|
||||
"iconPicker.recommend": "Recommend",
|
||||
"iconPicker.search": "Search emoji...",
|
||||
"iconPicker.tryYourLuck": "Try your luck",
|
||||
"importApp": "Import App",
|
||||
"importAppDescription": "App package or DSL file",
|
||||
"importAppFormats": "Supports .ifpkg, .yaml, and .yml",
|
||||
|
||||
@@ -476,6 +476,8 @@
|
||||
"operation.more": "More",
|
||||
"operation.moreActions": "More actions",
|
||||
"operation.moreActionsFor": "More actions for {{name}}",
|
||||
"operation.moveDown": "Move down",
|
||||
"operation.moveUp": "Move up",
|
||||
"operation.no": "No",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "No {{content}} were found",
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "Algunos servicios de búsqueda no están disponibles",
|
||||
"gotoAnything.tryDifferentSearch": "Prueba con un término de búsqueda diferente",
|
||||
"iconPicker.cancel": "Cancelar",
|
||||
"iconPicker.chooseStyle": "Elegir estilo",
|
||||
"iconPicker.emoji": "Emoji",
|
||||
"iconPicker.image": "Imagen",
|
||||
"iconPicker.ok": "OK",
|
||||
"iconPicker.recent": "Recientes",
|
||||
"iconPicker.recommend": "Recomendados",
|
||||
"iconPicker.search": "Buscar emoji…",
|
||||
"iconPicker.tryYourLuck": "Prueba suerte",
|
||||
"importApp": "Importar App",
|
||||
"importAppDescription": "Paquete de aplicación o archivo DSL",
|
||||
"importAppFormats": "Admite .ifpkg, .yaml y .yml",
|
||||
|
||||
@@ -476,6 +476,8 @@
|
||||
"operation.more": "Más",
|
||||
"operation.moreActions": "Más acciones",
|
||||
"operation.moreActionsFor": "Más acciones para {{name}}",
|
||||
"operation.moveDown": "Mover hacia abajo",
|
||||
"operation.moveUp": "Mover hacia arriba",
|
||||
"operation.no": "No",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "No se encontraron {{content}}",
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "برخی از سرویس های جستجو دردسترس نیستند",
|
||||
"gotoAnything.tryDifferentSearch": "عبارت جستجوی دیگری را امتحان کنید",
|
||||
"iconPicker.cancel": "لغو",
|
||||
"iconPicker.chooseStyle": "انتخاب سبک",
|
||||
"iconPicker.emoji": "ایموجی",
|
||||
"iconPicker.image": "تصویر",
|
||||
"iconPicker.ok": "باشه",
|
||||
"iconPicker.recent": "اخیر",
|
||||
"iconPicker.recommend": "پیشنهادی",
|
||||
"iconPicker.search": "جستجوی ایموجی…",
|
||||
"iconPicker.tryYourLuck": "شانست را امتحان کن",
|
||||
"importApp": "وارد کردن برنامه",
|
||||
"importAppDescription": "بستهٔ برنامه یا فایل DSL",
|
||||
"importAppFormats": "از .ifpkg، .yaml و .yml پشتیبانی میکند",
|
||||
|
||||
@@ -476,6 +476,8 @@
|
||||
"operation.more": "بیشتر",
|
||||
"operation.moreActions": "اقدامات بیشتر",
|
||||
"operation.moreActionsFor": "اقدامات بیشتر برای {{name}}",
|
||||
"operation.moveDown": "انتقال به پایین",
|
||||
"operation.moveUp": "انتقال به بالا",
|
||||
"operation.no": "نه",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "هیچ {{content}} یافت نشد",
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "Certains services de recherche indisponibles",
|
||||
"gotoAnything.tryDifferentSearch": "Essayez un autre terme de recherche",
|
||||
"iconPicker.cancel": "Annuler",
|
||||
"iconPicker.chooseStyle": "Choisir un style",
|
||||
"iconPicker.emoji": "Emoji",
|
||||
"iconPicker.image": "Image",
|
||||
"iconPicker.ok": "OK",
|
||||
"iconPicker.recent": "Récents",
|
||||
"iconPicker.recommend": "Recommandés",
|
||||
"iconPicker.search": "Rechercher un emoji…",
|
||||
"iconPicker.tryYourLuck": "J’ai de la chance",
|
||||
"importApp": "Importer l'App",
|
||||
"importAppDescription": "Paquet d’application ou fichier DSL",
|
||||
"importAppFormats": "Formats pris en charge : .ifpkg, .yaml et .yml",
|
||||
|
||||
@@ -476,6 +476,8 @@
|
||||
"operation.more": "Plus",
|
||||
"operation.moreActions": "Plus d'actions",
|
||||
"operation.moreActionsFor": "Plus d'actions pour {{name}}",
|
||||
"operation.moveDown": "Déplacer vers le bas",
|
||||
"operation.moveUp": "Déplacer vers le haut",
|
||||
"operation.no": "Non",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "Aucun {{content}} n'a été trouvé",
|
||||
|
||||
@@ -110,9 +110,14 @@
|
||||
"gotoAnything.someServicesUnavailable": "कुछ खोज सेवाएँ उपलब्ध नहीं हैं",
|
||||
"gotoAnything.tryDifferentSearch": "एक अलग खोज शब्द आजमाएँ",
|
||||
"iconPicker.cancel": "रद्द करें",
|
||||
"iconPicker.chooseStyle": "शैली चुनें",
|
||||
"iconPicker.emoji": "इमोजी",
|
||||
"iconPicker.image": "छवि",
|
||||
"iconPicker.ok": "ठीक है",
|
||||
"iconPicker.recent": "हाल के",
|
||||
"iconPicker.recommend": "सुझाए गए",
|
||||
"iconPicker.search": "इमोजी खोजें…",
|
||||
"iconPicker.tryYourLuck": "किस्मत आज़माएँ",
|
||||
"importApp": "ऐप आयात करें",
|
||||
"importAppDescription": "ऐप पैकेज या DSL फ़ाइल",
|
||||
"importAppFormats": ".ifpkg, .yaml और .yml समर्थित हैं",
|
||||
|
||||
Loaded 100 of 135 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user