mirror of
https://github.com/langgenius/dify.git
synced 2026-06-19 22:27:41 +08:00
Compare commits
4 Commits
test/cli-e
...
deploy/ent
| Author | SHA1 | Date | |
|---|---|---|---|
| f533e992d4 | |||
| 1427b0b098 | |||
| 2893adf5e4 | |||
| eb2aaf2ac1 |
@ -63,6 +63,8 @@ class OpenApiErrorCode(StrEnum):
|
||||
FILE_EXTENSION_BLOCKED = "file_extension_blocked"
|
||||
MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded"
|
||||
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
|
||||
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
|
||||
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
@ -239,3 +241,16 @@ class MemberLicenseExceeded(OpenApiError): # noqa: N818
|
||||
error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED
|
||||
description = "Workspace member license capacity reached."
|
||||
hint = "Contact your workspace administrator to expand the license seat count."
|
||||
|
||||
|
||||
class HumanInputFormNotFound(OpenApiError): # noqa: N818
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.HUMAN_INPUT_FORM_NOT_FOUND
|
||||
description = "No human-input form matches this token. It may be wrong, expired, or already submitted."
|
||||
|
||||
|
||||
class RecipientSurfaceMismatch(OpenApiError): # noqa: N818
|
||||
code = 403
|
||||
error_code = OpenApiErrorCode.RECIPIENT_SURFACE_MISMATCH
|
||||
description = "This form's recipient can't be submitted via the OpenAPI surface."
|
||||
hint = "Action it through its channel (web app or console)."
|
||||
|
||||
@ -12,16 +12,20 @@ import logging
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import BadRequest, NotFound
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
|
||||
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
|
||||
from core.workflow.human_input_policy import (
|
||||
HumanInputSurface,
|
||||
is_recipient_type_allowed_for_surface,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from libs.helper import to_timestamp
|
||||
from libs.oauth_bearer import Scope
|
||||
@ -47,12 +51,12 @@ def _jsonify_form_definition(form) -> Response:
|
||||
|
||||
def _ensure_form_belongs_to_app(form, app_model: App) -> None:
|
||||
if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id:
|
||||
raise NotFound("Form not found")
|
||||
raise HumanInputFormNotFound()
|
||||
|
||||
|
||||
def _ensure_form_is_allowed_for_openapi(form) -> None:
|
||||
if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.OPENAPI):
|
||||
raise NotFound("Form not found")
|
||||
raise RecipientSurfaceMismatch()
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/form/human_input/<string:form_token>")
|
||||
@ -60,11 +64,11 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
def get(self, app_id: str, form_token: str, *, auth_data: AuthData):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
app_model, _caller, _caller_kind = auth_data.require_app_context()
|
||||
service = HumanInputService(db.engine)
|
||||
form = service.get_form_by_token(form_token)
|
||||
if form is None:
|
||||
raise NotFound("Form not found")
|
||||
raise HumanInputFormNotFound()
|
||||
|
||||
_ensure_form_belongs_to_app(form, app_model)
|
||||
_ensure_form_is_allowed_for_openapi(form)
|
||||
@ -80,7 +84,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
service = HumanInputService(db.engine)
|
||||
form = service.get_form_by_token(form_token)
|
||||
if form is None:
|
||||
raise NotFound("Form not found")
|
||||
raise HumanInputFormNotFound()
|
||||
|
||||
_ensure_form_belongs_to_app(form, app_model)
|
||||
_ensure_form_is_allowed_for_openapi(form)
|
||||
@ -106,6 +110,6 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
submission_end_user_id=submission_end_user_id,
|
||||
)
|
||||
except FormNotFoundError:
|
||||
raise NotFound("Form not found")
|
||||
raise HumanInputFormNotFound()
|
||||
|
||||
return FormSubmitResponse()
|
||||
|
||||
@ -51,8 +51,11 @@ from core.tools.entities.tool_entities import ToolProviderType
|
||||
from core.tools.tool_manager import ToolManager
|
||||
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
from core.workflow.human_input_forms import load_form_tokens_by_form_id
|
||||
from core.workflow.human_input_forms import (
|
||||
load_form_dispositions_by_form_id,
|
||||
)
|
||||
from core.workflow.human_input_policy import (
|
||||
FormDisposition,
|
||||
HumanInputSurface,
|
||||
enrich_human_input_pause_reasons,
|
||||
resolve_human_input_pause_reason_inputs,
|
||||
@ -340,13 +343,14 @@ class WorkflowResponseConverter:
|
||||
human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)]
|
||||
expiration_times_by_form_id: dict[str, datetime] = {}
|
||||
display_in_ui_by_form_id: dict[str, bool] = {}
|
||||
form_token_by_form_id: dict[str, str] = {}
|
||||
dispositions_by_form_id: dict[str, FormDisposition] = {}
|
||||
if human_input_form_ids:
|
||||
stmt = select(
|
||||
HumanInputForm.id,
|
||||
HumanInputForm.expiration_time,
|
||||
HumanInputForm.form_definition,
|
||||
).where(HumanInputForm.id.in_(human_input_form_ids))
|
||||
hitl_surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from)
|
||||
with Session(bind=db.engine) as session:
|
||||
for form_id, expiration_time, form_definition in session.execute(stmt):
|
||||
expiration_times_by_form_id[str(form_id)] = expiration_time
|
||||
@ -355,17 +359,17 @@ class WorkflowResponseConverter:
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
definition_payload = {}
|
||||
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
|
||||
form_token_by_form_id = load_form_tokens_by_form_id(
|
||||
dispositions_by_form_id = load_form_dispositions_by_form_id(
|
||||
human_input_form_ids,
|
||||
session=session,
|
||||
surface=_INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from),
|
||||
surface=hitl_surface,
|
||||
)
|
||||
|
||||
# Reconnect paths must preserve the same pause-reason contract as live streams;
|
||||
# otherwise clients see schema drift after resume.
|
||||
pause_reasons = enrich_human_input_pause_reasons(
|
||||
pause_reasons,
|
||||
form_tokens_by_form_id=form_token_by_form_id,
|
||||
dispositions_by_form_id=dispositions_by_form_id,
|
||||
expiration_times_by_form_id={
|
||||
form_id: int(expiration_time.timestamp())
|
||||
for form_id, expiration_time in expiration_times_by_form_id.items()
|
||||
@ -379,6 +383,7 @@ class WorkflowResponseConverter:
|
||||
expiration_time = expiration_times_by_form_id.get(reason.form_id)
|
||||
if expiration_time is None:
|
||||
raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}")
|
||||
disposition = dispositions_by_form_id.get(reason.form_id)
|
||||
responses.append(
|
||||
HumanInputRequiredResponse(
|
||||
task_id=task_id,
|
||||
@ -391,7 +396,8 @@ class WorkflowResponseConverter:
|
||||
inputs=reason.inputs,
|
||||
actions=reason.actions,
|
||||
display_in_ui=display_in_ui_by_form_id.get(reason.form_id, False),
|
||||
form_token=form_token_by_form_id.get(reason.form_id),
|
||||
form_token=disposition.form_token if disposition else None,
|
||||
approval_channels=list(disposition.approval_channels) if disposition else [],
|
||||
resolved_default_values=reason.resolved_default_values,
|
||||
expiration_time=int(expiration_time.timestamp()),
|
||||
),
|
||||
|
||||
@ -288,6 +288,7 @@ class HumanInputRequiredResponse(StreamResponse):
|
||||
actions: Sequence[UserActionConfig] = Field(default_factory=list)
|
||||
display_in_ui: bool = False
|
||||
form_token: str | None = None
|
||||
approval_channels: list[str] = Field(default_factory=list)
|
||||
resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
|
||||
expiration_time: int = Field(..., description="Unix timestamp in seconds")
|
||||
|
||||
@ -311,6 +312,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
|
||||
actions: Sequence[UserActionConfig] = Field(default_factory=list)
|
||||
display_in_ui: bool = False
|
||||
form_token: str | None = None
|
||||
approval_channels: list[str] = Field(default_factory=list)
|
||||
resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
|
||||
expiration_time: int
|
||||
|
||||
@ -325,6 +327,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
|
||||
actions=data.actions,
|
||||
display_in_ui=data.display_in_ui,
|
||||
form_token=data.form_token,
|
||||
approval_channels=data.approval_channels,
|
||||
resolved_default_values=data.resolved_default_values,
|
||||
expiration_time=data.expiration_time,
|
||||
)
|
||||
|
||||
@ -12,60 +12,61 @@ from collections.abc import Sequence
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.workflow.human_input_policy import HumanInputSurface, get_preferred_form_token
|
||||
from core.workflow.human_input_policy import (
|
||||
FormDisposition,
|
||||
HumanInputSurface,
|
||||
disposition_for_surface,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from models.human_input import HumanInputFormRecipient, RecipientType
|
||||
|
||||
|
||||
def load_form_dispositions_by_form_id(
|
||||
form_ids: Sequence[str],
|
||||
*,
|
||||
session: Session | None = None,
|
||||
surface: HumanInputSurface | None = None,
|
||||
) -> dict[str, FormDisposition]:
|
||||
"""Resolve each paused form's resume token and approval channels for `surface`."""
|
||||
unique_form_ids = list(dict.fromkeys(form_ids))
|
||||
if not unique_form_ids:
|
||||
return {}
|
||||
|
||||
if session is not None:
|
||||
return _load_form_dispositions_by_form_id(session, unique_form_ids, surface=surface)
|
||||
|
||||
with Session(bind=db.engine, expire_on_commit=False) as new_session:
|
||||
return _load_form_dispositions_by_form_id(new_session, unique_form_ids, surface=surface)
|
||||
|
||||
|
||||
def _load_form_dispositions_by_form_id(
|
||||
session: Session,
|
||||
form_ids: Sequence[str],
|
||||
*,
|
||||
surface: HumanInputSurface | None,
|
||||
) -> dict[str, FormDisposition]:
|
||||
recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {}
|
||||
stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
|
||||
for recipient in session.scalars(stmt):
|
||||
recipients_by_form_id.setdefault(recipient.form_id, []).append(
|
||||
(recipient.recipient_type, recipient.access_token or "")
|
||||
)
|
||||
return {
|
||||
form_id: disposition_for_surface(recipients, surface=surface)
|
||||
for form_id, recipients in recipients_by_form_id.items()
|
||||
}
|
||||
|
||||
|
||||
def load_form_tokens_by_form_id(
|
||||
form_ids: Sequence[str],
|
||||
*,
|
||||
session: Session | None = None,
|
||||
surface: HumanInputSurface | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Load the preferred access token for each human input form."""
|
||||
unique_form_ids = list(dict.fromkeys(form_ids))
|
||||
if not unique_form_ids:
|
||||
return {}
|
||||
|
||||
if session is not None:
|
||||
return _load_form_tokens_by_form_id(session, unique_form_ids, surface=surface)
|
||||
|
||||
with Session(bind=db.engine, expire_on_commit=False) as new_session:
|
||||
return _load_form_tokens_by_form_id(new_session, unique_form_ids, surface=surface)
|
||||
|
||||
|
||||
def _load_form_tokens_by_form_id(
|
||||
session: Session,
|
||||
form_ids: Sequence[str],
|
||||
*,
|
||||
surface: HumanInputSurface | None = None,
|
||||
) -> dict[str, str]:
|
||||
recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {}
|
||||
stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
|
||||
for recipient in session.scalars(stmt):
|
||||
if not recipient.access_token:
|
||||
continue
|
||||
recipients_by_form_id.setdefault(recipient.form_id, []).append(
|
||||
(recipient.recipient_type, recipient.access_token)
|
||||
)
|
||||
|
||||
tokens_by_form_id: dict[str, str] = {}
|
||||
for form_id, recipients in recipients_by_form_id.items():
|
||||
token = _get_surface_form_token(recipients, surface=surface)
|
||||
if token is not None:
|
||||
tokens_by_form_id[form_id] = token
|
||||
return tokens_by_form_id
|
||||
|
||||
|
||||
def _get_surface_form_token(
|
||||
recipients: Sequence[tuple[RecipientType, str]],
|
||||
*,
|
||||
surface: HumanInputSurface | None,
|
||||
) -> str | None:
|
||||
if surface in {HumanInputSurface.SERVICE_API, HumanInputSurface.OPENAPI}:
|
||||
for recipient_type, token in recipients:
|
||||
if recipient_type == RecipientType.STANDALONE_WEB_APP and token:
|
||||
return token
|
||||
|
||||
return get_preferred_form_token(recipients)
|
||||
"""Resume tokens only, for callers that don't surface approval channels."""
|
||||
dispositions = load_form_dispositions_by_form_id(form_ids, session=session, surface=surface)
|
||||
return {
|
||||
form_id: disposition.form_token
|
||||
for form_id, disposition in dispositions.items()
|
||||
if disposition.form_token is not None
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from enum import StrEnum
|
||||
from typing import Any
|
||||
from typing import Any, NamedTuple
|
||||
|
||||
from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType
|
||||
from graphon.nodes.human_input.entities import FormInputConfig, SelectInputConfig
|
||||
@ -20,7 +20,7 @@ class HumanInputSurface(StrEnum):
|
||||
|
||||
# SERVICE_API and OPENAPI are intentionally narrower than CONSOLE: token callers
|
||||
# should only be able to act on end-user web forms, not internal console flows.
|
||||
_ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = {
|
||||
ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = {
|
||||
HumanInputSurface.SERVICE_API: frozenset({RecipientType.STANDALONE_WEB_APP}),
|
||||
HumanInputSurface.CONSOLE: frozenset({RecipientType.CONSOLE, RecipientType.BACKSTAGE}),
|
||||
HumanInputSurface.OPENAPI: frozenset({RecipientType.STANDALONE_WEB_APP}),
|
||||
@ -41,7 +41,7 @@ def is_recipient_type_allowed_for_surface(
|
||||
) -> bool:
|
||||
if recipient_type is None:
|
||||
return False
|
||||
return recipient_type in _ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
|
||||
return recipient_type in ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
|
||||
|
||||
|
||||
def get_preferred_form_token(
|
||||
@ -59,10 +59,37 @@ def get_preferred_form_token(
|
||||
return chosen_token
|
||||
|
||||
|
||||
class FormDisposition(NamedTuple):
|
||||
"""How a paused form resolves for one API surface.
|
||||
|
||||
A form's recipients split into those the surface may act on (yielding a resume
|
||||
`form_token`) and those it may not (their channels named in `approval_channels`
|
||||
so the caller is told where approval actually happens instead).
|
||||
"""
|
||||
|
||||
form_token: str | None
|
||||
approval_channels: list[str]
|
||||
|
||||
|
||||
def disposition_for_surface(
|
||||
recipients: Sequence[tuple[RecipientType, str]],
|
||||
*,
|
||||
surface: HumanInputSurface | None,
|
||||
) -> FormDisposition:
|
||||
if surface is None:
|
||||
return FormDisposition(form_token=get_preferred_form_token(recipients), approval_channels=[])
|
||||
allowed = ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
|
||||
actionable = [(recipient_type, token) for recipient_type, token in recipients if recipient_type in allowed]
|
||||
blocked_channels = {
|
||||
recipient_type.approval_channel_label for recipient_type, _ in recipients if recipient_type not in allowed
|
||||
}
|
||||
return FormDisposition(form_token=get_preferred_form_token(actionable), approval_channels=sorted(blocked_channels))
|
||||
|
||||
|
||||
def enrich_human_input_pause_reasons(
|
||||
reasons: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
form_tokens_by_form_id: Mapping[str, str],
|
||||
dispositions_by_form_id: Mapping[str, FormDisposition],
|
||||
expiration_times_by_form_id: Mapping[str, int],
|
||||
) -> list[dict[str, Any]]:
|
||||
enriched: list[dict[str, Any]] = []
|
||||
@ -71,7 +98,9 @@ def enrich_human_input_pause_reasons(
|
||||
if updated.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED:
|
||||
form_id = updated.get("form_id")
|
||||
if isinstance(form_id, str):
|
||||
updated["form_token"] = form_tokens_by_form_id.get(form_id)
|
||||
disposition = dispositions_by_form_id.get(form_id)
|
||||
updated["form_token"] = disposition.form_token if disposition else None
|
||||
updated["approval_channels"] = list(disposition.approval_channels) if disposition else []
|
||||
expiration_time = expiration_times_by_form_id.get(form_id)
|
||||
if expiration_time is not None:
|
||||
updated["expiration_time"] = expiration_time
|
||||
|
||||
@ -135,19 +135,32 @@ class HumanInputDelivery(DefaultFieldsMixin, Base):
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
# Second value = approval-channel label (surfaced in `approval_channels`).
|
||||
# EMAIL_MEMBER member means that the
|
||||
EMAIL_MEMBER = "email_member"
|
||||
EMAIL_EXTERNAL = "email_external"
|
||||
EMAIL_MEMBER = "email_member", "email"
|
||||
EMAIL_EXTERNAL = "email_external", "email"
|
||||
# STANDALONE_WEB_APP is used by the standalone web app.
|
||||
#
|
||||
# It's not used while running workflows / chatflows containing HumanInput
|
||||
# node inside console.
|
||||
STANDALONE_WEB_APP = "standalone_web_app"
|
||||
STANDALONE_WEB_APP = "standalone_web_app", "web_app"
|
||||
# CONSOLE is used while running workflows / chatflows containing HumanInput
|
||||
# node inside console. (E.G. running installed apps or debugging workflows / chatflows)
|
||||
CONSOLE = "console"
|
||||
CONSOLE = "console", "console"
|
||||
# BACKSTAGE is used for backstage input inside console.
|
||||
BACKSTAGE = "backstage"
|
||||
BACKSTAGE = "backstage", "console"
|
||||
|
||||
_approval_channel_label: str
|
||||
|
||||
def __new__(cls, value: str, approval_channel_label: str) -> "RecipientType":
|
||||
member = str.__new__(cls, value)
|
||||
member._value_ = value
|
||||
member._approval_channel_label = approval_channel_label
|
||||
return member
|
||||
|
||||
@property
|
||||
def approval_channel_label(self) -> str:
|
||||
return self._approval_channel_label
|
||||
|
||||
|
||||
@final
|
||||
|
||||
@ -23,8 +23,11 @@ from core.app.entities.task_entities import (
|
||||
WorkflowStartStreamResponse,
|
||||
)
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
|
||||
from core.workflow.human_input_forms import load_form_tokens_by_form_id
|
||||
from core.workflow.human_input_forms import (
|
||||
load_form_dispositions_by_form_id,
|
||||
)
|
||||
from core.workflow.human_input_policy import (
|
||||
FormDisposition,
|
||||
HumanInputSurface,
|
||||
enrich_human_input_pause_reasons,
|
||||
resolve_human_input_pause_reason_inputs,
|
||||
@ -359,7 +362,7 @@ def _build_human_input_required_events(
|
||||
|
||||
expiration_times_by_form_id: dict[str, int] = {}
|
||||
display_in_ui_by_form_id: dict[str, bool] = {}
|
||||
form_tokens_by_form_id: dict[str, str] = {}
|
||||
dispositions_by_form_id: dict[str, FormDisposition] = {}
|
||||
if human_input_form_ids and session_maker is not None:
|
||||
stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where(
|
||||
HumanInputForm.id.in_(human_input_form_ids)
|
||||
@ -372,7 +375,7 @@ def _build_human_input_required_events(
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
definition_payload = {}
|
||||
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
|
||||
form_tokens_by_form_id = load_form_tokens_by_form_id(
|
||||
dispositions_by_form_id = load_form_dispositions_by_form_id(
|
||||
human_input_form_ids,
|
||||
session=session,
|
||||
surface=human_input_surface,
|
||||
@ -393,6 +396,7 @@ def _build_human_input_required_events(
|
||||
reason.inputs,
|
||||
variable_pool=variable_pool,
|
||||
)
|
||||
disposition = dispositions_by_form_id.get(form_id)
|
||||
|
||||
response = HumanInputRequiredResponse(
|
||||
task_id=task_id,
|
||||
@ -405,7 +409,8 @@ def _build_human_input_required_events(
|
||||
inputs=resolved_inputs,
|
||||
actions=reason.actions,
|
||||
display_in_ui=display_in_ui_by_form_id.get(form_id, False),
|
||||
form_token=form_tokens_by_form_id.get(form_id),
|
||||
form_token=disposition.form_token if disposition else None,
|
||||
approval_channels=list(disposition.approval_channels) if disposition else [],
|
||||
resolved_default_values=reason.resolved_default_values,
|
||||
expiration_time=expiration_time,
|
||||
),
|
||||
@ -493,11 +498,11 @@ def _build_pause_event(
|
||||
for form_id in [reason.get("form_id")]
|
||||
if isinstance(form_id, str)
|
||||
]
|
||||
form_tokens_by_form_id: dict[str, str] = {}
|
||||
dispositions_by_form_id: dict[str, FormDisposition] = {}
|
||||
expiration_times_by_form_id: dict[str, int] = {}
|
||||
if human_input_form_ids and session_maker is not None:
|
||||
with session_maker() as session:
|
||||
form_tokens_by_form_id = load_form_tokens_by_form_id(
|
||||
dispositions_by_form_id = load_form_dispositions_by_form_id(
|
||||
human_input_form_ids,
|
||||
session=session,
|
||||
surface=human_input_surface,
|
||||
@ -512,7 +517,7 @@ def _build_pause_event(
|
||||
# otherwise clients see schema drift after resume.
|
||||
reasons = enrich_human_input_pause_reasons(
|
||||
reasons,
|
||||
form_tokens_by_form_id=form_tokens_by_form_id,
|
||||
dispositions_by_form_id=dispositions_by_form_id,
|
||||
expiration_times_by_form_id=expiration_times_by_form_id,
|
||||
)
|
||||
|
||||
|
||||
@ -26,11 +26,13 @@ from controllers.openapi._errors import (
|
||||
ErrorBody,
|
||||
ErrorDetail,
|
||||
FilenameNotExists,
|
||||
HumanInputFormNotFound,
|
||||
MemberLicenseExceeded,
|
||||
MemberLimitExceeded,
|
||||
OpenApiError,
|
||||
OpenApiErrorCode,
|
||||
OpenApiErrorFormatter,
|
||||
RecipientSurfaceMismatch,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
@ -319,6 +321,8 @@ ERROR_MATRIX = [
|
||||
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
|
||||
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
|
||||
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
|
||||
(HumanInputFormNotFound(), 404, "form_not_found"),
|
||||
(RecipientSurfaceMismatch(), 403, "recipient_surface_mismatch"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@ -11,8 +11,9 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound, UnprocessableEntity
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models.human_input import RecipientType
|
||||
@ -89,7 +90,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(HumanInputFormNotFound):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
@ -101,7 +102,10 @@ class TestOpenApiHumanInputFormGet:
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
form = SimpleNamespace(
|
||||
app_id="other-app", tenant_id="tenant-1", expiration_time=datetime(2099, 1, 1, tzinfo=UTC)
|
||||
app_id="other-app",
|
||||
tenant_id="tenant-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
expiration_time=datetime(2099, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
service_mock = Mock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
@ -114,7 +118,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(HumanInputFormNotFound):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
@ -142,7 +146,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(RecipientSurfaceMismatch):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
@ -234,6 +238,38 @@ class TestOpenApiHumanInputFormPost:
|
||||
)
|
||||
assert result == ({}, 200)
|
||||
|
||||
def test_post_standalone_web_app_recipient_submits(
|
||||
self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP)
|
||||
service_mock = Mock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
|
||||
module = sys.modules["controllers.openapi.human_input_form"]
|
||||
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = OpenApiWorkflowHumanInputFormApi()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="anyone")
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/apps/app-1/form/human_input/tok-1",
|
||||
method="POST",
|
||||
json={"action": "approve", "inputs": {}},
|
||||
):
|
||||
result = api.post.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
form_token="tok-1",
|
||||
auth_data=_make_auth_data(app_model, caller, "end_user"),
|
||||
)
|
||||
|
||||
service_mock.submit_form_by_token.assert_called_once()
|
||||
assert result == ({}, 200)
|
||||
|
||||
def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline):
|
||||
"""Malformed body → 422 via @accepts (was an unmapped pydantic error → 500)."""
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
@ -175,6 +175,7 @@ class TestAdvancedChatGenerateTaskPipeline:
|
||||
"actions": [{"id": "approve", "title": "Approve", "button_style": "default"}],
|
||||
"display_in_ui": True,
|
||||
"form_token": "token-1",
|
||||
"approval_channels": [],
|
||||
"resolved_default_values": {},
|
||||
"expiration_time": 123,
|
||||
}
|
||||
|
||||
@ -26,6 +26,26 @@ from models.account import Account
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Stub session: `execute` feeds the form-expiration query, `scalars` the recipients."""
|
||||
|
||||
def __init__(self, *, execute_rows=(), scalars_rows=()):
|
||||
self._execute_rows = execute_rows
|
||||
self._scalars_rows = scalars_rows
|
||||
|
||||
def execute(self, _stmt):
|
||||
return list(self._execute_rows)
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return list(self._scalars_rows)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _RecordingWorkflowAppRunner(WorkflowAppRunner):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@ -97,11 +117,11 @@ def test_graph_run_paused_event_emits_queue_pause_event():
|
||||
assert queue_event.paused_nodes == ["node-pause-1"]
|
||||
|
||||
|
||||
def _build_converter():
|
||||
def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API):
|
||||
application_generate_entity = SimpleNamespace(
|
||||
inputs={},
|
||||
files=[],
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
invoke_from=invoke_from,
|
||||
app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"),
|
||||
)
|
||||
system_variables = build_system_variables(
|
||||
@ -131,32 +151,15 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
|
||||
)
|
||||
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session = _FakeSession(
|
||||
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
|
||||
scalars_rows=[
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.CONSOLE, access_token="console-token"),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
class _FakeSession:
|
||||
def execute(self, _stmt):
|
||||
return [("form-1", expiration_time, '{"display_in_ui": true}')]
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return [
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
access_token="backstage-token",
|
||||
),
|
||||
]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
|
||||
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
reason = HumanInputRequired(
|
||||
@ -195,10 +198,92 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
|
||||
assert hi_resp.data.inputs[0].output_variable_name == "field"
|
||||
assert hi_resp.data.actions[0].id == "approve"
|
||||
assert hi_resp.data.display_in_ui is True
|
||||
assert hi_resp.data.form_token == "backstage-token"
|
||||
assert hi_resp.data.form_token is None
|
||||
assert hi_resp.data.approval_channels == ["console"]
|
||||
assert hi_resp.data.expiration_time == int(expiration_time.timestamp())
|
||||
|
||||
|
||||
def _build_paused_human_input_response(monkeypatch, recipients):
|
||||
"""Drive the live OPENAPI pause path with the given recipients via a fake session."""
|
||||
converter = _build_converter(invoke_from=InvokeFrom.OPENAPI)
|
||||
converter.workflow_start_to_stream_response(
|
||||
task_id="task",
|
||||
workflow_run_id="run-id",
|
||||
workflow_id="workflow-id",
|
||||
reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session = _FakeSession(
|
||||
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
|
||||
scalars_rows=list(recipients),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
|
||||
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="Rendered",
|
||||
inputs=[ParagraphInputConfig(output_variable_name="field")],
|
||||
actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
node_id="node-id",
|
||||
node_title="Human Step",
|
||||
)
|
||||
queue_event = QueueWorkflowPausedEvent(
|
||||
reasons=[reason],
|
||||
outputs={},
|
||||
paused_nodes=["node-id"],
|
||||
)
|
||||
|
||||
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
|
||||
responses = converter.workflow_pause_to_stream_response(
|
||||
event=queue_event,
|
||||
task_id="task",
|
||||
graph_runtime_state=runtime_state,
|
||||
)
|
||||
assert isinstance(responses[0], HumanInputRequiredResponse)
|
||||
return responses
|
||||
|
||||
|
||||
def test_openapi_pause_without_web_app_recipient_emits_approval_channels(monkeypatch: pytest.MonkeyPatch):
|
||||
responses = _build_paused_human_input_response(
|
||||
monkeypatch,
|
||||
recipients=[
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
hi_resp = responses[0]
|
||||
assert hi_resp.data.form_token is None
|
||||
assert hi_resp.data.approval_channels == ["console", "email"]
|
||||
|
||||
pause_resp = responses[-1]
|
||||
assert pause_resp.data.reasons[0]["approval_channels"] == ["console", "email"]
|
||||
|
||||
|
||||
def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(monkeypatch: pytest.MonkeyPatch):
|
||||
responses = _build_paused_human_input_response(
|
||||
monkeypatch,
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-app-token",
|
||||
),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
hi_resp = responses[0]
|
||||
assert hi_resp.data.form_token == "web-app-token"
|
||||
assert hi_resp.data.approval_channels == ["console"]
|
||||
|
||||
pause_resp = responses[-1]
|
||||
assert pause_resp.data.reasons[0]["approval_channels"] == ["console"]
|
||||
|
||||
|
||||
def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch):
|
||||
converter = _build_converter()
|
||||
converter.workflow_start_to_stream_response(
|
||||
@ -209,21 +294,9 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatc
|
||||
)
|
||||
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session = _FakeSession(execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')])
|
||||
|
||||
class _FakeSession:
|
||||
def execute(self, _stmt):
|
||||
return [("form-1", expiration_time, '{"display_in_ui": true}')]
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
|
||||
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
reason = HumanInputRequired(
|
||||
|
||||
@ -134,6 +134,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
"actions": [],
|
||||
"display_in_ui": False,
|
||||
"form_token": None,
|
||||
"approval_channels": [],
|
||||
"resolved_default_values": {},
|
||||
"expiration_time": 1,
|
||||
}
|
||||
|
||||
@ -32,6 +32,7 @@ from models.human_input import (
|
||||
EmailMemberRecipientPayload,
|
||||
HumanInputFormRecipient,
|
||||
RecipientType,
|
||||
StandaloneWebAppRecipientPayload,
|
||||
)
|
||||
|
||||
|
||||
@ -307,6 +308,9 @@ class _DummyRecipient:
|
||||
recipient_type: RecipientType
|
||||
access_token: str
|
||||
form: _DummyForm | None = None
|
||||
recipient_payload: str = dataclasses.field(
|
||||
default_factory=lambda: StandaloneWebAppRecipientPayload().model_dump_json()
|
||||
)
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
|
||||
@ -0,0 +1,63 @@
|
||||
import pytest
|
||||
|
||||
from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons
|
||||
from graphon.entities.pause_reason import PauseReasonType
|
||||
|
||||
_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dispositions", "expected_token", "expected_channels"),
|
||||
[
|
||||
({"f1": FormDisposition(form_token=None, approval_channels=["console", "email"])}, None, ["console", "email"]),
|
||||
({"f1": FormDisposition(form_token="tok", approval_channels=[])}, "tok", []),
|
||||
# form_id absent from the map (no recipient rows) falls back to no token, no channels.
|
||||
({}, None, []),
|
||||
],
|
||||
)
|
||||
def test_enrich_projects_disposition_onto_reason(dispositions, expected_token, expected_channels):
|
||||
out = enrich_human_input_pause_reasons(
|
||||
[dict(_HUMAN_INPUT_REASON)],
|
||||
dispositions_by_form_id=dispositions,
|
||||
expiration_times_by_form_id={},
|
||||
)
|
||||
|
||||
assert out[0]["form_token"] == expected_token
|
||||
assert out[0]["approval_channels"] == expected_channels
|
||||
|
||||
|
||||
def test_enrich_leaves_non_human_input_reasons_untouched():
|
||||
reason = {"TYPE": "something_else", "form_id": "f1"}
|
||||
|
||||
out = enrich_human_input_pause_reasons(
|
||||
[reason],
|
||||
dispositions_by_form_id={"f1": FormDisposition(form_token="tok", approval_channels=["email"])},
|
||||
expiration_times_by_form_id={},
|
||||
)
|
||||
|
||||
assert out[0] == reason
|
||||
assert "form_token" not in out[0]
|
||||
assert "approval_channels" not in out[0]
|
||||
|
||||
|
||||
def test_pause_reason_payload_carries_approval_channels_through_factory():
|
||||
# from_response_data maps fields by hand; this guards approval_channels/form_token
|
||||
# (the fields this feature added) against being dropped in that mapping.
|
||||
from core.app.entities.task_entities import (
|
||||
HumanInputRequiredPauseReasonPayload,
|
||||
HumanInputRequiredResponse,
|
||||
)
|
||||
|
||||
data = HumanInputRequiredResponse.Data(
|
||||
form_id="f",
|
||||
node_id="n",
|
||||
node_title="t",
|
||||
form_content="c",
|
||||
expiration_time=123,
|
||||
form_token=None,
|
||||
approval_channels=["console"],
|
||||
)
|
||||
payload = HumanInputRequiredPauseReasonPayload.from_response_data(data)
|
||||
|
||||
assert payload.approval_channels == ["console"]
|
||||
assert payload.form_token is None
|
||||
@ -1,7 +1,16 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.workflow.human_input_forms import _load_form_tokens_by_form_id, load_form_tokens_by_form_id
|
||||
from core.workflow.human_input_policy import HumanInputSurface
|
||||
import pytest
|
||||
|
||||
from core.workflow.human_input_forms import (
|
||||
load_form_dispositions_by_form_id,
|
||||
load_form_tokens_by_form_id,
|
||||
)
|
||||
from core.workflow.human_input_policy import (
|
||||
FormDisposition,
|
||||
HumanInputSurface,
|
||||
disposition_for_surface,
|
||||
)
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
@ -13,91 +22,100 @@ class _FakeSession:
|
||||
return self._recipients
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_prefers_backstage_token() -> None:
|
||||
def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | None) -> SimpleNamespace:
|
||||
return SimpleNamespace(form_id=form_id, recipient_type=recipient_type, access_token=access_token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("surface", "expected_token"),
|
||||
[
|
||||
# Unfiltered (no surface) picks the highest-priority recipient: backstage.
|
||||
(None, "backstage-token"),
|
||||
# SERVICE_API may only act on the web-app recipient.
|
||||
(HumanInputSurface.SERVICE_API, "web-token"),
|
||||
],
|
||||
)
|
||||
def test_load_form_tokens_picks_token_for_surface(surface, expected_token) -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
access_token="backstage-token",
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
|
||||
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
|
||||
]
|
||||
)
|
||||
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session) == {"form-1": "backstage-token"}
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=surface) == {"form-1": expected_token}
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_ignores_unsupported_recipients() -> None:
|
||||
def test_load_form_tokens_drops_forms_without_actionable_token() -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.EMAIL_MEMBER,
|
||||
access_token="email-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token=None,
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.EMAIL_MEMBER, "email-token"),
|
||||
_recipient("form-1", RecipientType.CONSOLE, None),
|
||||
]
|
||||
)
|
||||
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session) == {}
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_uses_shared_priority() -> None:
|
||||
def test_load_form_tokens_service_api_surface_uses_web_token() -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
|
||||
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
|
||||
]
|
||||
)
|
||||
|
||||
assert _load_form_tokens_by_form_id(session, ["form-1"]) == {"form-1": "console-token"}
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=HumanInputSurface.SERVICE_API) == {
|
||||
"form-1": "web-token"
|
||||
}
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_uses_web_token_for_service_api_surface() -> None:
|
||||
def test_load_dispositions_openapi_webapp_form_is_resumable() -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
access_token="backstage-token",
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
|
||||
]
|
||||
)
|
||||
|
||||
assert load_form_tokens_by_form_id(
|
||||
["form-1"],
|
||||
session=session,
|
||||
surface=HumanInputSurface.SERVICE_API,
|
||||
) == {"form-1": "web-token"}
|
||||
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
|
||||
"form-1": FormDisposition(form_token="web-token", approval_channels=["console"])
|
||||
}
|
||||
|
||||
|
||||
def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token() -> None:
|
||||
session = _FakeSession([_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")])
|
||||
|
||||
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
|
||||
"form-1": FormDisposition(form_token=None, approval_channels=["console"])
|
||||
}
|
||||
|
||||
|
||||
# disposition_for_surface partitions recipients into a surface-actionable resume
|
||||
# token plus the approval channels of the recipients the surface may NOT act on.
|
||||
_WEB = (RecipientType.STANDALONE_WEB_APP, "tok_web")
|
||||
_BACKSTAGE = (RecipientType.BACKSTAGE, "tok_b")
|
||||
_CONSOLE = (RecipientType.CONSOLE, "tok_c")
|
||||
_EMAIL_MEMBER = (RecipientType.EMAIL_MEMBER, "t1")
|
||||
_EMAIL_EXTERNAL = (RecipientType.EMAIL_EXTERNAL, "t2")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("recipients", "surface", "expected"),
|
||||
[
|
||||
# Token surface acts on the web-app recipient; blocked recipients become channels.
|
||||
([_BACKSTAGE, _WEB], HumanInputSurface.OPENAPI, FormDisposition("tok_web", ["console"])),
|
||||
([_EMAIL_MEMBER, _EMAIL_EXTERNAL], HumanInputSurface.OPENAPI, FormDisposition(None, ["email"])),
|
||||
([_EMAIL_MEMBER, _BACKSTAGE], HumanInputSurface.OPENAPI, FormDisposition(None, ["console", "email"])),
|
||||
# CONSOLE acts on console/backstage; a web-app recipient is blocked → web_app channel.
|
||||
([_CONSOLE, _WEB], HumanInputSurface.CONSOLE, FormDisposition("tok_c", ["web_app"])),
|
||||
([_WEB], HumanInputSurface.CONSOLE, FormDisposition(None, ["web_app"])),
|
||||
# No surface: unfiltered priority token, channels never populated.
|
||||
([_BACKSTAGE], None, FormDisposition("tok_b", [])),
|
||||
([_WEB, _EMAIL_MEMBER], None, FormDisposition("tok_web", [])),
|
||||
],
|
||||
)
|
||||
def test_disposition_for_surface_partitions_token_and_channels(recipients, surface, expected) -> None:
|
||||
assert disposition_for_surface(recipients, surface=surface) == expected
|
||||
|
||||
@ -12,38 +12,28 @@ from graphon.runtime import VariablePool
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
def test_service_api_only_allows_public_webapp_forms() -> None:
|
||||
assert is_recipient_type_allowed_for_surface(
|
||||
RecipientType.STANDALONE_WEB_APP,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.CONSOLE,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.BACKSTAGE,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.EMAIL_MEMBER,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
|
||||
|
||||
def test_console_only_allows_internal_console_surfaces() -> None:
|
||||
assert is_recipient_type_allowed_for_surface(
|
||||
RecipientType.CONSOLE,
|
||||
HumanInputSurface.CONSOLE,
|
||||
)
|
||||
assert is_recipient_type_allowed_for_surface(
|
||||
RecipientType.BACKSTAGE,
|
||||
HumanInputSurface.CONSOLE,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.STANDALONE_WEB_APP,
|
||||
HumanInputSurface.CONSOLE,
|
||||
)
|
||||
# Token surfaces (SERVICE_API, OPENAPI) may act only on public web-app forms;
|
||||
# CONSOLE may act on internal console/backstage forms. OPENAPI mirrors SERVICE_API
|
||||
# today but is pinned independently because the two are expected to diverge.
|
||||
@pytest.mark.parametrize(
|
||||
("recipient_type", "surface", "allowed"),
|
||||
[
|
||||
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.SERVICE_API, True),
|
||||
(RecipientType.CONSOLE, HumanInputSurface.SERVICE_API, False),
|
||||
(RecipientType.BACKSTAGE, HumanInputSurface.SERVICE_API, False),
|
||||
(RecipientType.EMAIL_MEMBER, HumanInputSurface.SERVICE_API, False),
|
||||
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI, True),
|
||||
(RecipientType.CONSOLE, HumanInputSurface.OPENAPI, False),
|
||||
(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI, False),
|
||||
(RecipientType.CONSOLE, HumanInputSurface.CONSOLE, True),
|
||||
(RecipientType.BACKSTAGE, HumanInputSurface.CONSOLE, True),
|
||||
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.CONSOLE, False),
|
||||
],
|
||||
)
|
||||
def test_recipient_type_allowed_per_surface(
|
||||
recipient_type: RecipientType, surface: HumanInputSurface, allowed: bool
|
||||
) -> None:
|
||||
assert is_recipient_type_allowed_for_surface(recipient_type, surface) is allowed
|
||||
|
||||
|
||||
def test_preferred_form_token_uses_shared_priority_order() -> None:
|
||||
@ -56,6 +46,17 @@ def test_preferred_form_token_uses_shared_priority_order() -> None:
|
||||
assert get_preferred_form_token(recipients) == "backstage-token"
|
||||
|
||||
|
||||
def test_preferred_form_token_skips_prioritized_type_with_empty_token() -> None:
|
||||
# An empty token is not actionable: the highest-priority recipient that
|
||||
# actually carries a token wins, not the highest-priority type.
|
||||
recipients = [
|
||||
(RecipientType.BACKSTAGE, ""),
|
||||
(RecipientType.CONSOLE, "console-token"),
|
||||
]
|
||||
|
||||
assert get_preferred_form_token(recipients) == "console-token"
|
||||
|
||||
|
||||
def test_resolve_variable_select_input_options_uses_runtime_values() -> None:
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(("start", "options"), ["approve", "reject"])
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
"""Tests for OPENAPI surface in HumanInputPolicy and human_input_forms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
def test_openapi_surface_exists():
|
||||
assert HumanInputSurface.OPENAPI == "openapi"
|
||||
|
||||
|
||||
def test_openapi_allows_standalone_web_app():
|
||||
assert is_recipient_type_allowed_for_surface(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI)
|
||||
|
||||
|
||||
def test_openapi_rejects_console_recipient():
|
||||
assert not is_recipient_type_allowed_for_surface(RecipientType.CONSOLE, HumanInputSurface.OPENAPI)
|
||||
|
||||
|
||||
def test_openapi_rejects_backstage_recipient():
|
||||
assert not is_recipient_type_allowed_for_surface(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI)
|
||||
|
||||
|
||||
def test_get_surface_form_token_openapi_picks_standalone_web_app():
|
||||
"""OPENAPI surface should pick STANDALONE_WEB_APP token, same as SERVICE_API."""
|
||||
from core.workflow.human_input_forms import _get_surface_form_token
|
||||
|
||||
recipients = [
|
||||
(RecipientType.BACKSTAGE, "backstage-token"),
|
||||
(RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
]
|
||||
token = _get_surface_form_token(recipients, surface=HumanInputSurface.OPENAPI)
|
||||
assert token == "web-token"
|
||||
19
api/tests/unit_tests/models/test_recipient_type_label.py
Normal file
19
api/tests/unit_tests/models/test_recipient_type_label.py
Normal file
@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("recipient_type", "expected_label"),
|
||||
[
|
||||
(RecipientType.EMAIL_MEMBER, "email"),
|
||||
(RecipientType.EMAIL_EXTERNAL, "email"),
|
||||
(RecipientType.CONSOLE, "console"),
|
||||
(RecipientType.BACKSTAGE, "console"),
|
||||
(RecipientType.STANDALONE_WEB_APP, "web_app"),
|
||||
],
|
||||
)
|
||||
def test_approval_channel_label_collapses_delivery_types(recipient_type: RecipientType, expected_label: str) -> None:
|
||||
# Both email types collapse to "email" and console/backstage to "console":
|
||||
# the user-facing approval channel, not the internal recipient type.
|
||||
assert recipient_type.approval_channel_label == expected_label
|
||||
@ -16,12 +16,14 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.entities.task_entities import StreamEvent
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
|
||||
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
|
||||
from graphon.nodes.human_input.enums import ValueSourceType
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from models.enums import CreatorUserRole
|
||||
from models.human_input import RecipientType
|
||||
from models.model import AppMode
|
||||
from models.workflow import WorkflowRun
|
||||
from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot
|
||||
@ -763,7 +765,11 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx")
|
||||
monkeypatch.setattr(
|
||||
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
|
||||
service_module,
|
||||
"load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
|
||||
},
|
||||
)
|
||||
session_maker = _SessionMaker(
|
||||
SimpleNamespace(
|
||||
@ -803,12 +809,99 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
|
||||
assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp())
|
||||
|
||||
|
||||
def _build_recipient_snapshot_events(recipients: Sequence[Any]) -> list[Mapping[str, Any]]:
|
||||
"""Drive the reconnect snapshot pause path for the OPENAPI surface.
|
||||
|
||||
Lets the real disposition loader run against a fake session whose ``scalars``
|
||||
yields the given recipients, so the reconnect path derives the same token and
|
||||
approval channels as the live path for the same recipient set.
|
||||
"""
|
||||
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx")
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session_maker = _SessionMaker(
|
||||
SimpleNamespace(
|
||||
execute=lambda _stmt: [("form-1", expiration_time, '{"display_in_ui": true}')],
|
||||
scalars=lambda _stmt: list(recipients),
|
||||
)
|
||||
)
|
||||
pause_entity = _FakePauseEntity(
|
||||
pause_id="pause-1",
|
||||
workflow_run_id="run-1",
|
||||
paused_at_value=expiration_time,
|
||||
pause_reasons=[
|
||||
HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
node_id="node-1",
|
||||
node_title="Human Input",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return _build_snapshot_events(
|
||||
workflow_run=workflow_run,
|
||||
node_snapshots=[snapshot],
|
||||
task_id="task-ctx",
|
||||
message_context=None,
|
||||
pause_entity=pause_entity,
|
||||
resumption_context=resumption_context,
|
||||
session_maker=cast(sessionmaker[Session], session_maker),
|
||||
human_input_surface=HumanInputSurface.OPENAPI,
|
||||
)
|
||||
|
||||
|
||||
def test_reconnect_pause_without_web_app_recipient_emits_approval_channels() -> None:
|
||||
events = _build_recipient_snapshot_events(
|
||||
recipients=[
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
human_input_event = events[-2]
|
||||
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
|
||||
assert human_input_event["data"]["form_token"] is None
|
||||
assert human_input_event["data"]["approval_channels"] == ["console", "email"]
|
||||
|
||||
pause_data = events[-1]["data"]
|
||||
assert pause_data["reasons"][0]["form_token"] is None
|
||||
assert pause_data["reasons"][0]["approval_channels"] == ["console", "email"]
|
||||
|
||||
|
||||
def test_reconnect_pause_with_web_app_recipient_sets_token_and_channels() -> None:
|
||||
events = _build_recipient_snapshot_events(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-app-token",
|
||||
),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
human_input_event = events[-2]
|
||||
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
|
||||
assert human_input_event["data"]["form_token"] == "web-app-token"
|
||||
assert human_input_event["data"]["approval_channels"] == ["console"]
|
||||
|
||||
pause_data = events[-1]["data"]
|
||||
assert pause_data["reasons"][0]["form_token"] == "web-app-token"
|
||||
assert pause_data["reasons"][0]["approval_channels"] == ["console"]
|
||||
|
||||
|
||||
def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"])
|
||||
monkeypatch.setattr(
|
||||
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
|
||||
service_module,
|
||||
"load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
|
||||
},
|
||||
)
|
||||
session_maker = _SessionMaker(
|
||||
SimpleNamespace(
|
||||
@ -886,7 +979,11 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex
|
||||
service_module, "_load_resumption_context", MagicMock(return_value=_build_resumption_context("task-1"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
|
||||
service_module,
|
||||
"load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
|
||||
},
|
||||
)
|
||||
|
||||
session = SimpleNamespace(
|
||||
|
||||
@ -37,7 +37,6 @@ import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { ZERO } from '@/util/uuid.js'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertNoAnsi,
|
||||
assertNonZeroExit,
|
||||
} from '../../helpers/assert.js'
|
||||
@ -216,147 +215,6 @@ describe('E2E / error message standards (spec 5.3)', () => {
|
||||
expect(result.stderr).not.toContain(sentValue)
|
||||
})
|
||||
|
||||
// ── 5.70d-h ErrorBody contract — error.server structure and rendering priorities ──
|
||||
// PR #37285 introduces canonical ErrorBody on every /openapi/v1 non-2xx response.
|
||||
// CLI strict-parses via zErrorBody.safeParse; success → full struct at error.server.
|
||||
//
|
||||
// V2 rendering priorities (format.ts, verified against codebase):
|
||||
// header code : server?.code ?? cliCode — server wins, CLI fallback
|
||||
// hint : cliHint ?? server?.hint — CLI wins, server fallback (V2 correction)
|
||||
// details : server?.details[] — " - loc: msg (type)" per entry, no -v
|
||||
|
||||
it('[P0] 5.70d JSON envelope contains error.server with canonical code/status/message', async () => {
|
||||
// Trigger: describe app ZERO — server returns canonical 404 ErrorBody
|
||||
// { code:"not_found", status:404, message:"app not found" }.
|
||||
// zErrorBody.safeParse succeeds → error.server is populated on the current server.
|
||||
const result = await fx.r(['describe', 'app', ZERO, '-o', 'json'])
|
||||
assertNonZeroExit(result)
|
||||
const envelope = JSON.parse(result.stderr.trim()) as {
|
||||
error: { code: string, server?: { code: string, status: number, message: string } }
|
||||
}
|
||||
expect(envelope.error.server, 'error.server must be present when server returns canonical ErrorBody').toBeDefined()
|
||||
expect(typeof envelope.error.server?.code, 'error.server.code must be a string').toBe('string')
|
||||
expect(envelope.error.server?.code.length).toBeGreaterThan(0)
|
||||
expect(typeof envelope.error.server?.status, 'error.server.status must be a number').toBe('number')
|
||||
expect(typeof envelope.error.server?.message, 'error.server.message must be a string').toBe('string')
|
||||
expect(envelope.error.server?.message.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P1] 5.70e @accepts query validation returns canonical 422 with details array', async () => {
|
||||
// Trigger: direct fetch to GET /apps?page=not-integer — @accepts(query=AppListQuery)
|
||||
// validates page as int and emits canonical 422 ErrorBody with details[].
|
||||
// Direct fetch is used because the CLI validates --page as integer client-side
|
||||
// (would exit 2 before hitting the server); this pins the server-side contract.
|
||||
const res = await fetch(
|
||||
`${E.host.replace(/\/$/, '')}/openapi/v1/apps?workspace_id=${E.workspaceId}&page=not-an-integer`,
|
||||
{ headers: { Authorization: `Bearer ${E.token}` }, signal: AbortSignal.timeout(8_000) },
|
||||
)
|
||||
expect(res.status).toBe(422)
|
||||
const body = await res.json() as {
|
||||
code?: string
|
||||
status?: number
|
||||
details?: Array<{ type: string, loc: Array<string | number>, msg: string }>
|
||||
}
|
||||
expect(body.code).toBe('invalid_param')
|
||||
expect(body.status).toBe(422)
|
||||
expect(Array.isArray(body.details), 'details must be an array').toBe(true)
|
||||
expect(body.details!.length).toBeGreaterThan(0)
|
||||
const entry = body.details![0]!
|
||||
expect(typeof entry.type).toBe('string')
|
||||
expect(typeof entry.msg).toBe('string')
|
||||
expect(Array.isArray(entry.loc)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] 5.70g rendering priority — header code: server code wins over CLI classification code', async () => {
|
||||
// renderHuman: headerCode = server?.code ?? e.code (server wins, V2 unchanged)
|
||||
// When canonical ErrorBody is parsed, the server semantic code replaces the CLI
|
||||
// classification code ("server_4xx_other") in the human-readable output header.
|
||||
// Trigger: describe app ZERO → canonical 404; header starts with "not_found:".
|
||||
const result = await fx.r(['describe', 'app', ZERO])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr.trimStart()).not.toMatch(/^server_4xx_other:/)
|
||||
expect(result.stderr.trimStart()).toMatch(/^not_found:/)
|
||||
})
|
||||
|
||||
it('[P1] 5.70g2 rendering priority — hint: CLI hint wins over server hint (V2 correction)', async () => {
|
||||
// renderHuman: hint = cliHint ?? server?.hint (CLI wins — V2 spec correction)
|
||||
// V1 incorrectly documented "server wins"; V2 aligns with codebase: CLI wins.
|
||||
// Test: 401 AuthExpired — classifyResponse sets c.hint = AUTH_LOGIN_HINT before
|
||||
// serverError is parsed; CLI hint takes precedence over any server-provided hint.
|
||||
// Verified on current server (no @accepts deployment required).
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
const envelope = JSON.parse(result.stderr.trim()) as { error: { hint?: string } }
|
||||
expect(envelope.error.hint, 'CLI login hint must appear for auth error').toMatch(/auth login/i)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] 5.70h JSON envelope: error.code = CLI classification; error.server.code = server semantic code', async () => {
|
||||
// toEnvelope() sets error.code from HTTP status bucket (e.g. "server_4xx_other")
|
||||
// while the server's semantic code is separate in error.server.code.
|
||||
// Agents can branch on error.server.code without parsing human-readable text.
|
||||
// Trigger: describe app ZERO → canonical 404; error.code="server_4xx_other",
|
||||
// error.server.code="not_found" — always distinct when ErrorBody is present.
|
||||
const result = await fx.r(['describe', 'app', ZERO, '-o', 'json'])
|
||||
assertNonZeroExit(result)
|
||||
const envelope = JSON.parse(result.stderr.trim()) as {
|
||||
error: { code: string, server?: { code: string } }
|
||||
}
|
||||
expect(envelope.error.code).toBe('server_4xx_other')
|
||||
expect(envelope.error.server?.code).toBeDefined()
|
||||
expect(envelope.error.server?.code).not.toBe('server_4xx_other')
|
||||
})
|
||||
// ── 5.70i / 5.70j PR #37285 boundary contract ───────────────────────────
|
||||
|
||||
it('[P1] 5.70i unknown /openapi/v1 route returns canonical 404 ErrorBody without route suggestions', async () => {
|
||||
// PR #37285: ExternalApi._help_on_404 suppresses flask-restx route enumeration.
|
||||
// Previously, an unknown path under /openapi/v1/ returned flask-restx's default
|
||||
// 404 with a "Did you mean /openapi/v1/apps?" suggestion, leaking the route table.
|
||||
// After the fix it must return a canonical ErrorBody and contain no suggestions.
|
||||
const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/this-path-does-not-exist-e2e`, {
|
||||
headers: { Authorization: `Bearer ${E.token}` },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
})
|
||||
expect(res.status).toBe(404)
|
||||
const body = await res.json() as Record<string, unknown>
|
||||
// canonical ErrorBody fields must be present
|
||||
expect(typeof body.code, '404 body must have a string code field').toBe('string')
|
||||
expect(body.status, '404 body must have status: 404').toBe(404)
|
||||
// no flask-restx route enumeration in the response
|
||||
const raw = JSON.stringify(body)
|
||||
expect(raw).not.toMatch(/did you mean/i)
|
||||
expect(raw).not.toMatch(/you might want/i)
|
||||
})
|
||||
|
||||
it('[P1] 5.70j device-flow 4xx uses RFC 8628 format, not ErrorBody — zErrorBody parse fails gracefully', async () => {
|
||||
// PR #37285 explicitly excludes RFC 8628 device-flow endpoints from the
|
||||
// ErrorBody contract. This test pins that contract:
|
||||
// - The device/token endpoint returns RFC 8628 {error: string} on failure,
|
||||
// not the canonical {code, status, message} shape.
|
||||
// - When the CLI's classifyResponse encounters this, zErrorBody.safeParse
|
||||
// returns failure → serverError = undefined → generic status-based message,
|
||||
// no error.server field, no crash.
|
||||
const res = await fetch(`${E.host.replace(/\/$/, '')}/openapi/v1/oauth/device/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_code: 'fake-invalid-device-code-e2e-test', client_id: 'difyctl' }),
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
})
|
||||
// device flow errors are 4xx (400 bad_request or 401 expired_token etc.)
|
||||
expect(res.status).toBeGreaterThanOrEqual(400)
|
||||
expect(res.status).toBeLessThan(500)
|
||||
const body = await res.json() as Record<string, unknown>
|
||||
// RFC 8628 shape: has 'error' string, must NOT have ErrorBody 'code'/'status' pair
|
||||
expect(typeof body.error, 'RFC 8628 body must have a string error field').toBe('string')
|
||||
expect(body).not.toHaveProperty('status')
|
||||
// zErrorBody.safeParse would fail → CLI sets serverError = undefined → generic message
|
||||
})
|
||||
|
||||
// ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ────────
|
||||
|
||||
it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => {
|
||||
|
||||
@ -1,407 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl get/create/delete/set member — Member Management
|
||||
*
|
||||
* Data lifecycle:
|
||||
* beforeAll — generates two random emails, invites them as test fixtures
|
||||
* afterAll — removes both fixtures (best-effort)
|
||||
*
|
||||
* Email format: auto_test+<timestamp>@dify.ai
|
||||
* No extra env vars required.
|
||||
*
|
||||
* JSON response shape (MemberListResponse):
|
||||
* { page, limit, total, has_more, data: MemberResponse[] }
|
||||
*
|
||||
* MemberResponse fields:
|
||||
* { id, name, email, role, status, avatar?, current: bool }
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterAll, beforeAll, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertNonZeroExit,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
// ── Fixture state ─────────────────────────────────────────────────────────────
|
||||
|
||||
let fx: AuthFixture
|
||||
|
||||
/** ID of the member used by get / set tests. */
|
||||
let testMemberId: string
|
||||
|
||||
/** ID of the member reserved for the delete-success test. */
|
||||
let deleteTargetId: string
|
||||
|
||||
const ts = Date.now()
|
||||
const memberEmail = `auto_test+${ts}@dify.ai`
|
||||
const deleteTargetEmail = `auto_test+${ts + 1}@dify.ai`
|
||||
|
||||
// ── Response type helpers ─────────────────────────────────────────────────────
|
||||
|
||||
type MemberItem = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
role: string
|
||||
status: string
|
||||
current?: boolean
|
||||
}
|
||||
|
||||
type MemberListJson = {
|
||||
data: MemberItem[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
has_more: boolean
|
||||
}
|
||||
|
||||
// ── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeAll(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
|
||||
// Invite the main test member; capture member_id from response
|
||||
const createMain = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
memberEmail,
|
||||
'--role',
|
||||
'normal',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
if (createMain.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`beforeAll: failed to create test member (${memberEmail}): ${createMain.stderr}`,
|
||||
)
|
||||
}
|
||||
const mainData = JSON.parse(createMain.stdout.trim()) as { member_id?: string }
|
||||
testMemberId = mainData.member_id as string
|
||||
if (!testMemberId)
|
||||
throw new Error(`beforeAll: missing member_id in: ${createMain.stdout}`)
|
||||
|
||||
// Invite the delete-target member
|
||||
const createTarget = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
deleteTargetEmail,
|
||||
'--role',
|
||||
'normal',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
if (createTarget.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`beforeAll: failed to create delete-target member (${deleteTargetEmail}): ${createTarget.stderr}`,
|
||||
)
|
||||
}
|
||||
const targetData = JSON.parse(createTarget.stdout.trim()) as { member_id?: string }
|
||||
deleteTargetId = targetData.member_id as string
|
||||
if (!deleteTargetId)
|
||||
throw new Error(`beforeAll: missing member_id in: ${createTarget.stdout}`)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (testMemberId) {
|
||||
await fx.r(['delete', 'member', testMemberId, '--yes']).catch(() => {})
|
||||
}
|
||||
if (deleteTargetId) {
|
||||
await fx.r(['delete', 'member', deleteTargetId, '--yes']).catch(() => {})
|
||||
}
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── get member ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl get member', () => {
|
||||
it('[P0] member list contains the created test member', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
const ids = (data.data ?? []).map(m => m.id)
|
||||
expect(ids, `testMemberId ${testMemberId} must appear in member list`).toContain(testMemberId)
|
||||
})
|
||||
|
||||
it('[P0] default table output contains required column headers', async () => {
|
||||
const result = await fx.r(['get', 'member'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/\bID\b/)
|
||||
expect(result.stdout).toMatch(/\bNAME\b/)
|
||||
expect(result.stdout).toMatch(/\bEMAIL\b/)
|
||||
expect(result.stdout).toMatch(/\bROLE\b/)
|
||||
expect(result.stdout).toMatch(/\bSTATUS\b/)
|
||||
})
|
||||
|
||||
it('[P0] authenticated account appears in member list', async () => {
|
||||
// The token owner (auto_test@dify.ai) must appear in the member list
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
const ownerRow = data.data.find(m => m.email === E.email)
|
||||
expect(ownerRow, `owner email ${E.email} must be in member list`).toBeDefined()
|
||||
expect(ownerRow?.role).toBe('owner')
|
||||
expect(ownerRow?.status).toBe('active')
|
||||
})
|
||||
|
||||
it('[P0] -o json returns valid JSON with data array', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
expect(Array.isArray(data.data), 'data must be an array').toBe(true)
|
||||
expect(data.data.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] -o json each member has id, email, role, status fields', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
const member = data.data[0]!
|
||||
expect(typeof member.id).toBe('string')
|
||||
expect(typeof member.email).toBe('string')
|
||||
expect(typeof member.role).toBe('string')
|
||||
expect(typeof member.status).toBe('string')
|
||||
})
|
||||
|
||||
it('[P0] output has no ANSI colour codes (non-TTY)', async () => {
|
||||
const result = await fx.r(['get', 'member'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated get member returns auth error (exit code 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'member'], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] -o yaml returns valid YAML (non-empty, no JSON braces)', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
})
|
||||
|
||||
it('[P1] -o json output is pipe-friendly (no ANSI, ends with newline)', async () => {
|
||||
const result = await fx.r(['get', 'member', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
expect(result.stdout.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] -w overrides the workspace', async () => {
|
||||
const result = await fx.r(['get', 'member', '-w', E.workspaceId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const data = assertJson<MemberListJson>(result)
|
||||
expect(Array.isArray(data.data)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// ── set member ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl set member', () => {
|
||||
it('[P0] owner/admin can promote normal → admin', async () => {
|
||||
const result = await fx.r(['set', 'member', testMemberId, '--role', 'admin', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const updated = data.data.find(m => m.id === testMemberId)
|
||||
expect(updated?.role).toBe('admin')
|
||||
})
|
||||
|
||||
it('[P0] owner/admin can demote admin → normal', async () => {
|
||||
await fx.r(['set', 'member', testMemberId, '--role', 'admin'])
|
||||
const result = await fx.r(['set', 'member', testMemberId, '--role', 'normal', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const updated = data.data.find(m => m.id === testMemberId)
|
||||
expect(updated?.role).toBe('normal')
|
||||
})
|
||||
|
||||
it('[P0] --role owner is rejected client-side (exit 2, no API call)', async () => {
|
||||
const result = await fx.r(['set', 'member', testMemberId, '--role', 'owner'])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/invalid|role|owner/i)
|
||||
})
|
||||
|
||||
it('[P0] missing --role returns usage error', async () => {
|
||||
const result = await fx.r(['set', 'member', testMemberId])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/role|required|missing/i)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated set member returns auth error (exit 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['set', 'member', testMemberId, '--role', 'normal'], {
|
||||
configDir: tmp.configDir,
|
||||
})
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] missing member-id returns usage error', async () => {
|
||||
const result = await fx.r(['set', 'member', '--role', 'normal'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing|required|arg|member/i)
|
||||
})
|
||||
|
||||
it('[P1] non-existent member-id returns server error', async () => {
|
||||
const result = await fx.r([
|
||||
'set',
|
||||
'member',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'--role',
|
||||
'normal',
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── create member — error paths ───────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl create member (error paths)', () => {
|
||||
it('[P0] --role with invalid value is rejected client-side (exit 2)', async () => {
|
||||
const result = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
`auto_test+unused${Date.now()}@dify.ai`,
|
||||
'--role',
|
||||
'superadmin',
|
||||
])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/invalid|role/i)
|
||||
})
|
||||
|
||||
it('[P0] --role owner is rejected client-side (exit 2)', async () => {
|
||||
const result = await fx.r([
|
||||
'create',
|
||||
'member',
|
||||
'--email',
|
||||
`auto_test+unused${Date.now()}@dify.ai`,
|
||||
'--role',
|
||||
'owner',
|
||||
])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/invalid|role|owner/i)
|
||||
})
|
||||
|
||||
it('[P0] missing --email returns usage error', async () => {
|
||||
const result = await fx.r(['create', 'member', '--role', 'normal'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/email|required|missing/i)
|
||||
})
|
||||
|
||||
it('[P0] missing --role returns usage error', async () => {
|
||||
const result = await fx.r(['create', 'member', '--email', memberEmail])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/role|required|missing/i)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated create member returns auth error (exit 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(
|
||||
['create', 'member', '--email', `auto_test+unauth${Date.now()}@dify.ai`, '--role', 'normal'],
|
||||
{ configDir: tmp.configDir },
|
||||
)
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ── delete member ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl delete member', () => {
|
||||
it('[P0] owner/admin can remove a member from the workspace', async () => {
|
||||
const result = await fx.r(['delete', 'member', deleteTargetId, '--yes'])
|
||||
assertExitCode(result, 0)
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const ids = data.data.map(m => m.id)
|
||||
expect(ids).not.toContain(deleteTargetId)
|
||||
deleteTargetId = ''
|
||||
})
|
||||
|
||||
it('[P0] attempting to delete self returns server error', async () => {
|
||||
const list = await fx.r(['get', 'member', '-o', 'json'])
|
||||
const data = assertJson<MemberListJson>(list)
|
||||
const self = data.data.find(m => m.email === E.email)
|
||||
if (!self) {
|
||||
console.warn('[E2E] could not identify self in member list — skipping')
|
||||
return
|
||||
}
|
||||
const result = await fx.r(['delete', 'member', self.id, '--yes'])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr).toMatch(/self|yourself|cannot|not.*allow|400|forbidden/i)
|
||||
})
|
||||
|
||||
it('[P0] missing member-id argument returns usage error', async () => {
|
||||
const result = await fx.r(['delete', 'member'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing|required|arg|member/i)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated delete member returns auth error (exit 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(
|
||||
['delete', 'member', '00000000-0000-0000-0000-000000000000', '--yes'],
|
||||
{ configDir: tmp.configDir },
|
||||
)
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] non-existent member-id returns server error', async () => {
|
||||
const result = await fx.r([
|
||||
'delete',
|
||||
'member',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'--yes',
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P1] -o json outputs structured envelope on error', async () => {
|
||||
const result = await fx.r([
|
||||
'delete',
|
||||
'member',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'--yes',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
})
|
||||
@ -92,8 +92,6 @@ export default defineConfig({
|
||||
'test/e2e/suites/framework/**/*.e2e.ts',
|
||||
// discovery (get app / describe app)
|
||||
'test/e2e/suites/discovery/**/*.e2e.ts',
|
||||
// member management (get/create/delete/set member)
|
||||
'test/e2e/suites/member/**/*.e2e.ts',
|
||||
// dsl (export / import)
|
||||
'test/e2e/suites/dsl/**/*.e2e.ts',
|
||||
// run tests (require valid token)
|
||||
|
||||
@ -135,7 +135,6 @@ AMPLITUDE_API_KEY=
|
||||
TEXT_GENERATION_TIMEOUT_MS=60000
|
||||
CSP_WHITELIST=
|
||||
ALLOW_EMBED=false
|
||||
ALLOW_INLINE_STYLES=false
|
||||
ALLOW_UNSAFE_DATA_SCHEME=false
|
||||
TOP_K_MAX_VALUE=10
|
||||
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
|
||||
|
||||
@ -387,7 +387,6 @@ services:
|
||||
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
|
||||
CSP_WHITELIST: ${CSP_WHITELIST:-}
|
||||
ALLOW_EMBED: ${ALLOW_EMBED:-false}
|
||||
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
|
||||
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
|
||||
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
|
||||
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
|
||||
|
||||
@ -393,7 +393,6 @@ services:
|
||||
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
|
||||
CSP_WHITELIST: ${CSP_WHITELIST:-}
|
||||
ALLOW_EMBED: ${ALLOW_EMBED:-false}
|
||||
ALLOW_INLINE_STYLES: ${ALLOW_INLINE_STYLES:-false}
|
||||
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
|
||||
MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai}
|
||||
MARKETPLACE_URL: ${MARKETPLACE_URL:-https://marketplace.dify.ai}
|
||||
|
||||
@ -192,11 +192,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(commonLayout)/snippets/[snippetId]/page.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/(shareLayout)/components/authenticated-layout.tsx": {
|
||||
"jsx-a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
|
||||
@ -166,8 +166,23 @@ See `[web/docs/overlay.md](../../web/docs/overlay.md)` for the web app overlay b
|
||||
|
||||
- `pnpm -C packages/dify-ui test` — Vitest unit tests for primitives.
|
||||
- `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`.
|
||||
- `pnpm -C packages/dify-ui test:storybook` — Storybook component tests in Vitest browser mode. Stories without `play` are render and a11y smoke tests; stories with `play` should cover public UI contracts such as opening overlays, keyboard navigation, disabled/loading guards, form submission, and controlled state updates.
|
||||
- `pnpm -C packages/dify-ui type-check` — `tsgo --noEmit` for this package only.
|
||||
|
||||
### Test Boundary
|
||||
|
||||
Use Storybook tests for behavior that belongs to the documented component example:
|
||||
visible state changes, user interaction, keyboard paths, overlay open/close flows,
|
||||
and accessibility-facing semantics. Keep regular Vitest unit tests for lower-level
|
||||
wrapper contracts such as class variants, Base UI passthrough props, hidden input
|
||||
serialization, data attribute hooks, store behavior, and edge cases that do not
|
||||
need a full story.
|
||||
|
||||
Storybook accessibility testing stays enabled globally with `a11y.test = 'error'`.
|
||||
If a story is temporarily marked `todo`, keep the exception local to that story
|
||||
and do not treat an interaction `play` test as a replacement for fixing the
|
||||
underlying accessibility issue.
|
||||
|
||||
### Disabling Animations In Tests
|
||||
|
||||
Base UI can wait for `element.getAnimations()` to finish before it unmounts overlays, panels, and transition-driven components. Browser-based test runners can make that timing unstable, especially when tests assert final DOM state rather than animation behavior.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import * as React from 'react'
|
||||
import { expect, waitFor, within } from 'storybook/test'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
@ -55,6 +56,21 @@ export const Default: Story = {
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Delete project' }))
|
||||
|
||||
const dialog = body.getByRole('alertdialog', { name: 'Delete project?' })
|
||||
await waitFor(async () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
|
||||
await userEvent.click(body.getByRole('button', { name: 'Cancel' }))
|
||||
await waitFor(async () => {
|
||||
await expect(body.queryByRole('alertdialog', { name: 'Delete project?' })).not.toBeInTheDocument()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const NonDestructive: Story = {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import * as React from 'react'
|
||||
import { expect, fn } from 'storybook/test'
|
||||
|
||||
import { Button } from '.'
|
||||
|
||||
@ -90,8 +91,21 @@ export const Loading: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
loading: true,
|
||||
onClick: fn(),
|
||||
children: 'Loading Button',
|
||||
},
|
||||
play: async ({ args, canvas, userEvent }) => {
|
||||
const button = canvas.getByRole('button', { name: 'Loading Button' })
|
||||
|
||||
await expect(button).toHaveAttribute('aria-disabled', 'true')
|
||||
await expect(button).toHaveAttribute('aria-busy', 'true')
|
||||
|
||||
button.focus()
|
||||
await expect(button).toHaveFocus()
|
||||
|
||||
await userEvent.click(button)
|
||||
await expect(args.onClick).not.toHaveBeenCalled()
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
|
||||
@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import * as React from 'react'
|
||||
import { expect } from 'storybook/test'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
@ -768,6 +769,15 @@ const MultipleChipsDemo = () => {
|
||||
|
||||
export const MultipleChips: Story = {
|
||||
render: () => <MultipleChipsDemo />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await expect(canvas.getByText('Maya Chen')).toBeVisible()
|
||||
await expect(canvas.getByText('Liam Brooks')).toBeVisible()
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Remove Maya Chen' }))
|
||||
|
||||
await expect(canvas.queryByText('Maya Chen')).not.toBeInTheDocument()
|
||||
await expect(canvas.getByText('Liam Brooks')).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const VirtualizedLongList: Story = {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import * as React from 'react'
|
||||
import { expect, waitFor, within } from 'storybook/test'
|
||||
import {
|
||||
Dialog,
|
||||
DialogCloseButton,
|
||||
@ -66,6 +67,22 @@ export const Default: Story = {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Open dialog' }))
|
||||
|
||||
const dialog = body.getByRole('dialog', { name: 'Invite collaborators' })
|
||||
await waitFor(async () => {
|
||||
await expect(dialog).toBeVisible()
|
||||
})
|
||||
await expect(body.getByRole('textbox', { name: 'Email address' })).toBeVisible()
|
||||
|
||||
await userEvent.click(body.getByRole('button', { name: 'Close' }))
|
||||
await waitFor(async () => {
|
||||
await expect(body.queryByRole('dialog', { name: 'Invite collaborators' })).not.toBeInTheDocument()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const WithoutCloseButton: Story = {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { FileTreeIconType } from '.'
|
||||
import * as React from 'react'
|
||||
import { expect } from 'storybook/test'
|
||||
import {
|
||||
FileTreeBadge,
|
||||
FileTreeFile,
|
||||
@ -330,6 +331,19 @@ function VisualStates() {
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => <ComposedFileTree />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const srcFolder = canvas.getByRole('button', { name: 'src' })
|
||||
|
||||
await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible()
|
||||
|
||||
await userEvent.click(srcFolder)
|
||||
await expect(srcFolder).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(canvas.queryByRole('button', { name: 'components' })).not.toBeInTheDocument()
|
||||
|
||||
await userEvent.click(srcFolder)
|
||||
await expect(srcFolder).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(canvas.getByRole('button', { name: 'components' })).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const DataDriven: Story = {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import * as React from 'react'
|
||||
import { expect } from 'storybook/test'
|
||||
import {
|
||||
Pagination,
|
||||
PaginationSkeleton,
|
||||
@ -77,6 +78,15 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Playground: Story = {
|
||||
render: () => <PaginationDemo />,
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
await expect(canvas.getByRole('button', { name: 'Edit page number, current page 2 of 200' })).toBeVisible()
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: 'Next page' }))
|
||||
await expect(canvas.getByRole('button', { name: 'Edit page number, current page 3 of 200' })).toBeVisible()
|
||||
|
||||
await userEvent.click(canvas.getByRole('button', { name: '50' }))
|
||||
await expect(canvas.getByRole('button', { name: '50' })).toHaveAttribute('aria-pressed', 'true')
|
||||
},
|
||||
parameters: {
|
||||
a11y: {
|
||||
test: 'todo',
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import * as React from 'react'
|
||||
import { expect, waitFor, within } from 'storybook/test'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -19,6 +20,8 @@ const triggerWidth = 'w-64'
|
||||
const cityItems = [
|
||||
{ label: 'Seattle', value: 'seattle' },
|
||||
{ label: 'New York', value: 'new-york' },
|
||||
{ label: 'Tokyo', value: 'tokyo' },
|
||||
{ label: 'Paris', value: 'paris' },
|
||||
]
|
||||
|
||||
const meta = {
|
||||
@ -41,11 +44,11 @@ type Story = StoryObj<typeof meta>
|
||||
export const Default: Story = {
|
||||
render: () => (
|
||||
<div className={triggerWidth}>
|
||||
<Select defaultValue="seattle">
|
||||
<Select items={cityItems} defaultValue="seattle">
|
||||
<SelectTrigger aria-label="City">
|
||||
<SelectValue placeholder="Select a city" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectContent listProps={{ 'aria-label': 'City options' }}>
|
||||
<SelectItem value="seattle">
|
||||
<SelectItemText>Seattle</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
@ -66,6 +69,27 @@ export const Default: Story = {
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
play: async ({ canvas, canvasElement, userEvent }) => {
|
||||
const trigger = canvas.getByRole('combobox', { name: 'City' })
|
||||
const body = within(canvasElement.ownerDocument.body)
|
||||
|
||||
await expect(trigger).toHaveTextContent('Seattle')
|
||||
|
||||
trigger.focus()
|
||||
await userEvent.keyboard('{ArrowDown}')
|
||||
|
||||
await waitFor(async () => {
|
||||
await expect(body.getByRole('option', { name: 'Tokyo' })).toBeVisible()
|
||||
})
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}{ArrowDown}{Enter}')
|
||||
await expect(trigger).toHaveTextContent('Tokyo')
|
||||
|
||||
await userEvent.keyboard('{Escape}')
|
||||
await waitFor(async () => {
|
||||
await expect(body.queryByRole('listbox', { name: 'City options' })).not.toBeInTheDocument()
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export const WithVisibleLabel: Story = {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import * as React from 'react'
|
||||
import { expect } from 'storybook/test'
|
||||
import { Switch, SwitchSkeleton } from '.'
|
||||
import {
|
||||
FieldDescription,
|
||||
@ -77,6 +78,17 @@ export const Default: Story = {
|
||||
checked: false,
|
||||
disabled: false,
|
||||
},
|
||||
play: async ({ canvas, userEvent }) => {
|
||||
const switchControl = canvas.getByRole('switch', { name: 'Enable auto retry' })
|
||||
|
||||
await expect(switchControl).toHaveAttribute('aria-checked', 'false')
|
||||
await expect(canvas.getByText('Failures require manual retry.')).toBeVisible()
|
||||
|
||||
await userEvent.click(switchControl)
|
||||
|
||||
await expect(switchControl).toHaveAttribute('aria-checked', 'true')
|
||||
await expect(canvas.getByText('Failures will retry automatically.')).toBeVisible()
|
||||
},
|
||||
}
|
||||
|
||||
export const DefaultOn: Story = {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
const Page = async (props: {
|
||||
params: Promise<{ snippetId: string }>
|
||||
|
||||
@ -417,15 +417,17 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render link to snippets before the create button', () => {
|
||||
it('should render sort filter before search and the snippets link', () => {
|
||||
renderList()
|
||||
|
||||
const sortButton = screen.getByRole('button', { name: 'Sort by Last modified' })
|
||||
const searchInput = screen.getByRole('searchbox', { name: 'app.gotoAnything.actions.searchApplications' })
|
||||
const snippetsLink = screen.getByRole('link', { name: 'app.studio.viewSnippets' })
|
||||
const createButton = screen.getByRole('button', { name: 'common.operation.create' })
|
||||
|
||||
expect(snippetsLink).toHaveAttribute('href', '/snippets')
|
||||
expect(sortButton.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(sortButton.compareDocumentPosition(searchInput) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(searchInput.compareDocumentPosition(snippetsLink) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
expect(snippetsLink.compareDocumentPosition(createButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@ -62,6 +62,7 @@ export function AppListHeaderFilters({
|
||||
showLeadingIcon={false}
|
||||
/>
|
||||
<CreatorsFilter value={creatorIDs} onChange={onCreatorIDsChange} />
|
||||
<AppSortFilter value={sortBy} onChange={onSortByChange} />
|
||||
<SearchInput
|
||||
className="w-50"
|
||||
value={keywords}
|
||||
@ -70,7 +71,6 @@ export function AppListHeaderFilters({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<AppSortFilter value={sortBy} onChange={onSortByChange} />
|
||||
<Link
|
||||
href="/snippets"
|
||||
className="flex h-8 items-center rounded-lg px-3 text-sm font-semibold text-text-secondary outline-hidden hover:bg-state-base-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
|
||||
@ -484,6 +484,21 @@ describe('MainNav', () => {
|
||||
expect(screen.getByRole('link', { name: /common.mainNav.home/ })).not.toHaveAttribute('aria-current')
|
||||
})
|
||||
|
||||
it('hides the main menu on snippet detail routes while keeping account settings available', () => {
|
||||
mockPathname = '/snippets/snippet-1/orchestrate'
|
||||
|
||||
renderMainNav()
|
||||
|
||||
expect(screen.getByRole('complementary')).toHaveClass('w-16')
|
||||
expect(screen.queryByLabelText('Dify')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.mainNav.home/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /common.menus.apps/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'explore.sidebar.webApps' })).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('replaces global navigation with app detail navigation on app routes', () => {
|
||||
mockPathname = '/app/app-1/overview'
|
||||
|
||||
|
||||
@ -60,6 +60,12 @@ const isDatasetDetailPathname = (pathname: string) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const isSnippetDetailPathname = (pathname: string) => {
|
||||
const [section, snippetId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return section === 'snippets' && !!snippetId
|
||||
}
|
||||
|
||||
const MainNav = ({
|
||||
className,
|
||||
}: MainNavProps) => {
|
||||
@ -70,6 +76,7 @@ const MainNav = ({
|
||||
const showEnvTag = langGeniusVersionInfo.current_env === 'TESTING' || langGeniusVersionInfo.current_env === 'DEVELOPMENT'
|
||||
const showAppDetailNavigation = !isCurrentWorkspaceDatasetOperator && pathname.startsWith('/app/')
|
||||
const showDatasetDetailNavigation = isDatasetDetailPathname(pathname)
|
||||
const showSnippetDetailBottomNavigation = isSnippetDetailPathname(pathname)
|
||||
const showDetailNavigation = showAppDetailNavigation || showDatasetDetailNavigation
|
||||
const { hasAppDetail, appSidebarExpand, setAppDetail, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
hasAppDetail: !!state.appDetail,
|
||||
@ -87,7 +94,9 @@ const MainNav = ({
|
||||
const detailNavigationTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const isDetailNavigationHoverPreviewOpen = isCollapsedDetailNavigation && detailNavigationHoverPreviewOpen
|
||||
const detailNavigationVisibleExpanded = detailNavigationExpanded || isDetailNavigationHoverPreviewOpen
|
||||
const bottomNavigationExpanded = !showDetailNavigation || detailNavigationVisibleExpanded
|
||||
const bottomNavigationExpanded = showSnippetDetailBottomNavigation
|
||||
? false
|
||||
: !showDetailNavigation || detailNavigationVisibleExpanded
|
||||
const handleToggleDetailNavigation = useCallback(() => {
|
||||
if (isDetailNavigationHoverPreviewOpen) {
|
||||
if (detailNavigationTransitionTimerRef.current)
|
||||
@ -234,7 +243,9 @@ const MainNav = ({
|
||||
? detailNavigationExpanded
|
||||
? 'w-[248px] bg-background-body p-1'
|
||||
: 'w-16 bg-background-body p-1'
|
||||
: 'w-60 flex-col',
|
||||
: showSnippetDetailBottomNavigation
|
||||
? 'w-16 bg-background-body p-1'
|
||||
: 'w-60 flex-col',
|
||||
'bg-background-body',
|
||||
className,
|
||||
)}
|
||||
@ -267,32 +278,36 @@ const MainNav = ({
|
||||
onToggle={handleToggleDetailNavigation}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
|
||||
{renderLogo()}
|
||||
<MainNavSearchButton />
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<WorkspaceCard />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
: showSnippetDetailBottomNavigation
|
||||
? null
|
||||
: (
|
||||
<>
|
||||
<div className="flex items-center justify-between pt-3 pr-2 pb-2 pl-4">
|
||||
{renderLogo()}
|
||||
<MainNavSearchButton />
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<WorkspaceCard />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{showDetailNavigation
|
||||
? showAppDetailNavigation
|
||||
? <AppDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: <DatasetDetailSection expand={detailNavigationVisibleExpanded} />
|
||||
: (
|
||||
<>
|
||||
<nav className="flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
</>
|
||||
)}
|
||||
{showEnvTag && detailNavigationVisibleExpanded && (
|
||||
: showSnippetDetailBottomNavigation
|
||||
? null
|
||||
: (
|
||||
<>
|
||||
<nav className="flex flex-col gap-px p-2">
|
||||
{navItems.map(item => (
|
||||
<MainNavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</nav>
|
||||
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
|
||||
</>
|
||||
)}
|
||||
{showEnvTag && !showSnippetDetailBottomNavigation && detailNavigationVisibleExpanded && (
|
||||
<div className="relative z-30 mt-auto shrink-0 px-3 pb-2">
|
||||
<EnvNav />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user