Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe676f6797 | ||
|
|
6a338654fb | ||
|
|
8f075c82bc | ||
|
|
28d17da3b1 | ||
|
|
891b2dc537 | ||
|
|
75f7069541 | ||
|
|
b7193d1cba | ||
|
|
ef0115d340 | ||
|
|
3afb9b3230 | ||
|
|
0862641533 | ||
|
|
cbd7872520 | ||
|
|
23882a704e | ||
|
|
1bd5254ea7 | ||
|
|
e13069dbe3 | ||
|
|
edd3d4a218 | ||
|
|
79751f7622 | ||
|
|
cb64446fa3 | ||
|
|
e77ee82526 | ||
|
|
b3f163fb0f |
@@ -16,7 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
api-unit:
|
||||
name: API Unit Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
env:
|
||||
COVERAGE_FILE: coverage-unit
|
||||
defaults:
|
||||
@@ -47,9 +47,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Run dify config tests
|
||||
run: uv run --project api pytest api/tests/unit_tests/configs/test_env_consistency.py
|
||||
|
||||
- name: Run Unit Tests
|
||||
run: |
|
||||
uv run --project api pytest \
|
||||
@@ -75,7 +72,7 @@ jobs:
|
||||
|
||||
api-integration:
|
||||
name: API Integration Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
env:
|
||||
COVERAGE_FILE: coverage-integration
|
||||
STORAGE_TYPE: opendal
|
||||
@@ -129,7 +126,7 @@ jobs:
|
||||
|
||||
api-coverage:
|
||||
name: API Coverage
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
needs:
|
||||
- api-unit
|
||||
- api-integration
|
||||
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
|
||||
create-manifest:
|
||||
needs: build
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: github.repository == 'langgenius/dify'
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
@@ -23,7 +23,7 @@ concurrency:
|
||||
jobs:
|
||||
validate:
|
||||
name: validate manifest + resolve target Dify release
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: github.repository == 'langgenius/dify'
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
|
||||
@@ -9,7 +9,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
db-migration-test-postgres:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
run: uv run --directory api flask upgrade-db
|
||||
|
||||
db-migration-test-mysql:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/agent'
|
||||
|
||||
@@ -10,7 +10,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/dev'
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/enterprise'
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/saas'
|
||||
|
||||
@@ -22,7 +22,7 @@ concurrency:
|
||||
jobs:
|
||||
check-cherry-pick-provenance:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
|
||||
@@ -7,7 +7,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
|
||||
with:
|
||||
|
||||
@@ -21,7 +21,7 @@ concurrency:
|
||||
jobs:
|
||||
pre_job:
|
||||
name: Skip Duplicate Checks
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
outputs:
|
||||
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
|
||||
steps:
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
name: Check Changed Files
|
||||
needs: pre_job
|
||||
if: needs.pre_job.outputs.should_skip != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
outputs:
|
||||
api-changed: ${{ steps.changes.outputs.api }}
|
||||
cli-changed: ${{ steps.changes.outputs.cli }}
|
||||
@@ -81,7 +81,6 @@ jobs:
|
||||
- '.npmrc'
|
||||
- '.nvmrc'
|
||||
- '.github/workflows/cli-tests.yml'
|
||||
- '.github/workflows/cli-docker-build.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
web:
|
||||
- 'web/**'
|
||||
@@ -164,7 +163,7 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped API tests
|
||||
run: echo "No API-related changes detected; skipping API tests."
|
||||
@@ -177,7 +176,7 @@ jobs:
|
||||
- check-changes
|
||||
- api-tests-run
|
||||
- api-tests-skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize API Tests status
|
||||
env:
|
||||
@@ -224,7 +223,7 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped CLI tests
|
||||
run: echo "No CLI-related changes detected; skipping CLI tests."
|
||||
@@ -237,7 +236,7 @@ jobs:
|
||||
- check-changes
|
||||
- cli-tests-run
|
||||
- cli-tests-skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize CLI Tests status
|
||||
env:
|
||||
@@ -284,7 +283,7 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web tests
|
||||
run: echo "No web-related changes detected; skipping web tests."
|
||||
@@ -297,7 +296,7 @@ jobs:
|
||||
- check-changes
|
||||
- web-tests-run
|
||||
- web-tests-skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Tests status
|
||||
env:
|
||||
@@ -344,7 +343,7 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped web full-stack e2e
|
||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||
@@ -357,7 +356,7 @@ jobs:
|
||||
- check-changes
|
||||
- web-e2e-run
|
||||
- web-e2e-skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Web Full-Stack E2E status
|
||||
env:
|
||||
@@ -411,7 +410,7 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped VDB tests
|
||||
run: echo "No VDB-related changes detected; skipping VDB tests."
|
||||
@@ -424,7 +423,7 @@ jobs:
|
||||
- check-changes
|
||||
- vdb-tests-run
|
||||
- vdb-tests-skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize VDB Tests status
|
||||
env:
|
||||
@@ -470,7 +469,7 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped DB migration tests
|
||||
run: echo "No migration-related changes detected; skipping DB migration tests."
|
||||
@@ -483,7 +482,7 @@ jobs:
|
||||
- check-changes
|
||||
- db-migration-test-run
|
||||
- db-migration-test-skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize DB Migration Test status
|
||||
env:
|
||||
@@ -530,7 +529,7 @@ jobs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Report skipped sandbox runtime tests
|
||||
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
|
||||
@@ -543,7 +542,7 @@ jobs:
|
||||
- check-changes
|
||||
- sandbox-runtime-tests-run
|
||||
- sandbox-runtime-tests-skip
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Finalize Sandbox Runtime Tests status
|
||||
env:
|
||||
|
||||
@@ -14,7 +14,7 @@ concurrency:
|
||||
jobs:
|
||||
check-changes:
|
||||
name: Check Changed Files
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
outputs:
|
||||
external-e2e-changed: ${{ steps.changes.outputs.external_e2e }}
|
||||
steps:
|
||||
|
||||
@@ -12,7 +12,7 @@ permissions: {}
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment PR with pyrefly diff
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pyrefly-diff:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
@@ -12,7 +12,7 @@ permissions: {}
|
||||
jobs:
|
||||
comment:
|
||||
name: Comment PR with type coverage
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pyrefly-type-coverage:
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
@@ -13,7 +13,7 @@ concurrency:
|
||||
jobs:
|
||||
sandbox-runtime-unit:
|
||||
name: Sandbox Runtime Unit Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
|
||||
sandbox-runtime-lint:
|
||||
name: Sandbox Runtime Lint
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
sandbox-runtime-integration:
|
||||
name: Sandbox Runtime Integration Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
name: Validate PR title
|
||||
permissions:
|
||||
pull-requests: read
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- name: Complete merge group check
|
||||
if: github.event_name == 'merge_group'
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
jobs:
|
||||
stale:
|
||||
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
@@ -19,7 +19,7 @@ permissions:
|
||||
jobs:
|
||||
python-style:
|
||||
name: Python Style
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
web-style:
|
||||
name: Web Style
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./web
|
||||
@@ -182,7 +182,7 @@ jobs:
|
||||
|
||||
superlinter:
|
||||
name: SuperLinter
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
@@ -17,7 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
name: unit test for Node.js SDK
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -35,7 +35,7 @@ concurrency:
|
||||
jobs:
|
||||
translate:
|
||||
if: github.repository == 'langgenius/dify'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
|
||||
steps:
|
||||
|
||||
@@ -16,7 +16,7 @@ concurrency:
|
||||
jobs:
|
||||
trigger:
|
||||
if: github.repository == 'langgenius/dify'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
test:
|
||||
name: Full VDB Tests
|
||||
if: github.repository == 'langgenius/dify'
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
|
||||
@@ -13,7 +13,7 @@ concurrency:
|
||||
jobs:
|
||||
test:
|
||||
name: VDB Smoke Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
runs-on: depot-ubuntu-24.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
|
||||
@@ -135,16 +135,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -d cucumber-report ]]; then
|
||||
rm -rf cucumber-report-non-external
|
||||
mv cucumber-report cucumber-report-non-external
|
||||
fi
|
||||
|
||||
if [[ -d .logs ]]; then
|
||||
rm -rf .logs-non-external
|
||||
mv .logs .logs-non-external
|
||||
fi
|
||||
|
||||
teardown_external_runtime() {
|
||||
local run_status=$?
|
||||
trap - EXIT
|
||||
|
||||
@@ -17,8 +17,6 @@ jobs:
|
||||
test:
|
||||
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
env:
|
||||
VITEST_COVERAGE_SCOPE: app-components
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
|
||||
@@ -107,6 +107,7 @@ test:
|
||||
echo "Target: $(TARGET_TESTS)"; \
|
||||
uv run --project api --dev pytest $(TARGET_TESTS); \
|
||||
else \
|
||||
set -e; \
|
||||
echo "Running backend unit tests"; \
|
||||
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
|
||||
api/tests/unit_tests \
|
||||
@@ -124,6 +125,7 @@ test-all:
|
||||
echo "Target: $(TARGET_TESTS)"; \
|
||||
uv run --project api --dev pytest $(TARGET_TESTS); \
|
||||
else \
|
||||
set -e; \
|
||||
echo "Running backend unit tests"; \
|
||||
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
|
||||
api/tests/unit_tests \
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, timedelta
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
@@ -11,6 +11,7 @@ from pydantic import (
|
||||
PositiveFloat,
|
||||
PositiveInt,
|
||||
computed_field,
|
||||
field_validator,
|
||||
)
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@@ -280,6 +281,27 @@ class PluginConfig(BaseSettings):
|
||||
default="",
|
||||
)
|
||||
|
||||
@field_validator("PLUGIN_REMOTE_INSTALL_PORT", mode="before")
|
||||
@classmethod
|
||||
def _reject_host_port_shaped_plugin_remote_install_port(cls, v):
|
||||
"""Reject ``host:port``-shaped values with an actionable hint.
|
||||
|
||||
``EXPOSE_PLUGIN_DEBUGGING_PORT`` is overloaded: it feeds both the
|
||||
plugin_daemon ``ports:`` mapping (where ``127.0.0.1:5003`` is valid
|
||||
compose syntax) and this integer app setting advertised in the console.
|
||||
Without this guard a loopback bind spec crashloops the api container
|
||||
with an opaque ``int_parsing`` traceback. See issue #39323.
|
||||
"""
|
||||
if isinstance(v, str) and ":" in v.strip():
|
||||
raise ValueError(
|
||||
"PLUGIN_REMOTE_INSTALL_PORT must be a bare port number, got "
|
||||
f"{v!r}. A 'host:port' value usually means "
|
||||
"EXPOSE_PLUGIN_DEBUGGING_PORT was set to a compose publish spec "
|
||||
"like '127.0.0.1:5003'; bind loopback via a "
|
||||
"docker-compose.override.yaml instead of overloading this var."
|
||||
)
|
||||
return v
|
||||
|
||||
@property
|
||||
def NEW_USER_DEFAULT_PLUGIN_ID_LIST(self) -> list[str]:
|
||||
return [item.strip() for item in self.NEW_USER_DEFAULT_PLUGIN_IDS.split(",") if item.strip()]
|
||||
@@ -1138,6 +1160,16 @@ class HomepageConfig(BaseSettings):
|
||||
default=True,
|
||||
)
|
||||
|
||||
ENABLE_STEP_BY_STEP_TOUR: bool = Field(
|
||||
description="Enable account-level Step-by-step Tour eligibility checks",
|
||||
default=False,
|
||||
)
|
||||
|
||||
STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT: datetime | None = Field(
|
||||
description="UTC timestamp after which newly initialized accounts are eligible for Step-by-step Tour",
|
||||
default=None,
|
||||
)
|
||||
|
||||
|
||||
class RagEtlConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@@ -40,6 +40,7 @@ from . import (
|
||||
init_validate,
|
||||
knowledge_fs_proxy,
|
||||
notification,
|
||||
onboarding,
|
||||
ping,
|
||||
setup,
|
||||
spec,
|
||||
@@ -209,6 +210,7 @@ __all__ = [
|
||||
"notification",
|
||||
"oauth",
|
||||
"oauth_server",
|
||||
"onboarding",
|
||||
"ops_trace",
|
||||
"parameter",
|
||||
"ping",
|
||||
|
||||
@@ -607,6 +607,13 @@ class DatasetListApi(Resource):
|
||||
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
|
||||
)
|
||||
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, dataset_id=dataset.id)
|
||||
else:
|
||||
enterprise_rbac_service.RBACService.DatasetAccess.replace_whitelist(
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
dataset.id,
|
||||
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.SPECIFIC),
|
||||
)
|
||||
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.DatasetPermissions.batch_get(
|
||||
current_tenant_id,
|
||||
@@ -875,7 +882,7 @@ class DatasetIndexingEstimateApi(Resource):
|
||||
file_details = session.scalars(
|
||||
select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids))
|
||||
).all()
|
||||
if file_details is None:
|
||||
if not file_details:
|
||||
raise NotFound("File not found.")
|
||||
|
||||
if file_details:
|
||||
|
||||
@@ -645,7 +645,7 @@ class TrialChatAudioApi(TrialAppResource):
|
||||
def post(self, current_user: Account, trial_app):
|
||||
app_model = trial_app
|
||||
|
||||
file = request.files["file"]
|
||||
file = request.files.get("file")
|
||||
|
||||
try:
|
||||
# Get IDs before they might be detached from session
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Console onboarding APIs.
|
||||
|
||||
This module keeps Step-by-step Tour persistence account-scoped. Workspace IDs
|
||||
are accepted only as presentation overrides; UI-only state such as minimized
|
||||
panels or the currently active task stays on the frontend. PATCH requests are
|
||||
action-based so callers do not replace server-side arrays with stale snapshots.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import account_initialization_required, setup_required, with_current_tenant_id, with_current_user
|
||||
|
||||
StepByStepTourAction = Literal[
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
|
||||
|
||||
|
||||
class StepByStepTourStatePatchPayload(BaseModel):
|
||||
action: StepByStepTourAction = Field(description="State update action")
|
||||
task_id: StepByStepTourTaskId | None = Field(default=None, description="Task ID for task actions")
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_patch_shape(self) -> "StepByStepTourStatePatchPayload":
|
||||
task_actions = {"complete_task", "uncomplete_task"}
|
||||
if self.action in task_actions and self.task_id is None:
|
||||
raise ValueError("task_id is required for task actions")
|
||||
if self.action not in task_actions and self.task_id is not None:
|
||||
raise ValueError("task_id is only supported for task actions")
|
||||
|
||||
return self
|
||||
|
||||
|
||||
class StepByStepTourStateResponse(ResponseModel):
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: list[StepByStepTourTaskId] = Field(default_factory=list)
|
||||
manually_enabled_workspace_ids: list[str] = Field(default_factory=list)
|
||||
manually_disabled_workspace_ids: list[str] = Field(default_factory=list)
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
register_schema_models(console_ns, StepByStepTourStatePatchPayload)
|
||||
register_response_schema_models(console_ns, StepByStepTourStateResponse)
|
||||
|
||||
|
||||
@console_ns.route("/onboarding/step-by-step-tour/state")
|
||||
class StepByStepTourStateApi(Resource):
|
||||
@console_ns.doc("get_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Get account-level Step-by-step Tour state")
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.get_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
session=db.session,
|
||||
),
|
||||
)
|
||||
|
||||
@console_ns.doc("patch_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Update account-level Step-by-step Tour state")
|
||||
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def patch(self, current_tenant_id: str, current_user: Account):
|
||||
payload = StepByStepTourStatePatchPayload.model_validate(console_ns.payload or {})
|
||||
patch = cast(StepByStepTourPatch, payload.model_dump(exclude_unset=True, exclude_none=True))
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.patch_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
patch=patch,
|
||||
session=db.session,
|
||||
),
|
||||
)
|
||||
@@ -101,7 +101,7 @@ class AudioApi(Resource):
|
||||
|
||||
Accepts an audio file upload and returns the transcribed text.
|
||||
"""
|
||||
file = request.files["file"]
|
||||
file = request.files.get("file")
|
||||
|
||||
try:
|
||||
response = AudioService.transcript_asr(
|
||||
|
||||
@@ -531,14 +531,22 @@ class DatasetListApi(DatasetApiResource):
|
||||
except services.errors.dataset.DatasetNameDuplicateError:
|
||||
raise DatasetNameDuplicateError()
|
||||
|
||||
if payload.permission == DatasetPermissionEnum.ALL_TEAM and dify_config.RBAC_ENABLED:
|
||||
RBACService.DatasetAccess.replace_whitelist(
|
||||
tenant_id,
|
||||
current_user.id,
|
||||
dataset.id,
|
||||
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
|
||||
)
|
||||
initialize_created_app_rbac_access_task.delay(tenant_id, current_user.id, dataset_id=dataset.id)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
if payload.permission == DatasetPermissionEnum.ALL_TEAM:
|
||||
RBACService.DatasetAccess.replace_whitelist(
|
||||
tenant_id,
|
||||
current_user.id,
|
||||
dataset.id,
|
||||
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
|
||||
)
|
||||
initialize_created_app_rbac_access_task.delay(tenant_id, current_user.id, dataset_id=dataset.id)
|
||||
else:
|
||||
RBACService.DatasetAccess.replace_whitelist(
|
||||
tenant_id,
|
||||
current_user.id,
|
||||
dataset.id,
|
||||
ReplaceMemberBindings(scope=RBACResourceWhitelistScope.SPECIFIC),
|
||||
)
|
||||
|
||||
return _dump_service_dataset_detail(dataset, session=session), 200
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ class AudioApi(WebApiResource):
|
||||
@web_ns.response(200, "Success", web_ns.models[AudioToTextResponse.__name__])
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
"""Convert audio to text"""
|
||||
file = request.files["file"]
|
||||
file = request.files.get("file")
|
||||
|
||||
try:
|
||||
response = AudioService.transcript_asr(
|
||||
|
||||
@@ -625,7 +625,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
message=message_snapshot,
|
||||
user=user,
|
||||
stream=stream,
|
||||
draft_var_saver_factory=self._get_draft_var_saver_factory(invoke_from, account=user),
|
||||
draft_var_saver_factory=self._get_draft_var_saver_factory(
|
||||
invoke_from,
|
||||
account=user,
|
||||
tenant_id=application_generate_entity.app_config.tenant_id,
|
||||
),
|
||||
)
|
||||
|
||||
return AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
|
||||
@@ -32,6 +32,7 @@ class _DebuggerDraftVariableSaver:
|
||||
self,
|
||||
*,
|
||||
account: Account,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
node_id: str,
|
||||
node_type: NodeType,
|
||||
@@ -39,6 +40,7 @@ class _DebuggerDraftVariableSaver:
|
||||
enclosing_node_id: str | None = None,
|
||||
) -> None:
|
||||
self._account = account
|
||||
self._tenant_id = tenant_id
|
||||
self._app_id = app_id
|
||||
self._node_id = node_id
|
||||
self._node_type = node_type
|
||||
@@ -49,6 +51,7 @@ class _DebuggerDraftVariableSaver:
|
||||
with Session(db.engine) as session, session.begin():
|
||||
DraftVariableSaverImpl(
|
||||
session=session,
|
||||
tenant_id=self._tenant_id,
|
||||
app_id=self._app_id,
|
||||
node_id=self._node_id,
|
||||
node_type=self._node_type,
|
||||
@@ -287,7 +290,12 @@ class BaseAppGenerator:
|
||||
|
||||
@final
|
||||
@staticmethod
|
||||
def _get_draft_var_saver_factory(invoke_from: InvokeFrom, account: Account | EndUser) -> DraftVariableSaverFactory:
|
||||
def _get_draft_var_saver_factory(
|
||||
invoke_from: InvokeFrom,
|
||||
account: Account | EndUser,
|
||||
*,
|
||||
tenant_id: str,
|
||||
) -> DraftVariableSaverFactory:
|
||||
if invoke_from == InvokeFrom.DEBUGGER:
|
||||
assert isinstance(account, Account)
|
||||
|
||||
@@ -300,6 +308,7 @@ class BaseAppGenerator:
|
||||
) -> DraftVariableSaver:
|
||||
return _DebuggerDraftVariableSaver(
|
||||
account=account,
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
node_id=node_id,
|
||||
node_type=node_type,
|
||||
|
||||
@@ -349,6 +349,7 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
draft_var_saver_factory = self._get_draft_var_saver_factory(
|
||||
invoke_from,
|
||||
user,
|
||||
tenant_id=pipeline.tenant_id,
|
||||
)
|
||||
# return response or stream generator
|
||||
response = self._handle_response(
|
||||
|
||||
@@ -399,7 +399,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
|
||||
worker_thread.start()
|
||||
|
||||
draft_var_saver_factory = self._get_draft_var_saver_factory(invoke_from, user)
|
||||
draft_var_saver_factory = self._get_draft_var_saver_factory(
|
||||
invoke_from,
|
||||
user,
|
||||
tenant_id=app_model.tenant_id,
|
||||
)
|
||||
|
||||
# return response or stream generator
|
||||
response = self._handle_response(
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""add step by step tour state
|
||||
|
||||
Revision ID: b8c9d0e1f2a3
|
||||
Revises: 3c9f8e2a1d7b
|
||||
Create Date: 2026-06-29 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
import models
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b8c9d0e1f2a3"
|
||||
down_revision = "3c9f8e2a1d7b"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"account_step_by_step_tour_states",
|
||||
sa.Column("id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("account_id", models.types.StringUUID(), nullable=False),
|
||||
sa.Column("first_workspace_id", models.types.StringUUID(), nullable=True),
|
||||
sa.Column("skipped", sa.Boolean(), server_default=sa.text("false"), nullable=False),
|
||||
sa.Column("completed_task_ids", models.types.AdjustedJSON(), nullable=False),
|
||||
sa.Column("manually_enabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
|
||||
sa.Column("manually_disabled_workspace_ids", models.types.AdjustedJSON(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
|
||||
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table("account_step_by_step_tour_states")
|
||||
@@ -101,6 +101,7 @@ from .model import (
|
||||
UploadFile,
|
||||
)
|
||||
from .oauth import DatasourceOauthParamConfig, DatasourceProvider, OAuthAccessToken
|
||||
from .onboarding import AccountStepByStepTourState
|
||||
from .provider import (
|
||||
LoadBalancingModelConfig,
|
||||
Provider,
|
||||
@@ -155,6 +156,7 @@ __all__ = [
|
||||
"Account",
|
||||
"AccountIntegrate",
|
||||
"AccountStatus",
|
||||
"AccountStepByStepTourState",
|
||||
"AccountTrialAppRecord",
|
||||
"Agent",
|
||||
"AgentConfigDraft",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Account-level onboarding state models."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import DateTime, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from .base import TypeBase, gen_uuidv7_string
|
||||
from .types import AdjustedJSON, StringUUID
|
||||
|
||||
|
||||
class AccountStepByStepTourState(TypeBase):
|
||||
"""Persistent account-level Step-by-step Tour state.
|
||||
|
||||
The tour is account-owned, with workspace IDs stored only as presentation
|
||||
overrides. The first workspace is the workspace context where an eligible
|
||||
account first asks for tour state; subsequent workspaces are opt-in only.
|
||||
"""
|
||||
|
||||
__tablename__ = "account_step_by_step_tour_states"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="account_step_by_step_tour_state_pkey"),
|
||||
sa.UniqueConstraint("account_id", name="account_step_by_step_tour_state_account_id_key"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
StringUUID,
|
||||
insert_default=gen_uuidv7_string,
|
||||
default_factory=gen_uuidv7_string,
|
||||
init=False,
|
||||
)
|
||||
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
first_workspace_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
|
||||
skipped: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
|
||||
completed_task_ids: Mapped[list[str]] = mapped_column(AdjustedJSON, nullable=False, default_factory=list)
|
||||
manually_enabled_workspace_ids: Mapped[list[str]] = mapped_column(
|
||||
AdjustedJSON,
|
||||
nullable=False,
|
||||
default_factory=list,
|
||||
)
|
||||
manually_disabled_workspace_ids: Mapped[list[str]] = mapped_column(
|
||||
AdjustedJSON,
|
||||
nullable=False,
|
||||
default_factory=list,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.current_timestamp(),
|
||||
nullable=False,
|
||||
init=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=func.current_timestamp(),
|
||||
nullable=False,
|
||||
init=False,
|
||||
onupdate=func.current_timestamp(),
|
||||
)
|
||||
@@ -7787,6 +7787,30 @@ Initiate OAuth login process
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [OAuthProviderTokenResponse](#oauthprovidertokenresponse)<br> |
|
||||
|
||||
### [GET] /onboarding/step-by-step-tour/state
|
||||
Get account-level Step-by-step Tour state
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [StepByStepTourStateResponse](#stepbysteptourstateresponse)<br> |
|
||||
|
||||
### [PATCH] /onboarding/step-by-step-tour/state
|
||||
Update account-level Step-by-step Tour state
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [StepByStepTourStatePatchPayload](#stepbysteptourstatepatchpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [StepByStepTourStateResponse](#stepbysteptourstateresponse)<br> |
|
||||
|
||||
### [DELETE] /rag/pipeline/customized/templates/{template_id}
|
||||
#### Parameters
|
||||
|
||||
@@ -21788,6 +21812,24 @@ Query parameters for listing snippet published workflows.
|
||||
| paused | integer | | Yes |
|
||||
| success | integer | | Yes |
|
||||
|
||||
#### StepByStepTourStatePatchPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| action | string, <br>**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action<br>*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes |
|
||||
| task_id | string | Task ID for task actions | No |
|
||||
|
||||
#### StepByStepTourStateResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| completed_task_ids | [ string, <br>**Available values:** "home", "integration", "knowledge", "studio" ] | | No |
|
||||
| first_workspace_id | string | | No |
|
||||
| manually_disabled_workspace_ids | [ string ] | | No |
|
||||
| manually_enabled_workspace_ids | [ string ] | | No |
|
||||
| skipped | boolean | | No |
|
||||
| updated_at | string | | No |
|
||||
|
||||
#### Storage
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -21941,6 +21983,7 @@ Model class for provider system configuration response.
|
||||
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
|
||||
| enable_marketplace | boolean | | Yes |
|
||||
| enable_social_oauth_login | boolean | | Yes |
|
||||
| enable_step_by_step_tour | boolean | | Yes |
|
||||
| enable_trial_app | boolean | | Yes |
|
||||
| is_allow_create_workspace | boolean | | Yes |
|
||||
| is_allow_register | boolean | | Yes |
|
||||
|
||||
@@ -1577,6 +1577,7 @@ Default configuration for form inputs.
|
||||
| enable_learn_app | boolean, <br>**Default:** true | | Yes |
|
||||
| enable_marketplace | boolean | | Yes |
|
||||
| enable_social_oauth_login | boolean | | Yes |
|
||||
| enable_step_by_step_tour | boolean | | Yes |
|
||||
| enable_trial_app | boolean | | Yes |
|
||||
| is_allow_create_workspace | boolean | | Yes |
|
||||
| is_allow_register | boolean | | Yes |
|
||||
|
||||
@@ -183,6 +183,7 @@ class SystemFeatureModel(FeatureResponseModel):
|
||||
enable_trial_app: bool = False
|
||||
enable_explore_banner: bool = False
|
||||
enable_learn_app: bool = True
|
||||
enable_step_by_step_tour: bool = False
|
||||
rbac_enabled: bool = False
|
||||
|
||||
|
||||
@@ -285,6 +286,7 @@ class FeatureService:
|
||||
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
|
||||
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
|
||||
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
|
||||
system_features.enable_step_by_step_tour = dify_config.ENABLE_STEP_BY_STEP_TOUR
|
||||
|
||||
@classmethod
|
||||
def _fulfill_trial_models_from_env(cls) -> list[str]:
|
||||
|
||||
@@ -601,6 +601,7 @@ class RagPipelineService:
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
draft_var_saver = DraftVariableSaver(
|
||||
session=session,
|
||||
tenant_id=pipeline.tenant_id,
|
||||
app_id=pipeline.id,
|
||||
node_id=workflow_node_execution.node_id,
|
||||
node_type=workflow_node_execution.node_type,
|
||||
@@ -1391,6 +1392,7 @@ class RagPipelineService:
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
draft_var_saver = DraftVariableSaver(
|
||||
session=session,
|
||||
tenant_id=pipeline.tenant_id,
|
||||
app_id=pipeline.id,
|
||||
node_id=workflow_node_execution_db_model.node_id,
|
||||
node_type=workflow_node_execution_db_model.node_type,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Account-level Step-by-step Tour persistence."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import ensure_naive_utc
|
||||
from models.account import Account
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
|
||||
STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
|
||||
|
||||
|
||||
class StepByStepTourStateResponse(TypedDict):
|
||||
first_workspace_id: str | None
|
||||
skipped: bool
|
||||
completed_task_ids: list[str]
|
||||
manually_enabled_workspace_ids: list[str]
|
||||
manually_disabled_workspace_ids: list[str]
|
||||
updated_at: datetime | None
|
||||
|
||||
|
||||
class StepByStepTourPatch(TypedDict):
|
||||
action: str
|
||||
task_id: NotRequired[str | None]
|
||||
|
||||
|
||||
class StepByStepTourService:
|
||||
"""Coordinate persisted tour state with account eligibility rules."""
|
||||
|
||||
@classmethod
|
||||
def get_state(
|
||||
cls,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
eligible = cls.is_eligible(account)
|
||||
state = cls._get_state(account.id, session=session)
|
||||
|
||||
if eligible:
|
||||
state = cls._ensure_state(account.id, session=session, state=state)
|
||||
if state.first_workspace_id is None:
|
||||
state.first_workspace_id = current_tenant_id
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
|
||||
return cls._build_response(state=state)
|
||||
|
||||
@classmethod
|
||||
def patch_state(
|
||||
cls,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
patch: StepByStepTourPatch,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
state = cls._ensure_state(account.id, session=session, state=None)
|
||||
cls._apply_action(
|
||||
state=state,
|
||||
action=patch["action"],
|
||||
task_id=patch.get("task_id"),
|
||||
current_tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
return cls._build_response(state=state)
|
||||
|
||||
@classmethod
|
||||
def is_eligible(cls, account: Account) -> bool:
|
||||
if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
|
||||
return False
|
||||
|
||||
rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
|
||||
if rollout_started_at is None:
|
||||
return False
|
||||
|
||||
account_started_at = account.initialized_at or account.created_at
|
||||
if account_started_at is None:
|
||||
return False
|
||||
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _get_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def _ensure_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> AccountStepByStepTourState:
|
||||
if state is None:
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is not None:
|
||||
return state
|
||||
|
||||
state = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(state)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# Another tab/device can create the account row between our read and insert.
|
||||
session.rollback()
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is None:
|
||||
raise
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _apply_action(
|
||||
cls,
|
||||
*,
|
||||
state: AccountStepByStepTourState,
|
||||
action: str,
|
||||
task_id: str | None,
|
||||
current_tenant_id: str,
|
||||
) -> None:
|
||||
match action:
|
||||
case "skip":
|
||||
state.skipped = True
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
case "complete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
|
||||
case "uncomplete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
|
||||
case "enable_current_workspace":
|
||||
state.skipped = False
|
||||
state.manually_enabled_workspace_ids = cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
case "disable_current_workspace":
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported action: {action}")
|
||||
|
||||
@classmethod
|
||||
def _build_response(
|
||||
cls,
|
||||
*,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> StepByStepTourStateResponse:
|
||||
if state is None:
|
||||
return {
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"first_workspace_id": state.first_workspace_id,
|
||||
"skipped": state.skipped,
|
||||
"completed_task_ids": cls._normalize_ids(state.completed_task_ids),
|
||||
"manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
|
||||
"manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
|
||||
"updated_at": state.updated_at,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_task_id(task_id: str) -> None:
|
||||
if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
|
||||
raise ValueError(f"Unsupported task_id: {task_id}")
|
||||
|
||||
@classmethod
|
||||
def _add_id(cls, values: list[str], value: str) -> list[str]:
|
||||
normalized = cls._normalize_ids(values)
|
||||
if value in normalized:
|
||||
return normalized
|
||||
return [*normalized, value]
|
||||
|
||||
@classmethod
|
||||
def _remove_id(cls, values: list[str], value: str) -> list[str]:
|
||||
return [item for item in cls._normalize_ids(values) if item != value]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ids(values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
@@ -823,6 +823,8 @@ _FILENAME_TRANS_TABLE = _make_filename_trans_table()
|
||||
|
||||
|
||||
class DraftVariableSaver:
|
||||
"""Persist draft outputs under the tenant that owns the app or pipeline."""
|
||||
|
||||
# _DUMMY_OUTPUT_IDENTITY is a placeholder output for workflow nodes.
|
||||
# Its sole possible value is `None`.
|
||||
#
|
||||
@@ -842,6 +844,10 @@ class DraftVariableSaver:
|
||||
# Database session used for persisting draft variables.
|
||||
_session: Session
|
||||
|
||||
# Resource owner tenant. An account's current tenant may be unset or point elsewhere
|
||||
# when draft variables are persisted by an asynchronous workflow execution.
|
||||
_tenant_id: str
|
||||
|
||||
# The application ID associated with the draft variables.
|
||||
# This should match the `Workflow.app_id` of the workflow to which the current node belongs.
|
||||
_app_id: str
|
||||
@@ -867,6 +873,7 @@ class DraftVariableSaver:
|
||||
def __init__(
|
||||
self,
|
||||
session: Session,
|
||||
tenant_id: str,
|
||||
app_id: str,
|
||||
node_id: str,
|
||||
node_type: NodeType,
|
||||
@@ -878,6 +885,7 @@ class DraftVariableSaver:
|
||||
# WorkflowNodeExecutionModel/WorkflowNodeExecution, not their `node_execution_id`
|
||||
# field. These are distinct database fields with different purposes.
|
||||
self._session = session
|
||||
self._tenant_id = tenant_id
|
||||
self._app_id = app_id
|
||||
self._node_id = node_id
|
||||
self._node_type = node_type
|
||||
@@ -885,12 +893,6 @@ class DraftVariableSaver:
|
||||
self._user = user
|
||||
self._enclosing_node_id = enclosing_node_id
|
||||
|
||||
def _resolve_app_tenant_id(self) -> str:
|
||||
tenant_id = self._session.scalar(select(App.tenant_id).where(App.id == self._app_id))
|
||||
if not tenant_id:
|
||||
raise ValueError(f"Unable to resolve tenant_id for app {self._app_id}")
|
||||
return tenant_id
|
||||
|
||||
def _create_dummy_output_variable(self):
|
||||
return WorkflowDraftVariable.new_node_variable(
|
||||
app_id=self._app_id,
|
||||
@@ -949,11 +951,10 @@ class DraftVariableSaver:
|
||||
if name == SystemVariableKey.FILES:
|
||||
# Here we know the type of variable must be `array[file]`, we
|
||||
# just rebuild files from the serialized payload.
|
||||
tenant_id = self._resolve_app_tenant_id()
|
||||
files = [
|
||||
build_file_from_stored_mapping(
|
||||
file_mapping=v,
|
||||
tenant_id=tenant_id,
|
||||
tenant_id=self._tenant_id,
|
||||
)
|
||||
for v in value
|
||||
]
|
||||
@@ -1096,8 +1097,8 @@ class DraftVariableSaver:
|
||||
content=original_content_serialized.encode(),
|
||||
mimetype=content_type,
|
||||
user=self._user,
|
||||
tenant_id=self._tenant_id,
|
||||
)
|
||||
assert self._user.current_tenant_id
|
||||
# Create WorkflowDraftVariableFile record
|
||||
variable_file = WorkflowDraftVariableFile(
|
||||
upload_file_id=upload_file.id,
|
||||
@@ -1105,7 +1106,7 @@ class DraftVariableSaver:
|
||||
length=original_length,
|
||||
value_type=value_seg.value_type,
|
||||
app_id=self._app_id,
|
||||
tenant_id=self._user.current_tenant_id,
|
||||
tenant_id=self._tenant_id,
|
||||
user_id=self._user.id,
|
||||
)
|
||||
variable_file.id = str(uuidv7())
|
||||
|
||||
@@ -1060,6 +1060,7 @@ class WorkflowService:
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
draft_var_saver = DraftVariableSaver(
|
||||
session=session,
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=workflow_node_execution.node_id,
|
||||
node_type=workflow_node_execution.node_type,
|
||||
@@ -1210,6 +1211,7 @@ class WorkflowService:
|
||||
with sessionmaker(bind=db.engine).begin() as session:
|
||||
draft_var_saver = DraftVariableSaver(
|
||||
session=session,
|
||||
tenant_id=app_model.tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
node_type=BuiltinNodeTypes.HUMAN_INPUT,
|
||||
|
||||
@@ -311,6 +311,7 @@ class TestDraftVariableLoader(unittest.TestCase):
|
||||
# Use DraftVariableSaver to create offloaded variable (this mimics production)
|
||||
saver = DraftVariableSaver(
|
||||
session=session,
|
||||
tenant_id=self._test_tenant_id,
|
||||
app_id=self._test_app_id,
|
||||
node_id="test_offload_node",
|
||||
node_type=BuiltinNodeTypes.LLM, # Use a real node type
|
||||
|
||||
@@ -8,8 +8,13 @@ from yarl import URL
|
||||
from configs.app_config import DifyConfig
|
||||
|
||||
|
||||
def _clear_environment(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
for name in tuple(os.environ):
|
||||
monkeypatch.delenv(name)
|
||||
|
||||
|
||||
def _set_basic_config_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
@@ -51,7 +56,7 @@ def test_dify_config_preserves_explicit_secret_key(
|
||||
|
||||
def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
# clear system environment variables
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set environment variables using monkeypatch
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
@@ -104,6 +109,24 @@ def test_new_user_default_plugin_ids_are_parsed_from_env(monkeypatch: pytest.Mon
|
||||
]
|
||||
|
||||
|
||||
def test_plugin_remote_install_port_rejects_host_port_spec(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A 'host:port' compose publish spec must produce an actionable error, not an opaque int_parsing traceback."""
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("PLUGIN_REMOTE_INSTALL_PORT", "127.0.0.1:5003")
|
||||
|
||||
with pytest.raises(ValueError, match="must be a bare port number"):
|
||||
DifyConfig(_env_file=None)
|
||||
|
||||
|
||||
def test_plugin_remote_install_port_accepts_bare_port(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv("PLUGIN_REMOTE_INSTALL_PORT", "5003")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
|
||||
assert config.PLUGIN_REMOTE_INSTALL_PORT == 5003
|
||||
|
||||
|
||||
def test_new_user_default_models_are_parsed_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_basic_config_env(monkeypatch)
|
||||
monkeypatch.setenv(
|
||||
@@ -140,7 +163,7 @@ def test_new_user_default_models_reject_duplicate_model_types(monkeypatch: pytes
|
||||
def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Test that HTTP timeout defaults are correctly set"""
|
||||
# clear system environment variables
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set minimal required env vars
|
||||
monkeypatch.setenv("DB_TYPE", "postgresql")
|
||||
@@ -160,7 +183,7 @@ def test_http_timeout_defaults(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
|
||||
def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
config = DifyConfig(_env_file=None)
|
||||
@@ -169,7 +192,7 @@ def test_internal_files_url_falls_back_to_server_console_api_url(monkeypatch: py
|
||||
|
||||
|
||||
def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
monkeypatch.setenv("INTERNAL_FILES_URL", "http://files-internal:5001")
|
||||
monkeypatch.setenv("SERVER_CONSOLE_API_URL", "http://api:5001")
|
||||
|
||||
@@ -183,7 +206,7 @@ def test_internal_files_url_prefers_explicit_value(monkeypatch: pytest.MonkeyPat
|
||||
def test_flask_configs(monkeypatch: pytest.MonkeyPatch):
|
||||
flask_app = Flask("app")
|
||||
# clear system environment variables
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set environment variables using monkeypatch
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
@@ -290,7 +313,7 @@ def test_db_session_timezone_override_can_disable_app_level_timezone_injection(m
|
||||
|
||||
|
||||
def test_pubsub_redis_url_default(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
@@ -313,7 +336,7 @@ def test_pubsub_redis_url_default(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
|
||||
def test_pubsub_redis_url_override(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
@@ -330,7 +353,7 @@ def test_pubsub_redis_url_override(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
|
||||
def test_pubsub_redis_url_required_when_default_unavailable(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
@@ -346,7 +369,7 @@ def test_pubsub_redis_url_required_when_default_unavailable(monkeypatch: pytest.
|
||||
|
||||
|
||||
def test_dify_config_exposes_redis_key_prefix_default(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
@@ -363,7 +386,7 @@ def test_dify_config_exposes_redis_key_prefix_default(monkeypatch: pytest.Monkey
|
||||
|
||||
|
||||
def test_dify_config_reads_redis_key_prefix_from_env(monkeypatch: pytest.MonkeyPatch):
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
monkeypatch.setenv("CONSOLE_WEB_URL", "https://example.com")
|
||||
@@ -415,7 +438,7 @@ def test_celery_broker_url_with_special_chars_password(
|
||||
from kombu.utils.url import parse_url
|
||||
|
||||
# clear system environment variables
|
||||
os.environ.clear()
|
||||
_clear_environment(monkeypatch)
|
||||
|
||||
# Set up basic required environment variables (following existing pattern)
|
||||
monkeypatch.setenv("CONSOLE_API_URL", "https://example.com")
|
||||
|
||||
+17
-13
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -275,9 +276,9 @@ class TestCustomizedPipelineTemplateApi:
|
||||
assert (response, status) == ("", 204)
|
||||
assert deleted_templates == [("template-1", tenant_id)]
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(PipelineCustomizedTemplate,)], indirect=True)
|
||||
def test_post_exports_yaml_from_orm_template(
|
||||
self,
|
||||
database_app: Flask,
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session
|
||||
) -> None:
|
||||
api = CustomizedPipelineTemplateApi()
|
||||
method = unwrap(api.post)
|
||||
@@ -293,26 +294,29 @@ class TestCustomizedPipelineTemplateApi:
|
||||
language="en-US",
|
||||
created_by="00000000-0000-0000-0000-000000000002",
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
template.id = "template-1"
|
||||
sqlite_session.add(template)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
|
||||
with database_app.test_request_context("/rag/pipeline/customized/templates/template-1", method="POST"):
|
||||
response, status = method(api, template.id)
|
||||
with app.test_request_context("/rag/pipeline/customized/templates/template-1", method="POST"):
|
||||
response, status = method(api, "template-1")
|
||||
|
||||
assert status == 200
|
||||
assert response == {"data": "dsl: value"}
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(PipelineCustomizedTemplate,)], indirect=True)
|
||||
def test_post_raises_when_template_is_missing(
|
||||
self,
|
||||
database_app: Flask,
|
||||
self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session
|
||||
) -> None:
|
||||
api = CustomizedPipelineTemplateApi()
|
||||
method = unwrap(api.post)
|
||||
with (
|
||||
database_app.test_request_context("/rag/pipeline/customized/templates/missing", method="POST"),
|
||||
pytest.raises(ValueError, match="Customized pipeline template not found"),
|
||||
):
|
||||
method(api, "44444444-4444-4444-4444-444444444444")
|
||||
assert sqlite_session.get(PipelineCustomizedTemplate, "missing") is None
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
|
||||
with app.test_request_context("/rag/pipeline/customized/templates/missing", method="POST"):
|
||||
with pytest.raises(ValueError, match="Customized pipeline template not found"):
|
||||
method(api, "missing")
|
||||
|
||||
|
||||
class TestPublishCustomizedPipelineTemplateApi:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from unittest.mock import Mock, PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.onboarding import (
|
||||
StepByStepTourStateApi,
|
||||
StepByStepTourStatePatchPayload,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from models.account import Account, AccountStatus
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
|
||||
|
||||
def _account() -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
return account
|
||||
|
||||
|
||||
def _state_response() -> dict[str, object]:
|
||||
return {
|
||||
"first_workspace_id": "workspace-1",
|
||||
"skipped": False,
|
||||
"completed_task_ids": ["home"],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": datetime(2026, 6, 28, tzinfo=UTC),
|
||||
}
|
||||
|
||||
|
||||
def test_get_step_by_step_tour_state(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
get_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "get_state", get_state)
|
||||
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.get)
|
||||
|
||||
with app.test_request_context("/console/api/onboarding/step-by-step-tour/state", method="GET"):
|
||||
result = method(api, "workspace-1", _account())
|
||||
|
||||
assert result == {
|
||||
"first_workspace_id": "workspace-1",
|
||||
"skipped": False,
|
||||
"completed_task_ids": ["home"],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": "2026-06-28T00:00:00Z",
|
||||
}
|
||||
get_state.assert_called_once()
|
||||
assert get_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert get_state.call_args.kwargs["session"] is db.session
|
||||
|
||||
|
||||
def test_patch_step_by_step_tour_state_passes_action_payload(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
patch_state = Mock(return_value=_state_response())
|
||||
monkeypatch.setattr(StepByStepTourService, "patch_state", patch_state)
|
||||
|
||||
api = StepByStepTourStateApi()
|
||||
method = unwrap(api.patch)
|
||||
payload = {"action": "complete_task", "task_id": "studio"}
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/onboarding/step-by-step-tour/state",
|
||||
method="PATCH",
|
||||
json=payload,
|
||||
):
|
||||
with patch.object(type(console_ns), "payload", new_callable=PropertyMock, return_value=payload):
|
||||
result = method(api, "workspace-1", _account())
|
||||
|
||||
assert result["completed_task_ids"] == ["home"]
|
||||
patch_state.assert_called_once()
|
||||
assert patch_state.call_args.kwargs["current_tenant_id"] == "workspace-1"
|
||||
assert patch_state.call_args.kwargs["patch"] == payload
|
||||
assert patch_state.call_args.kwargs["session"] is db.session
|
||||
|
||||
|
||||
def test_patch_payload_rejects_non_action_fields() -> None:
|
||||
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
|
||||
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "skipped": True})
|
||||
|
||||
|
||||
def test_patch_payload_rejects_task_id_without_task_action() -> None:
|
||||
with pytest.raises(ValidationError, match="task_id is only supported for task actions"):
|
||||
StepByStepTourStatePatchPayload.model_validate({"action": "skip", "task_id": "home"})
|
||||
|
||||
|
||||
def test_patch_payload_requires_action() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
StepByStepTourStatePatchPayload.model_validate({"task_id": "home"})
|
||||
@@ -1,3 +1,4 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import override
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -5,6 +6,8 @@ import pytest
|
||||
from flask import Flask, request
|
||||
from flask_login import LoginManager, UserMixin
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from controllers.common.wraps import _extract_resource_id
|
||||
@@ -34,6 +37,7 @@ from controllers.console.wraps import (
|
||||
)
|
||||
from models import Account
|
||||
from models.account import AccountStatus, TenantAccountRole
|
||||
from models.dataset import RateLimitLog
|
||||
from services.feature_service import LicenseStatus
|
||||
|
||||
|
||||
@@ -657,8 +661,7 @@ class TestRateLimiting:
|
||||
"""Test rate limiting decorator"""
|
||||
|
||||
@patch("controllers.console.wraps.redis_client")
|
||||
@patch("controllers.console.wraps.db")
|
||||
def test_should_allow_requests_within_rate_limit(self, mock_db: MagicMock, mock_redis: MagicMock):
|
||||
def test_should_allow_requests_within_rate_limit(self, mock_redis: MagicMock):
|
||||
"""Test that requests within rate limit are allowed"""
|
||||
# Arrange
|
||||
mock_rate_limit = MagicMock()
|
||||
@@ -685,8 +688,13 @@ class TestRateLimiting:
|
||||
mock_redis.zremrangebyscore.assert_called_once()
|
||||
|
||||
@patch("controllers.console.wraps.redis_client")
|
||||
@patch("controllers.console.wraps.db")
|
||||
def test_should_reject_requests_over_rate_limit(self, mock_db: MagicMock, mock_redis: MagicMock):
|
||||
@pytest.mark.parametrize("sqlite_session", [(RateLimitLog,)], indirect=True)
|
||||
def test_should_reject_requests_over_rate_limit(
|
||||
self,
|
||||
mock_redis: MagicMock,
|
||||
sqlite_session: Session,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
"""Test that requests over rate limit are rejected and logged"""
|
||||
# Arrange
|
||||
app = create_app_with_login()
|
||||
@@ -696,8 +704,7 @@ class TestRateLimiting:
|
||||
mock_rate_limit.subscription_plan = "pro"
|
||||
mock_redis.zcard.return_value = 11 # Over limit
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_db.session = mock_session
|
||||
monkeypatch.setattr("controllers.console.wraps.db", SimpleNamespace(session=sqlite_session))
|
||||
|
||||
@cloud_edition_billing_rate_limit_check("knowledge")
|
||||
def knowledge_request():
|
||||
@@ -719,9 +726,11 @@ class TestRateLimiting:
|
||||
assert exc_info.value.code == 403
|
||||
assert "rate limit" in str(exc_info.value.description)
|
||||
|
||||
# Verify rate limit log was created
|
||||
mock_session.add.assert_called_once()
|
||||
mock_session.commit.assert_called_once()
|
||||
rate_limit_log = sqlite_session.scalar(select(RateLimitLog))
|
||||
assert rate_limit_log is not None
|
||||
assert rate_limit_log.tenant_id == "tenant123"
|
||||
assert rate_limit_log.subscription_plan == "pro"
|
||||
assert rate_limit_log.operation == "knowledge"
|
||||
|
||||
|
||||
class TestCloudUtmRecord:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from http import HTTPStatus
|
||||
from inspect import unwrap
|
||||
from io import BytesIO
|
||||
@@ -6,6 +7,8 @@ from unittest.mock import ANY, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session, scoped_session, sessionmaker
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
@@ -36,6 +39,17 @@ from libs.datetime_utils import naive_utc_now
|
||||
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def workspace_session(sqlite_engine: Engine) -> Iterator[scoped_session[Session]]:
|
||||
"""Provide the callable scoped session expected by Flask-SQLAlchemy controllers."""
|
||||
Tenant.metadata.create_all(sqlite_engine, tables=[Tenant.__table__])
|
||||
session = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.remove()
|
||||
|
||||
|
||||
def make_account(account_id: str = "u1") -> Account:
|
||||
account = Account(name="Test User", email=f"{account_id}@example.com")
|
||||
account.id = account_id
|
||||
@@ -370,11 +384,13 @@ class TestTenantInfoResponse:
|
||||
|
||||
|
||||
class TestSwitchWorkspaceApi:
|
||||
def test_switch_success(self, app: Flask):
|
||||
def test_switch_success(self, app: Flask, workspace_session: scoped_session[Session]):
|
||||
api = SwitchWorkspaceApi()
|
||||
method = unwrap(api.post)
|
||||
payload = {"tenant_id": "t2"}
|
||||
tenant = make_tenant("t2")
|
||||
workspace_session.add(tenant)
|
||||
workspace_session.commit()
|
||||
user = make_account()
|
||||
with (
|
||||
app.test_request_context("/workspaces/switch", json=payload),
|
||||
@@ -383,11 +399,10 @@ class TestSwitchWorkspaceApi:
|
||||
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t2"}
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value = tenant
|
||||
result = method(api, session, user)
|
||||
result = method(api, workspace_session, user)
|
||||
|
||||
assert result["result"] == "success"
|
||||
switch_tenant.assert_called_once_with(user, "t2", session=session)
|
||||
switch_tenant.assert_called_once_with(user, "t2", session=workspace_session)
|
||||
|
||||
def test_switch_not_linked(self, app: Flask):
|
||||
api = SwitchWorkspaceApi()
|
||||
@@ -401,7 +416,7 @@ class TestSwitchWorkspaceApi:
|
||||
with pytest.raises(AccountNotLinkTenantError):
|
||||
method(api, MagicMock(), user)
|
||||
|
||||
def test_switch_tenant_not_found(self, app: Flask):
|
||||
def test_switch_tenant_not_found(self, app: Flask, workspace_session: scoped_session[Session]):
|
||||
api = SwitchWorkspaceApi()
|
||||
method = unwrap(api.post)
|
||||
payload = {"tenant_id": "missing"}
|
||||
@@ -410,19 +425,21 @@ class TestSwitchWorkspaceApi:
|
||||
app.test_request_context("/workspaces/switch", json=payload),
|
||||
patch("controllers.console.workspace.workspace.TenantService.switch_tenant"),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value = None
|
||||
with pytest.raises(ValueError):
|
||||
method(api, session, user)
|
||||
method(api, workspace_session, user)
|
||||
|
||||
|
||||
class TestCustomConfigWorkspaceApi:
|
||||
def test_post_success(self, app: Flask):
|
||||
def test_post_success(self, app: Flask, workspace_session: scoped_session[Session]):
|
||||
api = CustomConfigWorkspaceApi()
|
||||
method = unwrap(api.post)
|
||||
tenant = make_tenant(custom_config={})
|
||||
workspace_session.add(tenant)
|
||||
workspace_session.commit()
|
||||
|
||||
payload = {"remove_webapp_brand": True}
|
||||
events = []
|
||||
event.listen(workspace_session, "after_commit", lambda _: events.append("commit"))
|
||||
with (
|
||||
app.test_request_context("/workspaces/custom-config", json=payload),
|
||||
patch(
|
||||
@@ -430,27 +447,29 @@ class TestCustomConfigWorkspaceApi:
|
||||
side_effect=lambda *args, **kwargs: events.append("get_tenant_info") or {"id": "t1"},
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value = tenant
|
||||
session.commit.side_effect = lambda: events.append("commit")
|
||||
result = method(api, session, "t1")
|
||||
result = method(api, workspace_session, "t1")
|
||||
assert result["result"] == "success"
|
||||
assert events == ["commit", "get_tenant_info"]
|
||||
|
||||
def test_logo_fallback(self, app: Flask):
|
||||
def test_logo_fallback(self, app: Flask, workspace_session: scoped_session[Session]):
|
||||
api = CustomConfigWorkspaceApi()
|
||||
method = unwrap(api.post)
|
||||
|
||||
tenant = make_tenant(custom_config={"replace_webapp_logo": "old-logo"})
|
||||
workspace_session.add(tenant)
|
||||
workspace_session.commit()
|
||||
|
||||
payload = {"remove_webapp_brand": False}
|
||||
|
||||
with (
|
||||
app.test_request_context("/workspaces/custom-config", json=payload),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info", return_value={"id": "t1"}
|
||||
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info",
|
||||
return_value={"id": "t1"},
|
||||
),
|
||||
):
|
||||
session = MagicMock()
|
||||
session.get.return_value = tenant
|
||||
result = method(api, session, "t1")
|
||||
result = method(api, workspace_session, "t1")
|
||||
|
||||
assert tenant.custom_config_dict["replace_webapp_logo"] == "old-logo"
|
||||
assert result["result"] == "success"
|
||||
|
||||
@@ -541,14 +560,19 @@ class TestWebappLogoWorkspaceApi:
|
||||
|
||||
|
||||
class TestWorkspaceInfoApi:
|
||||
def test_post_success(self, app: Flask):
|
||||
def test_post_success(self, app: Flask, workspace_session: scoped_session[Session]):
|
||||
api = WorkspaceInfoApi()
|
||||
method = unwrap(api.post)
|
||||
tenant = make_tenant()
|
||||
workspace_session.add(tenant)
|
||||
workspace_session.commit()
|
||||
|
||||
payload = {"name": "New Name"}
|
||||
events = []
|
||||
with (
|
||||
app.test_request_context("/workspaces/info", json=payload),
|
||||
patch("controllers.console.workspace.workspace.db.get_or_404", return_value=tenant),
|
||||
patch("controllers.console.workspace.workspace.db.session", workspace_session),
|
||||
patch(
|
||||
"controllers.console.workspace.workspace.WorkspaceService.get_tenant_info",
|
||||
side_effect=lambda *args, **kwargs: (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.cot_agent_runner import CotAgentRunner
|
||||
from core.agent.entities import AgentScratchpadUnit
|
||||
@@ -26,7 +29,7 @@ class DummyRunner(CotAgentRunner):
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(mocker: MockerFixture):
|
||||
def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[DummyRunner]:
|
||||
# Prevent BaseAgentRunner __init__ from hitting database
|
||||
mocker.patch(
|
||||
"core.agent.base_agent_runner.BaseAgentRunner.organize_agent_history",
|
||||
@@ -81,9 +84,12 @@ def runner(mocker: MockerFixture):
|
||||
runner.agent_callback = None
|
||||
runner.memory = None
|
||||
runner.history_prompt_messages = []
|
||||
runner.session = MagicMock()
|
||||
runner.session = Session(sqlite_engine)
|
||||
|
||||
return runner
|
||||
try:
|
||||
yield runner
|
||||
finally:
|
||||
runner.session.close()
|
||||
|
||||
|
||||
class TestFillInputs:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.agent.errors import AgentMaxIterationError
|
||||
from core.agent.fc_agent_runner import FunctionCallAgentRunner
|
||||
@@ -69,7 +72,7 @@ class DummyResult:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner(mocker: MockerFixture):
|
||||
def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[FunctionCallAgentRunner]:
|
||||
# Completely bypass BaseAgentRunner __init__ to avoid DB / Flask context
|
||||
mocker.patch(
|
||||
"core.agent.base_agent_runner.BaseAgentRunner.__init__",
|
||||
@@ -131,7 +134,7 @@ def runner(mocker: MockerFixture):
|
||||
runner._current_thoughts = []
|
||||
runner.files = []
|
||||
runner.agent_callback = MagicMock()
|
||||
runner.session = MagicMock()
|
||||
runner.session = Session(sqlite_engine)
|
||||
|
||||
runner._init_prompt_tools = MagicMock(return_value=({}, []))
|
||||
runner.create_agent_thought = MagicMock(return_value="thought1")
|
||||
@@ -139,7 +142,10 @@ def runner(mocker: MockerFixture):
|
||||
runner.recalc_llm_max_tokens = MagicMock()
|
||||
runner.update_prompt_message_tool = MagicMock()
|
||||
|
||||
return runner
|
||||
try:
|
||||
yield runner
|
||||
finally:
|
||||
runner.session.close()
|
||||
|
||||
|
||||
# ==============================
|
||||
|
||||
@@ -485,7 +485,11 @@ class TestBaseAppGeneratorExtras:
|
||||
account.id = "account-id"
|
||||
account.tenant_id = "tenant-id"
|
||||
|
||||
factory = base_app_generator._get_draft_var_saver_factory(InvokeFrom.DEBUGGER, account)
|
||||
factory = base_app_generator._get_draft_var_saver_factory(
|
||||
InvokeFrom.DEBUGGER,
|
||||
account,
|
||||
tenant_id="tenant-id",
|
||||
)
|
||||
saver = factory(
|
||||
app_id="app-id",
|
||||
node_id="node-id",
|
||||
|
||||
@@ -185,7 +185,11 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
|
||||
return_value="converted",
|
||||
)
|
||||
mocker.patch.object(WorkflowAppGenerator, "_handle_response", return_value="response")
|
||||
mocker.patch.object(WorkflowAppGenerator, "_get_draft_var_saver_factory", return_value=MagicMock())
|
||||
draft_saver_factory = mocker.patch.object(
|
||||
WorkflowAppGenerator,
|
||||
"_get_draft_var_saver_factory",
|
||||
return_value=MagicMock(),
|
||||
)
|
||||
|
||||
pause_layer = MagicMock(name="pause-layer")
|
||||
mocker.patch(
|
||||
@@ -209,7 +213,7 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
|
||||
|
||||
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", DummyThread)
|
||||
|
||||
app_model = SimpleNamespace(mode="workflow")
|
||||
app_model = SimpleNamespace(mode="workflow", tenant_id="tenant")
|
||||
app_config = SimpleNamespace(app_id="app", tenant_id="tenant", workflow_id="wf")
|
||||
application_generate_entity = SimpleNamespace(
|
||||
task_id="task",
|
||||
@@ -240,6 +244,7 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
|
||||
assert result == "converted"
|
||||
assert worker_kwargs["kwargs"]["graph_engine_layers"] == ("base-layer", pause_layer)
|
||||
assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state
|
||||
assert draft_saver_factory.call_args.kwargs["tenant_id"] == app_model.tenant_id
|
||||
|
||||
|
||||
def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
@@ -301,7 +306,7 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
|
||||
pause_config = SimpleNamespace(session_factory=MagicMock(), state_owner_user_id="owner")
|
||||
|
||||
app_model = SimpleNamespace(mode="workflow")
|
||||
app_model = SimpleNamespace(mode="workflow", tenant_id="tenant")
|
||||
app_config = SimpleNamespace(app_id="app", tenant_id="tenant", workflow_id="workflow")
|
||||
application_generate_entity = SimpleNamespace(
|
||||
task_id="task",
|
||||
|
||||
@@ -4,6 +4,8 @@ from time import time
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.app_config.entities import WorkflowUIBasedAppConfig
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
|
||||
@@ -32,6 +34,13 @@ from models.model import AppMode
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
"""Provide the real session factory injected into the persistence layer."""
|
||||
|
||||
return sessionmaker(sqlite_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def _create_initialized_response_stream_filter() -> ResponseStreamFilter:
|
||||
"""Build a `ResponseStreamFilter` that has already run `initialize()`.
|
||||
|
||||
@@ -211,27 +220,25 @@ class TestPauseStatePersistenceLayer:
|
||||
workflow_execution_id=workflow_execution_id,
|
||||
)
|
||||
|
||||
def test_init_with_dependency_injection(self):
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_init_with_dependency_injection(self, sqlite_session_factory: sessionmaker[Session]):
|
||||
state_owner_user_id = "user-123"
|
||||
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id=state_owner_user_id,
|
||||
generate_entity=self._create_generate_entity(),
|
||||
response_stream_filter=ResponseStreamFilter(),
|
||||
)
|
||||
|
||||
assert layer._session_maker is session_factory
|
||||
assert layer._session_maker is sqlite_session_factory
|
||||
assert layer._state_owner_user_id == state_owner_user_id
|
||||
with pytest.raises(GraphEngineLayerNotInitializedError):
|
||||
_ = layer.graph_runtime_state
|
||||
assert layer.command_channel is None
|
||||
|
||||
def test_initialize_sets_dependencies(self):
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_initialize_sets_dependencies(self, sqlite_session_factory: sessionmaker[Session]):
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id="owner",
|
||||
generate_entity=self._create_generate_entity(),
|
||||
response_stream_filter=ResponseStreamFilter(),
|
||||
@@ -245,11 +252,12 @@ class TestPauseStatePersistenceLayer:
|
||||
assert layer.graph_runtime_state is graph_runtime_state
|
||||
assert layer.command_channel is command_channel
|
||||
|
||||
def test_on_event_with_graph_run_paused_event(self, monkeypatch: pytest.MonkeyPatch):
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_on_event_with_graph_run_paused_event(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
|
||||
):
|
||||
generate_entity = self._create_generate_entity(workflow_execution_id="run-123")
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id="owner-123",
|
||||
generate_entity=generate_entity,
|
||||
response_stream_filter=_create_initialized_response_stream_filter(),
|
||||
@@ -272,7 +280,7 @@ class TestPauseStatePersistenceLayer:
|
||||
|
||||
layer.on_event(event)
|
||||
|
||||
mock_factory.assert_called_once_with(session_factory)
|
||||
mock_factory.assert_called_once_with(sqlite_session_factory)
|
||||
assert mock_repo.create_workflow_pause.call_count == 1
|
||||
call_kwargs = mock_repo.create_workflow_pause.call_args.kwargs
|
||||
assert call_kwargs["workflow_run_id"] == "run-123"
|
||||
@@ -285,11 +293,12 @@ class TestPauseStatePersistenceLayer:
|
||||
|
||||
assert isinstance(pause_reasons, list)
|
||||
|
||||
def test_on_event_enriches_hitl_pause_reasons_before_persisting(self, monkeypatch: pytest.MonkeyPatch):
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_on_event_enriches_hitl_pause_reasons_before_persisting(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
|
||||
):
|
||||
generate_entity = self._create_generate_entity(workflow_execution_id="run-123")
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id="owner-123",
|
||||
generate_entity=generate_entity,
|
||||
response_stream_filter=_create_initialized_response_stream_filter(),
|
||||
@@ -343,10 +352,11 @@ class TestPauseStatePersistenceLayer:
|
||||
)
|
||||
assert mock_repo.create_workflow_pause.call_args.kwargs["pause_reasons"] == [enriched_reason]
|
||||
|
||||
def test_on_event_ignores_non_paused_events(self, monkeypatch: pytest.MonkeyPatch):
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_on_event_ignores_non_paused_events(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
|
||||
):
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id="owner-123",
|
||||
generate_entity=self._create_generate_entity(),
|
||||
response_stream_filter=ResponseStreamFilter(),
|
||||
@@ -372,10 +382,11 @@ class TestPauseStatePersistenceLayer:
|
||||
mock_factory.assert_not_called()
|
||||
mock_repo.create_workflow_pause.assert_not_called()
|
||||
|
||||
def test_on_event_raises_when_graph_runtime_state_is_uninitialized(self):
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_on_event_raises_when_graph_runtime_state_is_uninitialized(
|
||||
self, sqlite_session_factory: sessionmaker[Session]
|
||||
):
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id="owner-123",
|
||||
generate_entity=self._create_generate_entity(),
|
||||
response_stream_filter=ResponseStreamFilter(),
|
||||
@@ -386,10 +397,11 @@ class TestPauseStatePersistenceLayer:
|
||||
with pytest.raises(GraphEngineLayerNotInitializedError):
|
||||
layer.on_event(event)
|
||||
|
||||
def test_on_event_asserts_when_workflow_execution_id_missing(self, monkeypatch: pytest.MonkeyPatch):
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_on_event_asserts_when_workflow_execution_id_missing(
|
||||
self, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
|
||||
):
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id="owner-123",
|
||||
generate_entity=self._create_generate_entity(),
|
||||
response_stream_filter=_create_initialized_response_stream_filter(),
|
||||
@@ -494,12 +506,13 @@ def test_workflow_resumption_context_dumps_loads_roundtrip(state: WorkflowResump
|
||||
assert restored_entity.extras["trace_session_id"] == "session-1"
|
||||
|
||||
|
||||
def test_on_event_persists_response_stream_filter_dump(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
session_factory = Mock(name="session_factory")
|
||||
def test_on_event_persists_response_stream_filter_dump(
|
||||
monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
|
||||
) -> None:
|
||||
generate_entity = TestPauseStatePersistenceLayer._create_generate_entity(workflow_execution_id="run-123")
|
||||
response_stream_filter = _create_initialized_response_stream_filter()
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
session_factory=sqlite_session_factory,
|
||||
state_owner_user_id="owner-123",
|
||||
generate_entity=generate_entity,
|
||||
response_stream_filter=response_stream_filter,
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.app_config.entities import DatasetRetrieveConfigEntity
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@@ -14,13 +17,14 @@ def _retrieve_config() -> DatasetRetrieveConfigEntity:
|
||||
return DatasetRetrieveConfigEntity(retrieve_strategy=DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE)
|
||||
|
||||
|
||||
def test_get_dataset_tools_returns_empty_for_empty_dataset_ids() -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_dataset_tools_returns_empty_for_empty_dataset_ids(sqlite_session: Session) -> None:
|
||||
# Arrange
|
||||
retrieve_config = _retrieve_config()
|
||||
|
||||
# Act
|
||||
tools = DatasetRetrieverTool.get_dataset_tools(
|
||||
session=MagicMock(),
|
||||
session=sqlite_session,
|
||||
tenant_id="tenant",
|
||||
dataset_ids=[],
|
||||
retrieve_config=retrieve_config,
|
||||
@@ -35,13 +39,14 @@ def test_get_dataset_tools_returns_empty_for_empty_dataset_ids() -> None:
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_get_dataset_tools_returns_empty_for_missing_retrieve_config() -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_dataset_tools_returns_empty_for_missing_retrieve_config(sqlite_session: Session) -> None:
|
||||
# Arrange
|
||||
dataset_ids = ["d1"]
|
||||
|
||||
# Act
|
||||
tools = DatasetRetrieverTool.get_dataset_tools(
|
||||
session=MagicMock(),
|
||||
session=sqlite_session,
|
||||
tenant_id="tenant",
|
||||
dataset_ids=dataset_ids,
|
||||
retrieve_config=None, # type: ignore[arg-type]
|
||||
@@ -56,7 +61,8 @@ def test_get_dataset_tools_returns_empty_for_missing_retrieve_config() -> None:
|
||||
assert tools == []
|
||||
|
||||
|
||||
def test_get_dataset_tools_builds_tool_and_restores_strategy() -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_get_dataset_tools_builds_tool_and_restores_strategy(sqlite_session: Session) -> None:
|
||||
# Arrange
|
||||
retrieve_config = _retrieve_config()
|
||||
retrieval_tool = SimpleNamespace(name="dataset_tool", description="desc", run=lambda query: f"result:{query}")
|
||||
@@ -66,7 +72,7 @@ def test_get_dataset_tools_builds_tool_and_restores_strategy() -> None:
|
||||
# Act
|
||||
with patch("core.tools.utils.dataset_retriever_tool.DatasetRetrieval", return_value=feature):
|
||||
tools = DatasetRetrieverTool.get_dataset_tools(
|
||||
session=MagicMock(),
|
||||
session=sqlite_session,
|
||||
tenant_id="tenant",
|
||||
dataset_ids=["d1"],
|
||||
retrieve_config=retrieve_config,
|
||||
@@ -83,7 +89,7 @@ def test_get_dataset_tools_builds_tool_and_restores_strategy() -> None:
|
||||
assert retrieve_config.retrieve_strategy == DatasetRetrieveConfigEntity.RetrieveStrategy.MULTIPLE
|
||||
|
||||
|
||||
def _build_dataset_tool() -> tuple[DatasetRetrieverTool, SimpleNamespace]:
|
||||
def _build_dataset_tool(sqlite_session: Session) -> tuple[DatasetRetrieverTool, SimpleNamespace]:
|
||||
retrieval_tool = SimpleNamespace(
|
||||
name="dataset_tool",
|
||||
description="desc",
|
||||
@@ -93,7 +99,7 @@ def _build_dataset_tool() -> tuple[DatasetRetrieverTool, SimpleNamespace]:
|
||||
feature.to_dataset_retriever_tool.return_value = [retrieval_tool]
|
||||
with patch("core.tools.utils.dataset_retriever_tool.DatasetRetrieval", return_value=feature):
|
||||
tools = DatasetRetrieverTool.get_dataset_tools(
|
||||
session=MagicMock(),
|
||||
session=sqlite_session,
|
||||
tenant_id="tenant",
|
||||
dataset_ids=["d1"],
|
||||
retrieve_config=_retrieve_config(),
|
||||
@@ -106,9 +112,10 @@ def _build_dataset_tool() -> tuple[DatasetRetrieverTool, SimpleNamespace]:
|
||||
return tools[0], retrieval_tool
|
||||
|
||||
|
||||
def test_runtime_parameters_shape() -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_runtime_parameters_shape(sqlite_session: Session) -> None:
|
||||
# Arrange
|
||||
tool, _ = _build_dataset_tool()
|
||||
tool, _ = _build_dataset_tool(sqlite_session)
|
||||
|
||||
# Act
|
||||
params = tool.get_runtime_parameters()
|
||||
@@ -118,33 +125,36 @@ def test_runtime_parameters_shape() -> None:
|
||||
assert params[0].name == "query"
|
||||
|
||||
|
||||
def test_empty_query_behavior() -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_empty_query_behavior(sqlite_session: Session) -> None:
|
||||
# Arrange
|
||||
tool, _ = _build_dataset_tool()
|
||||
tool, _ = _build_dataset_tool(sqlite_session)
|
||||
|
||||
# Act
|
||||
empty_query = list(tool.invoke(session=MagicMock(), user_id="u", tool_parameters={}))
|
||||
empty_query = list(tool.invoke(session=sqlite_session, user_id="u", tool_parameters={}))
|
||||
|
||||
# Assert
|
||||
assert len(empty_query) == 1
|
||||
assert empty_query[0].message.text == "please input query"
|
||||
|
||||
|
||||
def test_query_invocation_result() -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_query_invocation_result(sqlite_session: Session) -> None:
|
||||
# Arrange
|
||||
tool, _ = _build_dataset_tool()
|
||||
tool, _ = _build_dataset_tool(sqlite_session)
|
||||
|
||||
# Act
|
||||
result = list(tool.invoke(session=MagicMock(), user_id="u", tool_parameters={"query": "hello"}))
|
||||
result = list(tool.invoke(session=sqlite_session, user_id="u", tool_parameters={"query": "hello"}))
|
||||
|
||||
# Assert
|
||||
assert len(result) == 1
|
||||
assert result[0].message.text == "result:hello"
|
||||
|
||||
|
||||
def test_validate_credentials() -> None:
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_validate_credentials(sqlite_session: Session) -> None:
|
||||
# Arrange
|
||||
tool, _ = _build_dataset_tool()
|
||||
tool, _ = _build_dataset_tool(sqlite_session)
|
||||
|
||||
# Act
|
||||
result = tool.validate_credentials(credentials={}, parameters={}, format_only=False)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""MCP tool tests using real SQLite sessions for the ORM invocation contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.mcp.types import (
|
||||
@@ -97,7 +100,8 @@ def test_mcp_tool_usage_extraction_helpers():
|
||||
assert derived.total_tokens == 0
|
||||
|
||||
|
||||
def test_mcp_tool_invoke_handles_content_types_and_structured_output():
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_mcp_tool_invoke_handles_content_types_and_structured_output(sqlite_session: Session):
|
||||
tool = _build_mcp_tool()
|
||||
img_data = base64.b64encode(b"img").decode()
|
||||
blob_data = base64.b64encode(b"blob").decode()
|
||||
@@ -123,7 +127,7 @@ def test_mcp_tool_invoke_handles_content_types_and_structured_output():
|
||||
)
|
||||
|
||||
with patch.object(MCPTool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool.invoke(session=MagicMock(), user_id="user-1", tool_parameters={"a": 1}))
|
||||
messages = list(tool.invoke(session=sqlite_session, user_id="user-1", tool_parameters={"a": 1}))
|
||||
|
||||
types = [m.type for m in messages]
|
||||
assert ToolInvokeMessage.MessageType.JSON in types
|
||||
@@ -133,7 +137,8 @@ def test_mcp_tool_invoke_handles_content_types_and_structured_output():
|
||||
assert tool.latest_usage.total_tokens == 5
|
||||
|
||||
|
||||
def test_mcp_tool_invoke_raises_for_unsupported_embedded_resource():
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_mcp_tool_invoke_raises_for_unsupported_embedded_resource(sqlite_session: Session):
|
||||
tool = _build_mcp_tool()
|
||||
# Use model_construct to bypass pydantic validation and force unsupported resource path.
|
||||
bad_resource = EmbeddedResource.model_construct(type="resource", resource=object())
|
||||
@@ -141,7 +146,7 @@ def test_mcp_tool_invoke_raises_for_unsupported_embedded_resource():
|
||||
|
||||
with patch.object(MCPTool, "invoke_remote_mcp_tool", return_value=result):
|
||||
with pytest.raises(ToolInvokeError, match="Unsupported embedded resource type"):
|
||||
list(tool.invoke(session=MagicMock(), user_id="user-1", tool_parameters={}))
|
||||
list(tool.invoke(session=sqlite_session, user_id="user-1", tool_parameters={}))
|
||||
|
||||
|
||||
def test_mcp_tool_handle_none_parameter_filters_empty_values():
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from collections.abc import Iterator
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import ANY, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import ChatAppGenerateEntity
|
||||
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit
|
||||
@@ -16,40 +15,29 @@ from models import TenantCreditPool
|
||||
from models.provider import ProviderType
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _patched_credit_pool_session_factory(engine: Engine) -> Generator[None, None, None]:
|
||||
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
sessions = []
|
||||
|
||||
def _session():
|
||||
session = session_maker()
|
||||
sessions.append(session)
|
||||
return session
|
||||
|
||||
with patch("events.event_handlers.update_provider_when_message_created.db", SimpleNamespace(session=_session)):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for session in sessions:
|
||||
session.close()
|
||||
@pytest.fixture
|
||||
def credit_pool_session_factory(sqlite_engine: Engine) -> Iterator[sessionmaker[Session]]:
|
||||
"""Bind message-created accounting to fixture-owned SQLite sessions."""
|
||||
TenantCreditPool.__table__.create(sqlite_engine)
|
||||
session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with patch("events.event_handlers.update_provider_when_message_created.db.session", session_factory):
|
||||
yield session_factory
|
||||
|
||||
|
||||
def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient() -> None:
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
TenantCreditPool.__table__.create(engine)
|
||||
def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_insufficient(
|
||||
credit_pool_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
pool_id = str(uuid4())
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
TenantCreditPool.__table__.insert(),
|
||||
{
|
||||
"id": pool_id,
|
||||
"tenant_id": tenant_id,
|
||||
"pool_type": ProviderQuotaType.TRIAL,
|
||||
"quota_limit": 10,
|
||||
"quota_used": 9,
|
||||
},
|
||||
)
|
||||
pool = TenantCreditPool(
|
||||
tenant_id=tenant_id,
|
||||
pool_type=ProviderQuotaType.TRIAL,
|
||||
quota_limit=10,
|
||||
quota_used=9,
|
||||
)
|
||||
pool.id = pool_id
|
||||
with credit_pool_session_factory.begin() as session:
|
||||
session.add(pool)
|
||||
|
||||
system_configuration = SimpleNamespace(
|
||||
current_quota_type=ProviderQuotaType.TRIAL,
|
||||
@@ -77,7 +65,6 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
|
||||
message = SimpleNamespace(message_tokens=2, answer_tokens=1)
|
||||
|
||||
with (
|
||||
_patched_credit_pool_session_factory(engine),
|
||||
patch.object(update_provider_when_message_created, "_execute_provider_updates"),
|
||||
):
|
||||
update_provider_when_message_created.handle(
|
||||
@@ -85,8 +72,8 @@ def test_message_created_trial_credit_accounting_does_not_raise_when_balance_is_
|
||||
application_generate_entity=application_generate_entity,
|
||||
)
|
||||
|
||||
with engine.connect() as connection:
|
||||
quota_used = connection.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id))
|
||||
with credit_pool_session_factory() as session:
|
||||
quota_used = session.scalar(select(TenantCreditPool.quota_used).where(TenantCreditPool.id == pool_id))
|
||||
|
||||
assert quota_used == 10
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
@@ -29,7 +30,8 @@ def _service(preview=_SKILL_MD_PREVIEW):
|
||||
return SkillToolInferenceService(drive_service=drive), drive
|
||||
|
||||
|
||||
def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_returns_suggestions_with_inferred_from(monkeypatch, sqlite_session: Session):
|
||||
service, drive = _service()
|
||||
raw = (
|
||||
'{"inferable": true, "reason": null, "cli_tools": [{"name": "ffmpeg",'
|
||||
@@ -38,8 +40,12 @@ def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
|
||||
' "env_suggestions": [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": true}]}]}'
|
||||
)
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
session = MagicMock()
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=session)
|
||||
result = service.infer(
|
||||
tenant_id="t-1",
|
||||
agent_id="a-1",
|
||||
slug="audio-transcribe",
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert result["inferable"] is True
|
||||
tool = result["cli_tools"][0]
|
||||
@@ -47,11 +53,13 @@ def test_infer_returns_suggestions_with_inferred_from(monkeypatch):
|
||||
assert tool["inferred_from"] == "audio-transcribe"
|
||||
assert tool["env_suggestions"] == [{"key": "OPENAI_API_KEY", "reason": "whisper call", "secret_likely": True}]
|
||||
drive.preview.assert_called_once_with(
|
||||
tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=session
|
||||
tenant_id="t-1", agent_id="a-1", key="audio-transcribe/SKILL.md", session=sqlite_session
|
||||
)
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
def test_infer_threads_skill_md_into_the_prompt(monkeypatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_threads_skill_md_into_the_prompt(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
@@ -60,21 +68,25 @@ def test_infer_threads_skill_md_into_the_prompt(monkeypatch):
|
||||
return '{"inferable": false, "cli_tools": [], "reason": "none"}'
|
||||
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(fake_invoke)):
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
|
||||
assert "Files inside the skill package" not in captured["prompt"]
|
||||
assert "ffmpeg" in captured["prompt"] # SKILL.md body present
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
def test_infer_not_inferable_passes_reason_through(monkeypatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_not_inferable_passes_reason_through(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
raw = '{"inferable": false, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
assert result == {"inferable": False, "cli_tools": [], "reason": "SKILL.md 未描述任何外部命令依赖"}
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
def test_infer_retries_once_then_422(monkeypatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_retries_once_then_422(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
calls: list[int] = []
|
||||
|
||||
@@ -84,37 +96,44 @@ def test_infer_retries_once_then_422(monkeypatch):
|
||||
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(bad_invoke)):
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
|
||||
assert len(calls) == 2 # one retry
|
||||
assert exc_info.value.code == "inference_failed"
|
||||
assert exc_info.value.status_code == 422
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
def test_infer_repairs_slightly_malformed_json(monkeypatch):
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_infer_repairs_slightly_malformed_json(monkeypatch, sqlite_session: Session):
|
||||
service, _ = _service()
|
||||
raw = 'Here you go: {"inferable": true, "cli_tools": [], "reason": null,}'
|
||||
with patch.object(SkillToolInferenceService, "_invoke", staticmethod(lambda **kwargs: raw)):
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=MagicMock())
|
||||
result = service.infer(tenant_id="t-1", agent_id="a-1", slug="audio-transcribe", session=sqlite_session)
|
||||
assert result["inferable"] is True
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
def test_missing_skill_maps_to_404():
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_missing_skill_maps_to_404(sqlite_session: Session):
|
||||
drive = MagicMock()
|
||||
drive.preview.side_effect = AgentDriveError("drive_key_not_found", "nope", status_code=404)
|
||||
service = SkillToolInferenceService(drive_service=drive)
|
||||
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=MagicMock())
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="ghost", session=sqlite_session)
|
||||
assert exc_info.value.code == "skill_not_found"
|
||||
assert exc_info.value.status_code == 404
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
def test_binary_skill_md_maps_to_404():
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_binary_skill_md_maps_to_404(sqlite_session: Session):
|
||||
service, _ = _service(preview={"key": "x/SKILL.md", "size": 1, "truncated": False, "binary": True, "text": None})
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=MagicMock())
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
|
||||
assert exc_info.value.code == "skill_not_found"
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
|
||||
# ── real-path coverage: _invoke / passthrough ────────────────────────────────
|
||||
@@ -157,11 +176,13 @@ def test_invoke_maps_model_failure_to_422_and_success_returns_text(monkeypatch):
|
||||
assert call["stream"] is False
|
||||
|
||||
|
||||
def test_load_skill_md_passes_through_non_missing_drive_errors():
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_load_skill_md_passes_through_non_missing_drive_errors(sqlite_session: Session):
|
||||
drive = MagicMock()
|
||||
drive.preview.side_effect = AgentDriveError("agent_not_found", "tenant mismatch", status_code=404)
|
||||
service = SkillToolInferenceService(drive_service=drive)
|
||||
|
||||
with pytest.raises(SkillToolInferenceError) as exc_info:
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=MagicMock())
|
||||
service.infer(tenant_id="t-1", agent_id="a-1", slug="x", session=sqlite_session)
|
||||
assert exc_info.value.code == "agent_not_found"
|
||||
assert not sqlite_session.in_transaction()
|
||||
|
||||
+13
-16
@@ -6,6 +6,7 @@ import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from repositories.api_workflow_run_repository import WorkflowRunCleanupRef
|
||||
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
|
||||
@@ -472,24 +473,24 @@ class TestRunDryRunMode:
|
||||
|
||||
|
||||
class TestTriggerLogMethods:
|
||||
def test_delete_trigger_logs(self, cleanup):
|
||||
session = MagicMock()
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_delete_trigger_logs(self, cleanup, sqlite_session: Session):
|
||||
with patch(
|
||||
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.SQLAlchemyWorkflowTriggerLogRepository"
|
||||
) as RepoClass:
|
||||
instance = RepoClass.return_value
|
||||
instance.delete_by_run_ids.return_value = 5
|
||||
result = cleanup._delete_trigger_logs(session, ["r1", "r2"])
|
||||
result = cleanup._delete_trigger_logs(sqlite_session, ["r1", "r2"])
|
||||
assert result == 5
|
||||
|
||||
def test_count_trigger_logs(self, cleanup):
|
||||
session = MagicMock()
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_count_trigger_logs(self, cleanup, sqlite_session: Session):
|
||||
with patch(
|
||||
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.SQLAlchemyWorkflowTriggerLogRepository"
|
||||
) as RepoClass:
|
||||
instance = RepoClass.return_value
|
||||
instance.count_by_run_ids.return_value = 3
|
||||
result = cleanup._count_trigger_logs(session, ["r1"])
|
||||
result = cleanup._count_trigger_logs(sqlite_session, ["r1"])
|
||||
assert result == 3
|
||||
|
||||
|
||||
@@ -499,26 +500,22 @@ class TestTriggerLogMethods:
|
||||
|
||||
|
||||
class TestNodeExecutionMethods:
|
||||
def test_count_node_executions(self, cleanup):
|
||||
session = MagicMock()
|
||||
session.get_bind.return_value = MagicMock()
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_count_node_executions(self, cleanup, sqlite_session: Session):
|
||||
with patch(
|
||||
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
|
||||
) as factory:
|
||||
repo = factory.create_api_workflow_node_execution_repository.return_value
|
||||
repo.count_by_runs.return_value = (10, 2)
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
|
||||
result = cleanup._count_node_executions_by_run_ids(session, ["r1"])
|
||||
result = cleanup._count_node_executions_by_run_ids(sqlite_session, ["r1"])
|
||||
assert result == (10, 2)
|
||||
|
||||
def test_delete_node_executions(self, cleanup):
|
||||
session = MagicMock()
|
||||
session.get_bind.return_value = MagicMock()
|
||||
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
|
||||
def test_delete_node_executions(self, cleanup, sqlite_session: Session):
|
||||
with patch(
|
||||
"services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.DifyAPIRepositoryFactory"
|
||||
) as factory:
|
||||
repo = factory.create_api_workflow_node_execution_repository.return_value
|
||||
repo.delete_by_runs.return_value = (5, 1)
|
||||
with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.sessionmaker"):
|
||||
result = cleanup._delete_node_executions_by_run_ids(session, ["r1"])
|
||||
result = cleanup._delete_node_executions_by_run_ids(sqlite_session, ["r1"])
|
||||
assert result == (5, 1)
|
||||
|
||||
@@ -6,6 +6,7 @@ from services.feature_service import FeatureService, SystemFeatureModel
|
||||
|
||||
def test_system_feature_model_defaults_enable_learn_app():
|
||||
assert SystemFeatureModel().enable_learn_app is True
|
||||
assert SystemFeatureModel().enable_step_by_step_tour is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
@@ -15,3 +16,12 @@ def test_get_system_features_reads_enable_learn_app(monkeypatch: pytest.MonkeyPa
|
||||
result = FeatureService.get_system_features()
|
||||
|
||||
assert result.enable_learn_app is enabled
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_get_system_features_reads_enable_step_by_step_tour(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
|
||||
monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
|
||||
|
||||
result = FeatureService.get_system_features()
|
||||
|
||||
assert result.enable_step_by_step_tour is enabled
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from models.account import Account, AccountStatus
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from services import step_by_step_tour_service as service_module
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
|
||||
|
||||
class _ScalarResult:
|
||||
def __init__(self, state: AccountStepByStepTourState | None) -> None:
|
||||
self._state = state
|
||||
|
||||
def scalar_one_or_none(self) -> AccountStepByStepTourState | None:
|
||||
return self._state
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self, state: AccountStepByStepTourState | None = None) -> None:
|
||||
self.state = state
|
||||
self.added: list[AccountStepByStepTourState] = []
|
||||
self.commit_count = 0
|
||||
self.flush_count = 0
|
||||
self.refresh_count = 0
|
||||
self.rollback_count = 0
|
||||
|
||||
def execute(self, _stmt) -> _ScalarResult:
|
||||
return _ScalarResult(self.state)
|
||||
|
||||
def add(self, state: AccountStepByStepTourState) -> None:
|
||||
self.state = state
|
||||
self.added.append(state)
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flush_count += 1
|
||||
|
||||
def commit(self) -> None:
|
||||
self.commit_count += 1
|
||||
|
||||
def refresh(self, state: AccountStepByStepTourState) -> None:
|
||||
self.refresh_count += 1
|
||||
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
|
||||
def rollback(self) -> None:
|
||||
self.rollback_count += 1
|
||||
|
||||
|
||||
class _RaceInsertSession(_FakeSession):
|
||||
def __init__(self, state_after_rollback: AccountStepByStepTourState) -> None:
|
||||
super().__init__(state=None)
|
||||
self.state_after_rollback = state_after_rollback
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flush_count += 1
|
||||
raise IntegrityError("insert", {}, Exception("duplicate"))
|
||||
|
||||
def rollback(self) -> None:
|
||||
super().rollback()
|
||||
self.state = self.state_after_rollback
|
||||
|
||||
|
||||
def _account(*, initialized_at: datetime | None = None, created_at: datetime | None = None) -> Account:
|
||||
account = Account(name="User", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
account.id = "account-1"
|
||||
account.initialized_at = initialized_at
|
||||
account.created_at = created_at or datetime(2026, 6, 28)
|
||||
return account
|
||||
|
||||
|
||||
def _state() -> AccountStepByStepTourState:
|
||||
state = AccountStepByStepTourState(account_id="account-1")
|
||||
state.updated_at = datetime(2026, 6, 28, tzinfo=UTC)
|
||||
return state
|
||||
|
||||
|
||||
def _set_tour_config(monkeypatch: pytest.MonkeyPatch, *, enabled: bool, rollout_started_at: datetime | None) -> None:
|
||||
monkeypatch.setattr(service_module.dify_config, "ENABLE_STEP_BY_STEP_TOUR", enabled)
|
||||
monkeypatch.setattr(service_module.dify_config, "STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT", rollout_started_at)
|
||||
|
||||
|
||||
def test_get_state_creates_state_and_records_first_workspace_for_eligible_account(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
session = _FakeSession()
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["first_workspace_id"] == "workspace-1"
|
||||
assert result["completed_task_ids"] == []
|
||||
assert len(session.added) == 1
|
||||
assert session.added[0].account_id == "account-1"
|
||||
assert session.commit_count == 1
|
||||
assert session.refresh_count == 1
|
||||
|
||||
|
||||
def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED")
|
||||
|
||||
result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28)))
|
||||
|
||||
assert result is True
|
||||
|
||||
|
||||
def test_get_state_does_not_create_state_for_ineligible_account_without_existing_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1))
|
||||
session = _FakeSession()
|
||||
|
||||
result = StepByStepTourService.get_state(
|
||||
account=_account(initialized_at=datetime(2026, 5, 31)),
|
||||
current_tenant_id="workspace-1",
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result == {
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
assert session.added == []
|
||||
assert session.commit_count == 0
|
||||
|
||||
|
||||
def test_patch_state_persists_even_when_account_is_not_eligible(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
session = _FakeSession()
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["skipped"] is False
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
assert len(session.added) == 1
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
def test_patch_state_skip_action_sets_skipped_and_removes_current_workspace_enable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
session = _FakeSession(state=state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "skip"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["skipped"] is True
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == []
|
||||
assert session.added == []
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
def test_patch_state_disable_action_moves_current_workspace_to_disabled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.manually_enabled_workspace_ids = ["workspace-1", "workspace-2"]
|
||||
session = _FakeSession(state=state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "disable_current_workspace"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-2"]
|
||||
assert result["manually_disabled_workspace_ids"] == ["workspace-1"]
|
||||
assert session.commit_count == 1
|
||||
|
||||
|
||||
def test_patch_state_complete_and_uncomplete_task(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
state = _state()
|
||||
state.completed_task_ids = ["home"]
|
||||
session = _FakeSession(state=state)
|
||||
|
||||
StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "complete_task", "task_id": "studio"},
|
||||
session=session,
|
||||
)
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-1",
|
||||
patch={"action": "uncomplete_task", "task_id": "home"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["completed_task_ids"] == ["studio"]
|
||||
|
||||
|
||||
def test_patch_state_recovers_when_concurrent_request_created_state(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_set_tour_config(monkeypatch, enabled=False, rollout_started_at=datetime(2026, 6, 1))
|
||||
existing_state = _state()
|
||||
existing_state.manually_enabled_workspace_ids = ["workspace-1"]
|
||||
session = _RaceInsertSession(state_after_rollback=existing_state)
|
||||
|
||||
result = StepByStepTourService.patch_state(
|
||||
account=_account(initialized_at=datetime(2026, 6, 28)),
|
||||
current_tenant_id="workspace-2",
|
||||
patch={"action": "enable_current_workspace"},
|
||||
session=session,
|
||||
)
|
||||
|
||||
assert result["manually_enabled_workspace_ids"] == ["workspace-1", "workspace-2"]
|
||||
assert session.flush_count == 1
|
||||
assert session.rollback_count == 1
|
||||
assert session.commit_count == 1
|
||||
@@ -1,6 +1,7 @@
|
||||
import dataclasses
|
||||
import secrets
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -59,6 +60,7 @@ class TestDraftVariableSaver:
|
||||
test_app_id = self._get_test_app_id()
|
||||
saver = DraftVariableSaver(
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id=test_app_id,
|
||||
node_id="test_node_id",
|
||||
node_type=BuiltinNodeTypes.START,
|
||||
@@ -121,6 +123,7 @@ class TestDraftVariableSaver:
|
||||
test_app_id = self._get_test_app_id()
|
||||
saver = DraftVariableSaver(
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id=test_app_id,
|
||||
node_id=_NODE_ID,
|
||||
node_type=BuiltinNodeTypes.START,
|
||||
@@ -139,6 +142,7 @@ class TestDraftVariableSaver:
|
||||
mock_user.id = str(uuid.uuid4())
|
||||
saver = DraftVariableSaver(
|
||||
session=mock_session,
|
||||
tenant_id="tenant-1",
|
||||
app_id=self._get_test_app_id(),
|
||||
node_id="start",
|
||||
node_type=BuiltinNodeTypes.START,
|
||||
@@ -161,13 +165,10 @@ class TestDraftVariableSaver:
|
||||
"tenant_id": "legacy-tenant",
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(saver, "_resolve_app_tenant_id", return_value="tenant-1"),
|
||||
patch(
|
||||
"services.workflow_draft_variable_service.build_file_from_stored_mapping",
|
||||
return_value=rebuilt_file,
|
||||
) as rebuild_file,
|
||||
):
|
||||
with patch(
|
||||
"services.workflow_draft_variable_service.build_file_from_stored_mapping",
|
||||
return_value=rebuilt_file,
|
||||
) as rebuild_file:
|
||||
draft_vars = saver._build_variables_from_start_mapping({"sys.files": [raw_file]})
|
||||
|
||||
sys_var = draft_vars[0]
|
||||
@@ -194,6 +195,7 @@ class TestDraftVariableSaver:
|
||||
|
||||
return DraftVariableSaver(
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="test-node-id",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
@@ -235,6 +237,40 @@ class TestDraftVariableSaver:
|
||||
# Should not have large variable metadata
|
||||
assert draft_var.file_id == mock_draft_var_file.id
|
||||
|
||||
def test_try_offload_large_variable_uses_resource_tenant(self, mock_session):
|
||||
mock_user = MagicMock(spec=Account)
|
||||
mock_user.id = "test-user-id"
|
||||
mock_user.current_tenant_id = ""
|
||||
saver = DraftVariableSaver(
|
||||
session=mock_session,
|
||||
tenant_id="app-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="test-node-id",
|
||||
node_type=BuiltinNodeTypes.LLM,
|
||||
node_execution_id="test-execution-id",
|
||||
user=mock_user,
|
||||
)
|
||||
upload_file = SimpleNamespace(id="upload-file-id")
|
||||
truncation_result = SimpleNamespace(result=StringSegment(value="..."), truncated=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"services.workflow_draft_variable_service.VariableTruncator.truncate", return_value=truncation_result
|
||||
),
|
||||
patch("services.workflow_draft_variable_service.FileService") as file_service_class,
|
||||
patch("services.workflow_draft_variable_service.sessionmaker") as sessionmaker_mock,
|
||||
):
|
||||
file_service_class.return_value.upload_file.return_value = upload_file
|
||||
result = saver._try_offload_large_variable("large_var", StringSegment(value="large value"))
|
||||
|
||||
assert result is not None
|
||||
_, variable_file = result
|
||||
assert file_service_class.return_value.upload_file.call_args.kwargs["tenant_id"] == "app-tenant-id"
|
||||
assert variable_file.tenant_id == "app-tenant-id"
|
||||
sessionmaker_mock.return_value.begin.return_value.__enter__.return_value.add.assert_called_once_with(
|
||||
variable_file
|
||||
)
|
||||
|
||||
@patch("services.workflow_draft_variable_service._batch_upsert_draft_variable", autospec=True)
|
||||
def test_save_method_integration(self, mock_batch_upsert, draft_saver):
|
||||
"""Test complete save workflow."""
|
||||
@@ -257,6 +293,7 @@ class TestDraftVariableSaver:
|
||||
|
||||
saver = DraftVariableSaver(
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="start-node-id",
|
||||
node_type=BuiltinNodeTypes.START,
|
||||
@@ -292,6 +329,7 @@ class TestDraftVariableSaver:
|
||||
|
||||
saver = DraftVariableSaver(
|
||||
session=mock_session,
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
node_id="start-node-id",
|
||||
node_type=BuiltinNodeTypes.START,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import json
|
||||
import operator
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.custom_tool.tool import ApiTool
|
||||
@@ -16,6 +19,13 @@ from core.tools.entities.tool_entities import (
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database_session(sqlite_engine: Engine) -> Iterator[Session]:
|
||||
"""Yield the live ORM session required by the shared tool invocation contract."""
|
||||
with Session(sqlite_engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _get_message_by_type[T](msgs: list[ToolInvokeMessage], msg_type: type[T]) -> ToolInvokeMessage | None:
|
||||
return next((i for i in msgs if isinstance(i.message, msg_type)), None)
|
||||
|
||||
@@ -57,7 +67,9 @@ class TestApiToolInvoke:
|
||||
)
|
||||
|
||||
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
|
||||
def test_invoke_with_json_response_creates_text_message_with_serialized_json(self, mock_get: Mock) -> None:
|
||||
def test_invoke_with_json_response_creates_text_message_with_serialized_json(
|
||||
self, mock_get: Mock, database_session: Session
|
||||
) -> None:
|
||||
"""Test that when upstream returns JSON, the output Text message contains JSON-serialized string."""
|
||||
# Setup mock response with JSON content
|
||||
json_response_data = {
|
||||
@@ -74,7 +86,7 @@ class TestApiToolInvoke:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Invoke the tool
|
||||
result_generator = self.api_tool._invoke(session=Mock(), user_id="test_user", tool_parameters={})
|
||||
result_generator = self.api_tool._invoke(session=database_session, user_id="test_user", tool_parameters={})
|
||||
|
||||
# Get the result from the generator
|
||||
result = list(result_generator)
|
||||
@@ -135,7 +147,7 @@ class TestApiToolInvoke:
|
||||
ids=operator.itemgetter(0),
|
||||
)
|
||||
def test_invoke_with_non_dict_json_response_creates_text_message_with_serialized_json(
|
||||
self, mock_get: Mock, test_case
|
||||
self, mock_get: Mock, test_case, database_session: Session
|
||||
) -> None:
|
||||
"""Test that when upstream returns a non-dict JSON, the output Text message contains JSON-serialized string."""
|
||||
# Setup mock response with non-dict JSON content
|
||||
@@ -149,7 +161,7 @@ class TestApiToolInvoke:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Invoke the tool
|
||||
result_generator = self.api_tool._invoke(session=Mock(), user_id="test_user", tool_parameters={})
|
||||
result_generator = self.api_tool._invoke(session=database_session, user_id="test_user", tool_parameters={})
|
||||
|
||||
# Get the result from the generator
|
||||
result = list(result_generator)
|
||||
@@ -173,7 +185,9 @@ class TestApiToolInvoke:
|
||||
assert json_message is None, "_invoke should not yield a JSON message for JSON array response"
|
||||
|
||||
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
|
||||
def test_invoke_with_text_response_creates_text_message_with_original_text(self, mock_get: Mock) -> None:
|
||||
def test_invoke_with_text_response_creates_text_message_with_original_text(
|
||||
self, mock_get: Mock, database_session: Session
|
||||
) -> None:
|
||||
"""Test that when upstream returns plain text, the output Text message contains the original text."""
|
||||
# Setup mock response with plain text content
|
||||
text_response_data = "This is a plain text response"
|
||||
@@ -186,7 +200,7 @@ class TestApiToolInvoke:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Invoke the tool
|
||||
result_generator = self.api_tool._invoke(session=Mock(), user_id="test_user", tool_parameters={})
|
||||
result_generator = self.api_tool._invoke(session=database_session, user_id="test_user", tool_parameters={})
|
||||
|
||||
# Get the result from the generator
|
||||
result = list(result_generator)
|
||||
@@ -202,7 +216,7 @@ class TestApiToolInvoke:
|
||||
assert message.message.text == text_response_data
|
||||
|
||||
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
|
||||
def test_invoke_with_empty_response(self, mock_get: Mock) -> None:
|
||||
def test_invoke_with_empty_response(self, mock_get: Mock, database_session: Session) -> None:
|
||||
"""Test that empty responses are handled correctly."""
|
||||
# Setup mock response with empty content
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
@@ -212,7 +226,7 @@ class TestApiToolInvoke:
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
# Invoke the tool
|
||||
result_generator = self.api_tool._invoke(session=Mock(), user_id="test_user", tool_parameters={})
|
||||
result_generator = self.api_tool._invoke(session=database_session, user_id="test_user", tool_parameters={})
|
||||
|
||||
# Get the result from the generator
|
||||
result = list(result_generator)
|
||||
@@ -228,7 +242,7 @@ class TestApiToolInvoke:
|
||||
assert "Empty response from the tool" in message.message.text
|
||||
|
||||
@patch("core.tools.custom_tool.tool.ssrf_proxy.get")
|
||||
def test_invoke_with_error_response(self, mock_get: Mock) -> None:
|
||||
def test_invoke_with_error_response(self, mock_get: Mock, database_session: Session) -> None:
|
||||
"""Test that error responses are handled correctly."""
|
||||
# Setup mock response with error status code
|
||||
mock_response = Mock(spec=httpx.Response)
|
||||
@@ -236,7 +250,7 @@ class TestApiToolInvoke:
|
||||
mock_response.text = "Not Found"
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
result_generator = self.api_tool._invoke(session=Mock(), user_id="test_user", tool_parameters={})
|
||||
result_generator = self.api_tool._invoke(session=database_session, user_id="test_user", tool_parameters={})
|
||||
|
||||
# Invoke the tool and expect an error
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import base64
|
||||
from collections.abc import Iterator
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.mcp.types import (
|
||||
AudioContent,
|
||||
@@ -21,6 +24,13 @@ from core.tools.mcp_tool.tool import MCPTool
|
||||
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
|
||||
"""Use a real ORM session while MCP transport remains mocked."""
|
||||
with Session(sqlite_engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
def _make_mcp_tool(output_schema: dict[str, Any] | None = None) -> MCPTool:
|
||||
identity = ToolIdentity(
|
||||
author="test",
|
||||
@@ -56,7 +66,7 @@ class TestMCPToolInvoke:
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_invoke_image_or_audio_yields_blob(self, content_factory, mime_type) -> None:
|
||||
def test_invoke_image_or_audio_yields_blob(self, content_factory, mime_type, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool()
|
||||
raw = b"\x00\x01test-bytes\x02"
|
||||
b64 = base64.b64encode(raw).decode()
|
||||
@@ -64,7 +74,7 @@ class TestMCPToolInvoke:
|
||||
result = CallToolResult(content=[content])
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=Mock(), user_id="test_user", tool_parameters={}))
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
@@ -73,14 +83,14 @@ class TestMCPToolInvoke:
|
||||
assert msg.message.blob == raw
|
||||
assert msg.meta == {"mime_type": mime_type}
|
||||
|
||||
def test_invoke_embedded_text_resource_yields_text(self) -> None:
|
||||
def test_invoke_embedded_text_resource_yields_text(self, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool()
|
||||
text_resource = TextResourceContents(uri="file://test.txt", mimeType="text/plain", text="hello world")
|
||||
content = EmbeddedResource(type="resource", resource=text_resource)
|
||||
result = CallToolResult(content=[content])
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=Mock(), user_id="test_user", tool_parameters={}))
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
@@ -92,7 +102,7 @@ class TestMCPToolInvoke:
|
||||
("mime_type", "expected_mime"),
|
||||
[("application/pdf", "application/pdf"), (None, "application/octet-stream")],
|
||||
)
|
||||
def test_invoke_embedded_blob_resource_yields_blob(self, mime_type, expected_mime) -> None:
|
||||
def test_invoke_embedded_blob_resource_yields_blob(self, mime_type, expected_mime, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool()
|
||||
raw = b"binary-data"
|
||||
b64 = base64.b64encode(raw).decode()
|
||||
@@ -101,7 +111,7 @@ class TestMCPToolInvoke:
|
||||
result = CallToolResult(content=[content])
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=Mock(), user_id="test_user", tool_parameters={}))
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
@@ -110,12 +120,12 @@ class TestMCPToolInvoke:
|
||||
assert msg.message.blob == raw
|
||||
assert msg.meta == {"mime_type": expected_mime}
|
||||
|
||||
def test_invoke_yields_variables_when_structured_content_and_schema(self) -> None:
|
||||
def test_invoke_yields_variables_when_structured_content_and_schema(self, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool(output_schema={"type": "object"})
|
||||
result = CallToolResult(content=[], structuredContent={"a": 1, "b": "x"})
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=Mock(), user_id="test_user", tool_parameters={}))
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
# Expect two variable messages corresponding to keys a and b
|
||||
assert len(messages) == 2
|
||||
@@ -266,7 +276,7 @@ class TestMCPToolUsageExtraction:
|
||||
assert usage.prompt_tokens == 100
|
||||
assert usage.completion_tokens == 50
|
||||
|
||||
def test_invoke_sets_latest_usage_from_meta(self) -> None:
|
||||
def test_invoke_sets_latest_usage_from_meta(self, orm_session: Session) -> None:
|
||||
"""Test that _invoke sets _latest_usage from result meta."""
|
||||
tool = _make_mcp_tool()
|
||||
meta = {
|
||||
@@ -281,7 +291,7 @@ class TestMCPToolUsageExtraction:
|
||||
result = CallToolResult(content=[TextContent(type="text", text="test")], _meta=meta)
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
list(tool._invoke(session=Mock(), user_id="test_user", tool_parameters={}))
|
||||
list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
# Verify latest_usage was set correctly
|
||||
assert tool.latest_usage.prompt_tokens == 200
|
||||
@@ -289,13 +299,13 @@ class TestMCPToolUsageExtraction:
|
||||
assert tool.latest_usage.total_tokens == 300
|
||||
assert tool.latest_usage.total_price == Decimal("0.003")
|
||||
|
||||
def test_invoke_with_no_meta_returns_empty_usage(self) -> None:
|
||||
def test_invoke_with_no_meta_returns_empty_usage(self, orm_session: Session) -> None:
|
||||
"""Test that _invoke returns empty usage when no meta is present."""
|
||||
tool = _make_mcp_tool()
|
||||
result = CallToolResult(content=[TextContent(type="text", text="test")], _meta=None)
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
list(tool._invoke(session=Mock(), user_id="test_user", tool_parameters={}))
|
||||
list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
# Verify latest_usage is empty
|
||||
assert tool.latest_usage.total_tokens == 0
|
||||
|
||||
@@ -807,11 +807,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/apps/starred-app-card.tsx": {
|
||||
"jsx_a11y/no-noninteractive-element-to-interactive-role": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/action-button/index.tsx": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 1
|
||||
@@ -3936,14 +3931,6 @@
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/tool-provider-grid.tsx": {
|
||||
"jsx_a11y/click-events-have-key-events": {
|
||||
"count": 1
|
||||
},
|
||||
"jsx_a11y/no-static-element-interactions": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/tools/types.ts": {
|
||||
"erasable-syntax-only/enums": {
|
||||
"count": 4
|
||||
|
||||
+1
-1
@@ -56,5 +56,5 @@
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0"
|
||||
"packageManager": "pnpm@11.15.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
import {
|
||||
zGetOnboardingStepByStepTourStateResponse,
|
||||
zPatchOnboardingStepByStepTourStateBody,
|
||||
zPatchOnboardingStepByStepTourStateResponse,
|
||||
} from './zod.gen'
|
||||
|
||||
/**
|
||||
* Get account-level Step-by-step Tour state
|
||||
*/
|
||||
export const get = oc
|
||||
.route({
|
||||
description: 'Get account-level Step-by-step Tour state',
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getOnboardingStepByStepTourState',
|
||||
path: '/onboarding/step-by-step-tour/state',
|
||||
tags: ['console'],
|
||||
})
|
||||
.output(zGetOnboardingStepByStepTourStateResponse)
|
||||
|
||||
/**
|
||||
* Update account-level Step-by-step Tour state
|
||||
*/
|
||||
export const patch = oc
|
||||
.route({
|
||||
description: 'Update account-level Step-by-step Tour state',
|
||||
inputStructure: 'detailed',
|
||||
method: 'PATCH',
|
||||
operationId: 'patchOnboardingStepByStepTourState',
|
||||
path: '/onboarding/step-by-step-tour/state',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPatchOnboardingStepByStepTourStateBody }))
|
||||
.output(zPatchOnboardingStepByStepTourStateResponse)
|
||||
|
||||
export const state = {
|
||||
get,
|
||||
patch,
|
||||
}
|
||||
|
||||
export const stepByStepTour = {
|
||||
state,
|
||||
}
|
||||
|
||||
export const onboarding = {
|
||||
stepByStepTour,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
onboarding,
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type StepByStepTourStateResponse = {
|
||||
completed_task_ids?: Array<'home' | 'integration' | 'knowledge' | 'studio'>
|
||||
first_workspace_id?: string | null
|
||||
manually_disabled_workspace_ids?: Array<string>
|
||||
manually_enabled_workspace_ids?: Array<string>
|
||||
skipped?: boolean
|
||||
updated_at?: string | null
|
||||
}
|
||||
|
||||
export type StepByStepTourStatePatchPayload = {
|
||||
action:
|
||||
| 'complete_task'
|
||||
| 'disable_current_workspace'
|
||||
| 'enable_current_workspace'
|
||||
| 'skip'
|
||||
| 'uncomplete_task'
|
||||
task_id?: 'home' | 'integration' | 'knowledge' | 'studio' | null
|
||||
}
|
||||
|
||||
export type GetOnboardingStepByStepTourStateData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/onboarding/step-by-step-tour/state'
|
||||
}
|
||||
|
||||
export type GetOnboardingStepByStepTourStateResponses = {
|
||||
200: StepByStepTourStateResponse
|
||||
}
|
||||
|
||||
export type GetOnboardingStepByStepTourStateResponse =
|
||||
GetOnboardingStepByStepTourStateResponses[keyof GetOnboardingStepByStepTourStateResponses]
|
||||
|
||||
export type PatchOnboardingStepByStepTourStateData = {
|
||||
body: StepByStepTourStatePatchPayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/onboarding/step-by-step-tour/state'
|
||||
}
|
||||
|
||||
export type PatchOnboardingStepByStepTourStateResponses = {
|
||||
200: StepByStepTourStateResponse
|
||||
}
|
||||
|
||||
export type PatchOnboardingStepByStepTourStateResponse =
|
||||
PatchOnboardingStepByStepTourStateResponses[keyof PatchOnboardingStepByStepTourStateResponses]
|
||||
@@ -0,0 +1,41 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* StepByStepTourStateResponse
|
||||
*/
|
||||
export const zStepByStepTourStateResponse = z.object({
|
||||
completed_task_ids: z.array(z.enum(['home', 'integration', 'knowledge', 'studio'])).optional(),
|
||||
first_workspace_id: z.string().nullish(),
|
||||
manually_disabled_workspace_ids: z.array(z.string()).optional(),
|
||||
manually_enabled_workspace_ids: z.array(z.string()).optional(),
|
||||
skipped: z.boolean().optional().default(false),
|
||||
updated_at: z.iso.datetime().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* StepByStepTourStatePatchPayload
|
||||
*/
|
||||
export const zStepByStepTourStatePatchPayload = z.object({
|
||||
action: z.enum([
|
||||
'complete_task',
|
||||
'disable_current_workspace',
|
||||
'enable_current_workspace',
|
||||
'skip',
|
||||
'uncomplete_task',
|
||||
]),
|
||||
task_id: z.enum(['home', 'integration', 'knowledge', 'studio']).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetOnboardingStepByStepTourStateResponse = zStepByStepTourStateResponse
|
||||
|
||||
export const zPatchOnboardingStepByStepTourStateBody = zStepByStepTourStatePatchPayload
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zPatchOnboardingStepByStepTourStateResponse = zStepByStepTourStateResponse
|
||||
@@ -48,6 +48,7 @@ export const contractLoaders = {
|
||||
import('./notification/orpc.gen').then(({ notification }) => ({ notification })),
|
||||
notion: () => import('./notion/orpc.gen').then(({ notion }) => ({ notion })),
|
||||
oauth: () => import('./oauth/orpc.gen').then(({ oauth }) => ({ oauth })),
|
||||
onboarding: () => import('./onboarding/orpc.gen').then(({ onboarding }) => ({ onboarding })),
|
||||
ping: () => import('./ping/orpc.gen').then(({ ping }) => ({ ping })),
|
||||
rag: () => import('./rag/orpc.gen').then(({ rag }) => ({ rag })),
|
||||
refreshToken: () =>
|
||||
|
||||
@@ -32,6 +32,7 @@ import { logout } from './logout/orpc.gen'
|
||||
import { notification } from './notification/orpc.gen'
|
||||
import { notion } from './notion/orpc.gen'
|
||||
import { oauth } from './oauth/orpc.gen'
|
||||
import { onboarding } from './onboarding/orpc.gen'
|
||||
import { ping } from './ping/orpc.gen'
|
||||
import { rag } from './rag/orpc.gen'
|
||||
import { refreshToken } from './refresh-token/orpc.gen'
|
||||
@@ -88,6 +89,7 @@ const communityContract = {
|
||||
notification,
|
||||
notion,
|
||||
oauth,
|
||||
onboarding,
|
||||
ping,
|
||||
rag,
|
||||
refreshToken,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type SystemFeatureModel = {
|
||||
enable_learn_app: boolean
|
||||
enable_marketplace: boolean
|
||||
enable_social_oauth_login: boolean
|
||||
enable_step_by_step_tour: boolean
|
||||
enable_trial_app: boolean
|
||||
is_allow_create_workspace: boolean
|
||||
is_allow_register: boolean
|
||||
|
||||
@@ -113,6 +113,7 @@ export const zSystemFeatureModel = z.object({
|
||||
enable_learn_app: z.boolean().default(true),
|
||||
enable_marketplace: z.boolean().default(false),
|
||||
enable_social_oauth_login: z.boolean().default(false),
|
||||
enable_step_by_step_tour: z.boolean().default(false),
|
||||
enable_trial_app: z.boolean().default(false),
|
||||
is_allow_create_workspace: z.boolean().default(false),
|
||||
is_allow_register: z.boolean().default(false),
|
||||
|
||||
@@ -520,6 +520,7 @@ export type SystemFeatureModel = {
|
||||
enable_learn_app: boolean
|
||||
enable_marketplace: boolean
|
||||
enable_social_oauth_login: boolean
|
||||
enable_step_by_step_tour: boolean
|
||||
enable_trial_app: boolean
|
||||
is_allow_create_workspace: boolean
|
||||
is_allow_register: boolean
|
||||
|
||||
@@ -791,6 +791,7 @@ export const zSystemFeatureModel = z.object({
|
||||
enable_learn_app: z.boolean().default(true),
|
||||
enable_marketplace: z.boolean().default(false),
|
||||
enable_social_oauth_login: z.boolean().default(false),
|
||||
enable_step_by_step_tour: z.boolean().default(false),
|
||||
enable_trial_app: z.boolean().default(false),
|
||||
is_allow_create_workspace: z.boolean().default(false),
|
||||
is_allow_register: z.boolean().default(false),
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"lastModified": 1782365697,
|
||||
"lastModified": 1782516559,
|
||||
"icons": {
|
||||
"agent-v2-access-point": {
|
||||
"body": "<g fill=\"none\"><path d=\"M7.5 11.25C7.91421 11.25 8.25 11.5858 8.25 12V14.25C8.25 14.6642 7.91421 15 7.5 15C7.08579 15 6.75 14.6642 6.75 14.25V12C6.75 11.5858 7.08579 11.25 7.5 11.25Z\" fill=\"currentColor\"/><path d=\"M2.19653 2.19653C2.48937 1.90372 2.96418 1.90382 3.25708 2.19653L8.03027 6.96973C8.09162 7.03108 8.13966 7.10082 8.17529 7.1748C8.19164 7.20869 8.20587 7.24378 8.21704 7.28027C8.24638 7.37633 8.25641 7.477 8.24634 7.57617C8.23743 7.66451 8.21216 7.74788 8.17529 7.82446C8.13963 7.89868 8.09176 7.96874 8.03027 8.03027L3.25708 12.8035C2.96419 13.096 2.48932 13.0962 2.19653 12.8035C1.90394 12.5107 1.90405 12.0358 2.19653 11.7429L5.68945 8.25H0.75C0.335786 8.25 0 7.91421 0 7.5C0 7.08579 0.335786 6.75 0.75 6.75H5.68945L2.19653 3.25708C1.90389 2.96423 1.90388 2.48937 2.19653 2.19653Z\" fill=\"currentColor\"/><path d=\"M10.1521 10.1521C10.445 9.85921 10.9198 9.85921 11.2126 10.1521L12.8035 11.7429C13.096 12.0358 13.0962 12.5107 12.8035 12.8035C12.5107 13.0962 12.0358 13.096 11.7429 12.8035L10.1521 11.2126C9.85921 10.9198 9.85922 10.445 10.1521 10.1521Z\" fill=\"currentColor\"/><path d=\"M14.25 6.75C14.6642 6.75 15 7.08579 15 7.5C15 7.91421 14.6642 8.25 14.25 8.25H12C11.5858 8.25 11.25 7.91421 11.25 7.5C11.25 7.08579 11.5858 6.75 12 6.75H14.25Z\" fill=\"currentColor\"/><path d=\"M11.7422 2.19653C12.035 1.90387 12.5098 1.90406 12.8027 2.19653C13.0956 2.4894 13.0955 2.96419 12.8027 3.25708L11.2119 4.8479C10.919 5.14079 10.4443 5.1408 10.1514 4.8479C9.85883 4.55497 9.85858 4.08013 10.1514 3.78735L11.7422 2.19653Z\" fill=\"currentColor\"/><path d=\"M7.5 0C7.91421 0 8.25 0.335786 8.25 0.75V3C8.25 3.41421 7.91421 3.75 7.5 3.75C7.08579 3.75 6.75 3.41421 6.75 3V0.75C6.75 0.335786 7.08579 0 7.5 0Z\" fill=\"currentColor\"/></g>",
|
||||
@@ -451,6 +451,9 @@
|
||||
"width": 12,
|
||||
"height": 12
|
||||
},
|
||||
"line-education-lesson-open-01": {
|
||||
"body": "<g fill=\"none\"><path d=\"M8 13.6667L14 12.3333V3L8 4.33333L2 3V12.3333L8 13.6667ZM8 4.33333V13.6667\" stroke=\"currentColor\" stroke-width=\"1.33333\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
|
||||
},
|
||||
"line-files-copy": {
|
||||
"body": "<g fill=\"none\"><path d=\"M10.6665 2.66634H11.9998C12.3535 2.66634 12.6926 2.80682 12.9426 3.05687C13.1927 3.30691 13.3332 3.64605 13.3332 3.99967V13.333C13.3332 13.6866 13.1927 14.0258 12.9426 14.2758C12.6926 14.5259 12.3535 14.6663 11.9998 14.6663H3.99984C3.64622 14.6663 3.30708 14.5259 3.05703 14.2758C2.80698 14.0258 2.6665 13.6866 2.6665 13.333V3.99967C2.6665 3.64605 2.80698 3.30691 3.05703 3.05687C3.30708 2.80682 3.64622 2.66634 3.99984 2.66634H5.33317M5.99984 1.33301H9.99984C10.368 1.33301 10.6665 1.63148 10.6665 1.99967V3.33301C10.6665 3.7012 10.368 3.99967 9.99984 3.99967H5.99984C5.63165 3.99967 5.33317 3.7012 5.33317 3.33301V1.99967C5.33317 1.63148 5.63165 1.33301 5.99984 1.33301Z\" stroke=\"currentColor\" stroke-width=\"1.5\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/></g>"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prefix": "custom-vender",
|
||||
"name": "Dify Custom Vender",
|
||||
"total": 330,
|
||||
"total": 331,
|
||||
"version": "0.0.0-private",
|
||||
"author": {
|
||||
"name": "LangGenius, Inc.",
|
||||
|
||||
Generated
+1454
-1705
File diff suppressed because it is too large
Load Diff
+54
-54
@@ -35,36 +35,36 @@ overrides:
|
||||
esbuild@>=0.27.3 <0.28.1: ^0.28.1
|
||||
is-core-module: npm:@nolyfill/is-core-module@^1.0.39
|
||||
js-yaml@<=4.1.1: ^4.1.2
|
||||
picomatch@>=4.0.0 <4.0.4: 4.0.4
|
||||
picomatch@>=4.0.0 <4.0.4: 4.0.5
|
||||
postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4
|
||||
postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.4
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
rollup@>=4.0.0 <4.59.0: 4.62.2
|
||||
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
|
||||
side-channel: npm:@nolyfill/side-channel@^1.0.44
|
||||
solid-js: 1.9.13
|
||||
string-width: ~8.2.1
|
||||
solid-js: 1.9.14
|
||||
string-width: ~8.2.2
|
||||
tar@<=7.5.15: ^7.5.16
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.2.5
|
||||
ws@>=8.0.0 <8.20.1: ^8.21.0
|
||||
ws@>=8.0.0 <8.20.1: ^8.21.1
|
||||
yaml@>=2.0.0 <2.8.3: 2.9.0
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.44.5
|
||||
'@amplitude/plugin-session-replay-browser': 1.33.1
|
||||
'@amplitude/analytics-browser': 2.45.2
|
||||
'@amplitude/plugin-session-replay-browser': 1.33.4
|
||||
'@base-ui/react': 1.6.0
|
||||
'@chromatic-com/storybook': 5.2.1
|
||||
'@cucumber/cucumber': 13.0.0
|
||||
'@cucumber/cucumber': 13.1.1
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@eslint-community/eslint-plugin-eslint-comments': 4.7.2
|
||||
'@eslint-react/eslint-plugin': 5.14.5
|
||||
'@eslint-react/eslint-plugin': 5.17.1
|
||||
'@eslint/markdown': 8.0.3
|
||||
'@floating-ui/react': 0.27.20
|
||||
'@formatjs/intl-localematcher': 0.8.12
|
||||
'@formatjs/intl-localematcher': 0.8.13
|
||||
'@heroicons/react': 2.2.0
|
||||
'@hey-api/openapi-ts': 0.98.2
|
||||
'@hono/node-server': 2.0.8
|
||||
'@hono/node-server': 2.0.10
|
||||
'@iconify-json/heroicons': 1.2.3
|
||||
'@iconify-json/ri': 1.2.10
|
||||
'@lexical/code': 0.47.0
|
||||
@@ -77,41 +77,41 @@ catalog:
|
||||
'@mdx-js/loader': 3.1.1
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
'@mediabunny/mp3-encoder': 1.50.8
|
||||
'@mediabunny/mp3-encoder': 1.50.9
|
||||
'@monaco-editor/react': 4.7.0
|
||||
'@napi-rs/keyring': 1.3.0
|
||||
'@next/mdx': 16.2.10
|
||||
'@orpc/client': 1.14.7
|
||||
'@orpc/contract': 1.14.7
|
||||
'@orpc/openapi-client': 1.14.7
|
||||
'@orpc/tanstack-query': 1.14.7
|
||||
'@orpc/client': 1.14.8
|
||||
'@orpc/contract': 1.14.8
|
||||
'@orpc/openapi-client': 1.14.8
|
||||
'@orpc/tanstack-query': 1.14.8
|
||||
'@playwright/test': 1.61.1
|
||||
'@remixicon/react': 4.9.0
|
||||
'@rgrove/parse-xml': 4.2.2
|
||||
'@sentry/react': 10.65.0
|
||||
'@storybook/addon-a11y': 10.5.0
|
||||
'@storybook/addon-docs': 10.5.0
|
||||
'@storybook/addon-links': 10.5.0
|
||||
'@storybook/addon-onboarding': 10.5.0
|
||||
'@storybook/addon-themes': 10.5.0
|
||||
'@storybook/addon-vitest': 10.5.0
|
||||
'@storybook/nextjs-vite': 10.5.0
|
||||
'@storybook/react': 10.5.0
|
||||
'@storybook/react-vite': 10.5.0
|
||||
'@sentry/react': 10.66.0
|
||||
'@storybook/addon-a11y': 10.5.2
|
||||
'@storybook/addon-docs': 10.5.2
|
||||
'@storybook/addon-links': 10.5.2
|
||||
'@storybook/addon-onboarding': 10.5.2
|
||||
'@storybook/addon-themes': 10.5.2
|
||||
'@storybook/addon-vitest': 10.5.2
|
||||
'@storybook/nextjs-vite': 10.5.2
|
||||
'@storybook/react': 10.5.2
|
||||
'@storybook/react-vite': 10.5.2
|
||||
'@streamdown/math': 1.0.2
|
||||
'@svgdotjs/svg.js': 3.2.5
|
||||
'@svgdotjs/svg.js': 3.2.6
|
||||
'@t3-oss/env-core': 0.13.11
|
||||
'@t3-oss/env-nextjs': 0.13.11
|
||||
'@tailwindcss/postcss': 4.3.2
|
||||
'@tailwindcss/postcss': 4.3.3
|
||||
'@tailwindcss/typography': 0.5.20
|
||||
'@tailwindcss/vite': 4.3.2
|
||||
'@tailwindcss/vite': 4.3.3
|
||||
'@tanstack/eslint-plugin-query': 5.101.2
|
||||
'@tanstack/form-core': 1.33.1
|
||||
'@tanstack/form-core': 1.33.2
|
||||
'@tanstack/query-core': 5.101.2
|
||||
'@tanstack/react-form': 1.33.1
|
||||
'@tanstack/react-form': 1.33.2
|
||||
'@tanstack/react-hotkeys': 0.10.0
|
||||
'@tanstack/react-query': 5.101.2
|
||||
'@tanstack/react-virtual': 3.14.5
|
||||
'@tanstack/react-virtual': 3.14.6
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/jest-dom': 6.9.1
|
||||
'@testing-library/react': 16.3.2
|
||||
@@ -127,10 +127,10 @@ catalog:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/parser': 8.63.0
|
||||
'@typescript-eslint/parser': 8.64.0
|
||||
'@typescript/native': npm:typescript@7.0.2
|
||||
'@vitejs/plugin-react': 6.0.3
|
||||
'@vitejs/plugin-rsc': 0.5.27
|
||||
'@vitejs/plugin-rsc': 0.5.28
|
||||
'@vitest/browser': 4.1.10
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
'@vitest/coverage-v8': 4.1.10
|
||||
@@ -145,7 +145,7 @@ catalog:
|
||||
code-inspector-plugin: 1.6.6
|
||||
concurrently: ^10.0.3
|
||||
copy-to-clipboard: 4.0.2
|
||||
cron-parser: 5.6.1
|
||||
cron-parser: 5.6.2
|
||||
dayjs: 1.11.21
|
||||
decimal.js: 10.6.0
|
||||
dompurify: 3.4.12
|
||||
@@ -160,10 +160,10 @@ catalog:
|
||||
eslint: 10.7.0
|
||||
eslint-markdown: 0.12.1
|
||||
eslint-plugin-antfu: 3.2.3
|
||||
eslint-plugin-command: 3.5.2
|
||||
eslint-plugin-command: 3.5.3
|
||||
eslint-plugin-erasable-syntax-only: 0.4.2
|
||||
eslint-plugin-hyoban: 0.14.1
|
||||
eslint-plugin-jsdoc: 63.0.13
|
||||
eslint-plugin-jsdoc: 63.1.0
|
||||
eslint-plugin-jsonc: 3.3.0
|
||||
eslint-plugin-markdown-preferences: 0.41.1
|
||||
eslint-plugin-n: 18.2.2
|
||||
@@ -171,24 +171,24 @@ catalog:
|
||||
eslint-plugin-perfectionist: 5.10.0
|
||||
eslint-plugin-pnpm: 1.6.1
|
||||
eslint-plugin-regexp: 3.1.1
|
||||
eslint-plugin-storybook: 10.5.0
|
||||
eslint-plugin-storybook: 10.5.2
|
||||
eslint-plugin-toml: 1.4.0
|
||||
eslint-plugin-unicorn: 71.1.0
|
||||
eslint-plugin-yml: 3.6.0
|
||||
eventsource-parser: 3.1.0
|
||||
fast-deep-equal: 3.1.3
|
||||
foxact: 0.3.8
|
||||
fuse.js: 7.4.2
|
||||
happy-dom: 20.10.6
|
||||
fuse.js: 7.5.0
|
||||
happy-dom: 20.11.0
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
hono: 4.12.29
|
||||
hono: 4.12.31
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.3.6
|
||||
i18next-resources-to-backend: 1.2.1
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.11
|
||||
jotai: 2.20.1
|
||||
immer: 11.1.15
|
||||
jotai: 2.20.2
|
||||
jotai-effect: 2.3.1
|
||||
jotai-scope: 0.11.0
|
||||
jotai-tanstack-query: 0.11.0
|
||||
@@ -196,12 +196,12 @@ catalog:
|
||||
js-yaml: 5.2.1
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.17.0
|
||||
knip: 6.26.0
|
||||
knip: 6.27.0
|
||||
ky: 2.0.2
|
||||
lexical: 0.47.0
|
||||
lockfile: 1.0.4
|
||||
loro-crdt: 1.13.6
|
||||
mediabunny: 1.50.8
|
||||
loro-crdt: 1.13.7
|
||||
mediabunny: 1.50.9
|
||||
mermaid: 11.16.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
@@ -209,19 +209,19 @@ catalog:
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.10
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.9.0
|
||||
nuqs: 2.9.1
|
||||
open: 11.0.0
|
||||
ora: 9.4.1
|
||||
picocolors: 1.1.1
|
||||
pinyin-pro: 3.28.1
|
||||
playwright: 1.61.1
|
||||
postcss: 8.5.17
|
||||
postcss: 8.5.19
|
||||
qrcode.react: 4.2.0
|
||||
qs: 6.15.3
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7
|
||||
react-easy-crop: 6.2.2
|
||||
react-i18next: 17.0.9
|
||||
react-i18next: 17.0.10
|
||||
react-papaparse: 4.4.0
|
||||
react-pdf-highlighter: 8.0.0-rc.0
|
||||
react-server-dom-webpack: 19.2.7
|
||||
@@ -237,22 +237,22 @@ catalog:
|
||||
socket.io-client: 4.8.3
|
||||
sortablejs: 1.15.7
|
||||
std-semver: 1.0.8
|
||||
storybook: 10.5.0
|
||||
storybook: 10.5.2
|
||||
streamdown: 2.5.0
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.6.0
|
||||
tailwindcss: 4.3.2
|
||||
tldts: 7.4.8
|
||||
tsx: 4.23.0
|
||||
tailwindcss: 4.3.3
|
||||
tldts: 7.4.9
|
||||
tsx: 4.23.1
|
||||
typescript: npm:@typescript/typescript6@6.0.2
|
||||
uglify-js: 3.19.3
|
||||
undici: 7.28.0
|
||||
unist-util-visit: 5.1.0
|
||||
use-context-selector: 2.0.0
|
||||
uuid: 14.0.1
|
||||
vinext: 1.0.0-beta.1
|
||||
vinext: 1.0.0-beta.2
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.2.5
|
||||
vite-plugin-inspect: 12.0.0-beta.3
|
||||
vite-plugin-inspect: 12.0.2
|
||||
vite-plus: 0.2.5
|
||||
vitest: 4.1.10
|
||||
vitest-browser-react: 2.2.0
|
||||
|
||||
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
}),
|
||||
headers: vi.fn(),
|
||||
resolveServerConsoleApiUrl: vi.fn(),
|
||||
basePath: '',
|
||||
}))
|
||||
|
||||
vi.mock('@/context/query-client-server', () => ({
|
||||
@@ -29,6 +30,12 @@ vi.mock('@/next/navigation', () => ({
|
||||
redirect: (url: string) => mocks.redirect(url),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/var', () => ({
|
||||
get basePath() {
|
||||
return mocks.basePath
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/server', () => ({
|
||||
serverUserProfileQueryOptions: () => ({
|
||||
queryKey: ['common', 'user-profile'],
|
||||
@@ -62,6 +69,7 @@ vi.mock('@/features/system-features/server', () => ({
|
||||
describe('CommonLayoutHydrationBoundary', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.basePath = ''
|
||||
mocks.queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
mocks.headers.mockResolvedValue(
|
||||
new Headers({
|
||||
@@ -99,7 +107,7 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should hydrate common layout queries and render children', async () => {
|
||||
it('should prefetch common layout queries and render children', async () => {
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
const element = await CommonLayoutHydrationBoundary({
|
||||
@@ -126,6 +134,7 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
})
|
||||
|
||||
it('should redirect unauthorized users to the refresh route with the current path', async () => {
|
||||
mocks.basePath = '/workflow'
|
||||
mocks.profileQueryFn.mockRejectedValue(
|
||||
new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }),
|
||||
)
|
||||
@@ -138,7 +147,8 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should default unauthorized refresh redirects to the home path when the pathname header is missing', async () => {
|
||||
it('should use the internal home path when the pathname header is missing', async () => {
|
||||
mocks.basePath = '/workflow'
|
||||
mocks.headers.mockResolvedValue(new Headers())
|
||||
mocks.profileQueryFn.mockRejectedValue(
|
||||
new Response(JSON.stringify({ code: 'unauthorized' }), { status: 401 }),
|
||||
@@ -150,15 +160,17 @@ describe('CommonLayoutHydrationBoundary', () => {
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/auth/refresh?redirect_url=%2F')
|
||||
})
|
||||
|
||||
it('should redirect setup errors to install', async () => {
|
||||
mocks.profileQueryFn.mockRejectedValue(
|
||||
new Response(JSON.stringify({ code: 'not_setup' }), { status: 401 }),
|
||||
)
|
||||
it.each([
|
||||
['not_setup', '/install'],
|
||||
['not_init_validated', '/init'],
|
||||
])('should use an internal destination for %s errors', async (code, destination) => {
|
||||
mocks.basePath = '/workflow'
|
||||
mocks.profileQueryFn.mockRejectedValue(new Response(JSON.stringify({ code }), { status: 401 }))
|
||||
const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
|
||||
|
||||
await expect(CommonLayoutHydrationBoundary({ children: null })).rejects.toThrow('NEXT_REDIRECT')
|
||||
|
||||
expect(mocks.redirect).toHaveBeenCalledWith('/install')
|
||||
expect(mocks.redirect).toHaveBeenCalledWith(destination)
|
||||
})
|
||||
|
||||
it('should render children without server prefetch when the server API URL is not resolvable', async () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
resolveServerConsoleApiUrl,
|
||||
serverConsoleQuery,
|
||||
} from '@/service/server'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
|
||||
const CURRENT_SEARCH_HEADER = 'x-dify-search'
|
||||
@@ -35,22 +34,22 @@ const parseConsoleErrorPayload = async (error: Response): Promise<ConsoleErrorPa
|
||||
|
||||
const getCurrentPath = async () => {
|
||||
const requestHeaders = await headers()
|
||||
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || `${basePath}/`
|
||||
const pathname = requestHeaders.get(CURRENT_PATHNAME_HEADER) || '/'
|
||||
const search = requestHeaders.get(CURRENT_SEARCH_HEADER) || ''
|
||||
return `${pathname}${search}`
|
||||
}
|
||||
|
||||
const redirectToAuthRefresh = async () => {
|
||||
const currentPath = await getCurrentPath()
|
||||
redirect(`${basePath}${AUTH_REFRESH_PATH}?redirect_url=${encodeURIComponent(currentPath)}`)
|
||||
redirect(`${AUTH_REFRESH_PATH}?redirect_url=${encodeURIComponent(currentPath)}`)
|
||||
}
|
||||
|
||||
const handleProfileError = async (error: unknown) => {
|
||||
if (!(error instanceof Response)) throw error
|
||||
|
||||
const errorData = await parseConsoleErrorPayload(error)
|
||||
if (errorData?.code === 'not_setup') redirect(`${basePath}/install`)
|
||||
if (errorData?.code === 'not_init_validated') redirect(`${basePath}/init`)
|
||||
if (errorData?.code === 'not_setup') redirect('/install')
|
||||
if (errorData?.code === 'not_init_validated') redirect('/init')
|
||||
if (error.status === 401) await redirectToAuthRefresh()
|
||||
|
||||
throw error
|
||||
|
||||
+28
-2
@@ -61,8 +61,8 @@ const createParameter = (overrides?: Partial<ToolParameter>): ToolParameter => (
|
||||
zh_Hans: 'Setting Param',
|
||||
},
|
||||
human_description: {
|
||||
en_US: 'desc',
|
||||
zh_Hans: 'desc',
|
||||
en_US: 'Setting description',
|
||||
zh_Hans: 'Setting description',
|
||||
},
|
||||
type: 'string',
|
||||
form: 'config',
|
||||
@@ -91,6 +91,10 @@ const createTool = (overrides?: Partial<Tool>): Tool => ({
|
||||
en_US: 'Info Param',
|
||||
zh_Hans: 'Info Param',
|
||||
},
|
||||
human_description: {
|
||||
en_US: 'Info description',
|
||||
zh_Hans: 'Info description',
|
||||
},
|
||||
form: 'llm',
|
||||
required: false,
|
||||
}),
|
||||
@@ -181,6 +185,28 @@ describe('SettingBuiltInTool', () => {
|
||||
expect(screen.getByTestId('mock-form')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the setting tab hidden when readonly by default', async () => {
|
||||
renderComponent({ readonly: true })
|
||||
|
||||
expect(await screen.findByText('Info Param')).toBeInTheDocument()
|
||||
expect(screen.queryByText('tools.setBuiltInTools.setting')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose readonly setting details when explicitly enabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderComponent({ readonly: true, showReadOnlySettingDetails: true })
|
||||
|
||||
expect(await screen.findByText('Info Param')).toBeInTheDocument()
|
||||
await user.click(screen.getByText('tools.setBuiltInTools.setting'))
|
||||
|
||||
expect(screen.getByText('Setting Param')).toBeInTheDocument()
|
||||
expect(screen.getByText('tools.setBuiltInTools.string')).toBeInTheDocument()
|
||||
expect(screen.getByText('Setting description')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Info Param')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('mock-form')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render a masked drawer with balanced vertical offsets', async () => {
|
||||
const { baseElement } = renderComponent()
|
||||
await waitFor(() => {
|
||||
|
||||
+14
-6
@@ -44,6 +44,7 @@ type Props = Readonly<{
|
||||
toolName: string
|
||||
setting?: Record<string, any>
|
||||
readonly?: boolean
|
||||
showReadOnlySettingDetails?: boolean
|
||||
onHide: () => void
|
||||
onSave?: (value: Record<string, any>) => void
|
||||
credentialId?: string
|
||||
@@ -58,6 +59,7 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
toolName,
|
||||
setting = {},
|
||||
readonly,
|
||||
showReadOnlySettingDetails = false,
|
||||
onHide,
|
||||
onSave,
|
||||
credentialId,
|
||||
@@ -75,6 +77,8 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
const infoSchemas = formSchemas.filter((item) => item.form === 'llm')
|
||||
const settingSchemas = formSchemas.filter((item) => item.form !== 'llm')
|
||||
const hasSetting = settingSchemas.length > 0
|
||||
const showSettingTab = hasSetting && (!readonly || showReadOnlySettingDetails)
|
||||
const showSettingAsDetails = readonly && showReadOnlySettingDetails
|
||||
const [tempSetting, setTempSetting] = useState(setting)
|
||||
const [currType, setCurrType] = useState('info')
|
||||
const isInfoActive = currType === 'info'
|
||||
@@ -120,12 +124,12 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
return type
|
||||
}
|
||||
|
||||
const infoUI = (
|
||||
const renderSchemaDetails = (schemas: typeof formSchemas) => (
|
||||
<div className="">
|
||||
{infoSchemas.length > 0 && (
|
||||
{schemas.length > 0 && (
|
||||
<div className="space-y-1 py-2">
|
||||
{infoSchemas.map((item, index) => (
|
||||
<div key={index} className="py-1">
|
||||
{schemas.map((item) => (
|
||||
<div key={item.name} className="py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="code-sm-semibold text-text-secondary">{item.label[language]}</div>
|
||||
<div className="system-xs-regular text-text-tertiary">{getType(item.type)}</div>
|
||||
@@ -146,6 +150,8 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
const infoUI = renderSchemaDetails(infoSchemas)
|
||||
const settingDetailsUI = renderSchemaDetails(settingSchemas)
|
||||
|
||||
const settingUI = (
|
||||
<Form
|
||||
@@ -230,7 +236,7 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
{/* form */}
|
||||
<div className="h-full">
|
||||
<div className="flex h-full flex-col">
|
||||
{hasSetting && !readonly ? (
|
||||
{showSettingTab ? (
|
||||
<TabSlider
|
||||
className="mt-1 shrink-0 px-4"
|
||||
itemClassName="py-3"
|
||||
@@ -256,7 +262,9 @@ const SettingBuiltInTool: FC<Props> = ({
|
||||
</div>
|
||||
)}
|
||||
<div className="h-0 grow overflow-y-auto px-4">
|
||||
{isInfoActive ? infoUI : settingUI}
|
||||
{isInfoActive && infoUI}
|
||||
{!isInfoActive && showSettingAsDetails && settingDetailsUI}
|
||||
{!isInfoActive && !showSettingAsDetails && settingUI}
|
||||
{!readonly && !isInfoActive && (
|
||||
<div className="flex shrink-0 justify-end space-x-2 rounded-b-[10px] bg-components-panel-bg py-2">
|
||||
<Button
|
||||
|
||||
@@ -9,25 +9,39 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
getStepByStepTourDropdownMenuContentProps,
|
||||
useStepByStepTourControlledDropdown,
|
||||
} from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
|
||||
type CreateAppDropdownProps = {
|
||||
onCreateBlank: () => void
|
||||
onCreateTemplate?: () => void
|
||||
onImportDSL: () => void
|
||||
stepByStepTourControlledOpen?: boolean
|
||||
stepByStepTourTarget?: string
|
||||
stepByStepTourHighlightPart?: string
|
||||
}
|
||||
|
||||
export function CreateAppDropdown({
|
||||
onCreateBlank,
|
||||
onCreateTemplate,
|
||||
onImportDSL,
|
||||
stepByStepTourControlledOpen,
|
||||
stepByStepTourTarget,
|
||||
stepByStepTourHighlightPart,
|
||||
}: CreateAppDropdownProps) {
|
||||
const { t } = useTranslation()
|
||||
const menu = useStepByStepTourControlledDropdown({
|
||||
controlledOpen: stepByStepTourControlledOpen,
|
||||
})
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu modal={false} open={menu.open} onOpenChange={menu.onOpenChange}>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
data-step-by-step-tour-target={stepByStepTourTarget}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className="gap-0.5 px-2 whitespace-nowrap shadow-xs shadow-shadow-shadow-3"
|
||||
@@ -38,7 +52,16 @@ export function CreateAppDropdown({
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent placement="bottom-end" sideOffset={4} popupClassName="w-70 p-0">
|
||||
<DropdownMenuContent
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
disableMotion: menu.controlled,
|
||||
highlightPart: menu.controlled ? stepByStepTourHighlightPart : undefined,
|
||||
interactionMode: menu.controlled ? 'presentation' : 'interactive',
|
||||
popupClassName: 'w-70 p-0',
|
||||
})}
|
||||
>
|
||||
<div className="py-1">
|
||||
<DropdownMenuItem
|
||||
className="h-8 gap-1 rounded-lg px-2 py-1 system-md-regular text-text-secondary"
|
||||
|
||||
@@ -77,6 +77,7 @@ const defaultProviderContext = {
|
||||
}
|
||||
|
||||
const defaultModalContext: ModalContextState = {
|
||||
hasBlockingModalOpen: false,
|
||||
setShowAccountSettingModal: noop,
|
||||
setShowModerationSettingModal: noop,
|
||||
setShowExternalDataToolModal: noop,
|
||||
|
||||
@@ -63,6 +63,7 @@ const mockSetShowAccountSettingModal = vi.fn()
|
||||
const mockUseProviderContext = vi.fn<() => ProviderContextState>()
|
||||
|
||||
const buildModalContext = (): ModalContextState => ({
|
||||
hasBlockingModalOpen: false,
|
||||
setShowAccountSettingModal: mockSetShowAccountSettingModal,
|
||||
setShowModerationSettingModal: vi.fn(),
|
||||
setShowExternalDataToolModal: vi.fn(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Mock } from 'vitest'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import * as appsService from '@/service/apps'
|
||||
import * as exploreService from '@/service/explore'
|
||||
@@ -407,21 +408,28 @@ vi.mock('@langgenius/dify-ui/dropdown-menu', () => {
|
||||
children,
|
||||
className,
|
||||
popupClassName,
|
||||
popupProps,
|
||||
positionerProps,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
popupClassName?: string
|
||||
popupProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
positionerProps?: React.HTMLAttributes<HTMLDivElement>
|
||||
}) => {
|
||||
const { isOpen } = useDropdownMenuContext()
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="dropdown-menu-content"
|
||||
role="menu"
|
||||
className={[className, popupClassName].filter(Boolean).join(' ')}
|
||||
>
|
||||
{children}
|
||||
<div data-testid="dropdown-menu-positioner" {...positionerProps}>
|
||||
<div
|
||||
data-testid="dropdown-menu-content"
|
||||
role="menu"
|
||||
className={[className, popupClassName].filter(Boolean).join(' ')}
|
||||
{...popupProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
@@ -1885,6 +1893,29 @@ describe('AppCard', () => {
|
||||
expect(screen.getByText('common.operation.delete')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render the tour-controlled operations menu as presentation only', async () => {
|
||||
render(
|
||||
<AppCard
|
||||
app={mockApp}
|
||||
stepByStepTourActionMenuHighlightPart={
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu
|
||||
}
|
||||
stepByStepTourActionMenuOpen
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(await screen.findByText('app.editApp')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'app.editApp', hidden: true }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dropdown-menu-positioner')).toHaveAttribute(
|
||||
'data-step-by-step-tour-highlight-part',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu,
|
||||
)
|
||||
expect(screen.getByTestId('dropdown-menu-content')).toHaveAttribute('aria-hidden', 'true')
|
||||
expect(screen.getByTestId('dropdown-menu-content')).toHaveClass('pointer-events-none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Open in Explore - No App Found', () => {
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
|
||||
import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import * as React from 'react'
|
||||
import { stepByStepTourSessionAtom } from '@/app/components/step-by-step-tour/state'
|
||||
import {
|
||||
getStepByStepTourTargetSelector,
|
||||
STEP_BY_STEP_TOUR_TARGETS,
|
||||
} from '@/app/components/step-by-step-tour/target-registry'
|
||||
import { createConsoleQueryWrapper } from '@/test/console/query-data'
|
||||
import { seedRegisteredConsoleStateFixture } from '@/test/console/state-fixture'
|
||||
import { renderWithNuqs } from '@/test/nuqs-testing'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import List from '../list'
|
||||
@@ -31,6 +39,7 @@ const mockUseWorkflowOnlineUsers = vi.hoisted(() =>
|
||||
onlineUsersMap: {},
|
||||
})),
|
||||
)
|
||||
let stepByStepTourSessionState: StepByStepTourSessionState = {}
|
||||
|
||||
const mockLearnDifyApp = vi.hoisted(
|
||||
() =>
|
||||
@@ -118,7 +127,6 @@ vi.mock('@/context/permission-state', async () => {
|
||||
workspacePermissionKeys: mockWorkspacePermissionKeys,
|
||||
}))
|
||||
})
|
||||
|
||||
const mockOnPlanInfoChanged = vi.fn()
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => ({
|
||||
@@ -401,11 +409,34 @@ vi.mock('@/next/dynamic', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../app-card', () => ({
|
||||
AppCard: ({ app }: { app: { id: string; name: string } }) => {
|
||||
AppCard: ({
|
||||
app,
|
||||
stepByStepTourActionMenuOpen,
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
stepByStepTourCardTarget,
|
||||
stepByStepTourCardHighlightPart,
|
||||
}: {
|
||||
app: { id: string; name: string }
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
stepByStepTourCardTarget?: string
|
||||
stepByStepTourCardHighlightPart?: string
|
||||
}) => {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': `app-card-${app.id}`, role: 'article' },
|
||||
{
|
||||
'data-testid': `app-card-${app.id}`,
|
||||
'data-step-by-step-tour-target': stepByStepTourCardTarget,
|
||||
'data-step-by-step-tour-highlight-part': stepByStepTourCardHighlightPart,
|
||||
role: 'article',
|
||||
},
|
||||
app.name,
|
||||
React.createElement('button', {
|
||||
'data-testid': `app-card-action-bar-${app.id}`,
|
||||
'data-step-by-step-tour-highlight-part': stepByStepTourActionMenuHighlightPart,
|
||||
'data-step-by-step-tour-menu-open': String(Boolean(stepByStepTourActionMenuOpen)),
|
||||
type: 'button',
|
||||
}),
|
||||
)
|
||||
},
|
||||
AppCardActionBar: ({ app, onRefresh }: { app: { id: string }; onRefresh?: () => void }) => {
|
||||
@@ -425,10 +456,14 @@ vi.mock('../app-card', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../empty', () => ({
|
||||
default: () => {
|
||||
default: ({ stepByStepTourTarget }: { stepByStepTourTarget?: string }) => {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'empty-state', role: 'status' },
|
||||
{
|
||||
'data-testid': 'empty-state',
|
||||
'data-step-by-step-tour-target': stepByStepTourTarget,
|
||||
role: 'status',
|
||||
},
|
||||
'No apps found',
|
||||
)
|
||||
},
|
||||
@@ -497,12 +532,21 @@ const renderList = (searchParams = '', options: RenderListOptions = {}) => {
|
||||
const { wrapper: ConsoleQueryWrapper } = createConsoleQueryWrapper({
|
||||
systemFeatures: { branding: { enabled: false }, ...options.systemFeatures },
|
||||
})
|
||||
return renderWithNuqs(
|
||||
const store = createStore()
|
||||
seedRegisteredConsoleStateFixture(store)
|
||||
store.set(stepByStepTourSessionAtom, stepByStepTourSessionState)
|
||||
const rendered = renderWithNuqs(
|
||||
<ConsoleQueryWrapper>
|
||||
<List onCreateLearnDify={options.onCreateLearnDify} onTryLearnDify={options.onTryLearnDify} />
|
||||
<JotaiProvider store={store}>
|
||||
<List
|
||||
onCreateLearnDify={options.onCreateLearnDify}
|
||||
onTryLearnDify={options.onTryLearnDify}
|
||||
/>
|
||||
</JotaiProvider>
|
||||
</ConsoleQueryWrapper>,
|
||||
{ searchParams },
|
||||
)
|
||||
return rendered
|
||||
}
|
||||
|
||||
type AppListInfiniteOptions = {
|
||||
@@ -526,9 +570,25 @@ const openAppSortSelect = async (user = userEvent.setup()) => {
|
||||
return user
|
||||
}
|
||||
|
||||
const setActiveStudioStepByStepTour = (
|
||||
activeGuideIndex: number,
|
||||
activeGuideGroup:
|
||||
| 'studioWithApps'
|
||||
| 'studioNoCreateEmpty'
|
||||
| 'studioNoCreateWithApps'
|
||||
| undefined = 'studioWithApps',
|
||||
) => {
|
||||
stepByStepTourSessionState = {
|
||||
activeTaskId: 'studio',
|
||||
activeGuideGroup,
|
||||
activeGuideIndex,
|
||||
}
|
||||
}
|
||||
|
||||
describe('List', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
stepByStepTourSessionState = {}
|
||||
mockIsCurrentWorkspaceDatasetOperator.mockReturnValue(false)
|
||||
mockWorkspacePermissionKeys = ['app.create_and_management']
|
||||
mockDragging = false
|
||||
@@ -600,6 +660,29 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' }))!.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the create menu before the Studio with-apps guide group is persisted', async () => {
|
||||
setActiveStudioStepByStepTour(0, undefined)
|
||||
|
||||
renderList()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'common.operation.create' })).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreate,
|
||||
)
|
||||
expect(await screen.findByText('app.newApp.startFromBlank')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'app.newApp.startFromBlank', hidden: true }),
|
||||
).toBeInTheDocument()
|
||||
const createMenuHighlightPart = document.body.querySelector(
|
||||
'[data-step-by-step-tour-highlight-part]',
|
||||
)
|
||||
expect(createMenuHighlightPart).toHaveAttribute(
|
||||
'data-step-by-step-tour-highlight-part',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsCreateMenu,
|
||||
)
|
||||
expect(screen.getByRole('menu', { hidden: true })).toHaveAttribute('aria-hidden', 'true')
|
||||
})
|
||||
|
||||
it('should render filters and search before the right aligned actions', () => {
|
||||
renderList()
|
||||
|
||||
@@ -690,6 +773,148 @@ describe('List', () => {
|
||||
expect(mockRefetchStarredAppList).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should expose the first workspace app card and open its action menu for the Studio with-apps tour manage guide', () => {
|
||||
setActiveStudioStepByStepTour(1)
|
||||
mockStarredAppData = {
|
||||
data: [
|
||||
{
|
||||
id: 'starred-app-1',
|
||||
name: 'Starred App',
|
||||
description: 'Starred description',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon: '⭐',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
tags: [],
|
||||
author_name: 'Author 1',
|
||||
created_at: 1704067200,
|
||||
updated_at: 1704153600,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
has_more: false,
|
||||
}
|
||||
|
||||
renderList()
|
||||
|
||||
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
|
||||
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
|
||||
const starredCard = screen.getByRole('link', { name: /Starred App/ })
|
||||
const starredActionBar = screen.getByTestId('app-card-action-bar-starred-app-1')
|
||||
|
||||
expect(firstWorkspaceCard).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCard,
|
||||
)
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute(
|
||||
'data-step-by-step-tour-highlight-part',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioWithAppsFirstAppCardActionsMenu,
|
||||
)
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'true')
|
||||
expect(
|
||||
screen.queryByRole('menuitem', { name: 'app.newApp.startFromBlank' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('menuitem', { name: 'app.newApp.startFromTemplate' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(starredCard).not.toHaveAttribute('data-step-by-step-tour-target')
|
||||
expect(starredActionBar).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
|
||||
})
|
||||
|
||||
it('should highlight the first starred app row for the Studio no-create with-apps tour', () => {
|
||||
mockWorkspacePermissionKeys = []
|
||||
setActiveStudioStepByStepTour(0, 'studioNoCreateWithApps')
|
||||
mockStarredAppData = {
|
||||
data: [
|
||||
{
|
||||
id: 'starred-app-1',
|
||||
name: 'Starred App',
|
||||
description: 'Starred description',
|
||||
mode: AppModeEnum.CHAT,
|
||||
icon: '⭐',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
tags: [],
|
||||
author_name: 'Author 1',
|
||||
created_at: 1704067200,
|
||||
updated_at: 1704153600,
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 100,
|
||||
has_more: false,
|
||||
}
|
||||
|
||||
renderList()
|
||||
|
||||
const starredCard = screen.getByRole('link', { name: /Starred App/ })
|
||||
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
|
||||
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
|
||||
|
||||
expect(starredCard).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard,
|
||||
)
|
||||
expect(starredCard).toHaveAttribute(
|
||||
'data-step-by-step-tour-highlight-part',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard,
|
||||
)
|
||||
expect(firstWorkspaceCard).not.toHaveAttribute('data-step-by-step-tour-target')
|
||||
expect(firstWorkspaceCard).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'false')
|
||||
})
|
||||
|
||||
it('should highlight the first all-apps row for the Studio no-create with-apps tour when there are no starred apps', () => {
|
||||
mockWorkspacePermissionKeys = []
|
||||
setActiveStudioStepByStepTour(0, 'studioNoCreateWithApps')
|
||||
|
||||
renderList()
|
||||
|
||||
const firstWorkspaceCard = screen.getByTestId('app-card-app-1')
|
||||
const secondWorkspaceCard = screen.getByTestId('app-card-app-2')
|
||||
const firstWorkspaceActionBar = screen.getByTestId('app-card-action-bar-app-1')
|
||||
|
||||
expect(firstWorkspaceCard).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppCard,
|
||||
)
|
||||
expect(firstWorkspaceCard).toHaveAttribute(
|
||||
'data-step-by-step-tour-highlight-part',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard,
|
||||
)
|
||||
expect(secondWorkspaceCard).toHaveAttribute(
|
||||
'data-step-by-step-tour-highlight-part',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioNoCreateFirstAppRowCard,
|
||||
)
|
||||
expect(firstWorkspaceActionBar).not.toHaveAttribute('data-step-by-step-tour-highlight-part')
|
||||
expect(firstWorkspaceActionBar).toHaveAttribute('data-step-by-step-tour-menu-open', 'false')
|
||||
})
|
||||
|
||||
it('should expose the regular empty state for the Studio no-create empty tour', () => {
|
||||
mockWorkspacePermissionKeys = []
|
||||
mockAppData = { pages: [{ data: [], total: 0 }] }
|
||||
setActiveStudioStepByStepTour(0, 'studioNoCreateEmpty')
|
||||
|
||||
renderList()
|
||||
|
||||
const target = document.querySelector(
|
||||
getStepByStepTourTargetSelector(STEP_BY_STEP_TOUR_TARGETS.studioNoCreateEmpty),
|
||||
)
|
||||
|
||||
expect(target).toBeInTheDocument()
|
||||
expect(target).toBe(screen.getByTestId('empty-state'))
|
||||
expect(target).not.toHaveClass('absolute', 'top-1/2', 'left-1/2')
|
||||
expect(screen.queryByText('app.firstEmpty.title')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.create' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not render new app card in the app grid', () => {
|
||||
renderList()
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
@@ -711,6 +936,28 @@ describe('List', () => {
|
||||
expect(screen.getByRole('button', { name: 'Types' }))!.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('new-app-card')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('empty-state')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: /app\.newApp\.startFromTemplate/ }),
|
||||
).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioEmptyTemplate,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /app\.newApp\.startFromBlank/ })).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioEmptyBlank,
|
||||
)
|
||||
expect(screen.getByRole('button', { name: /app\.importDSL/ })).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioEmptyDSL,
|
||||
)
|
||||
expect(
|
||||
screen
|
||||
.getByText('app.firstEmpty.learnDifyTitle')
|
||||
.closest('[data-step-by-step-tour-target]'),
|
||||
).toHaveAttribute(
|
||||
'data-step-by-step-tour-target',
|
||||
STEP_BY_STEP_TOUR_TARGETS.studioEmptyLearnDify,
|
||||
)
|
||||
})
|
||||
|
||||
it('should lay out first empty state placeholder cards with auto-fill grid columns', () => {
|
||||
|
||||
@@ -36,6 +36,10 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import StarIcon from '@/app/components/base/icons/src/vender/Star'
|
||||
import { UserAvatarList } from '@/app/components/base/user-avatar-list'
|
||||
import { buildInstalledAppPath } from '@/app/components/explore/installed-app/routes'
|
||||
import {
|
||||
getStepByStepTourDropdownMenuContentProps,
|
||||
useStepByStepTourControlledDropdown,
|
||||
} from '@/app/components/step-by-step-tour/dropdown-menu'
|
||||
import { userProfileIdAtom } from '@/context/account-state'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@@ -105,6 +109,10 @@ type AppCardProps = {
|
||||
onlineUsers?: WorkflowOnlineUser[]
|
||||
onRefresh?: () => void
|
||||
onOpenTagManagement?: () => void
|
||||
stepByStepTourActionMenuOpen?: boolean
|
||||
stepByStepTourCardTarget?: string
|
||||
stepByStepTourCardHighlightPart?: string
|
||||
stepByStepTourActionMenuHighlightPart?: string
|
||||
}
|
||||
|
||||
type AppAccessModeIconProps = {
|
||||
@@ -816,6 +824,10 @@ export function AppCard({
|
||||
onlineUsers = [],
|
||||
onRefresh,
|
||||
onOpenTagManagement = () => {},
|
||||
stepByStepTourActionMenuOpen = false,
|
||||
stepByStepTourCardTarget,
|
||||
stepByStepTourCardHighlightPart,
|
||||
stepByStepTourActionMenuHighlightPart,
|
||||
}: AppCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
@@ -831,7 +843,12 @@ export function AppCard({
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [confirmDeleteInput, setConfirmDeleteInput] = useState('')
|
||||
const [showAccessControl, setShowAccessControl] = useState(false)
|
||||
const [isOperationsMenuOpen, setIsOperationsMenuOpen] = useState(false)
|
||||
const operationsMenu = useStepByStepTourControlledDropdown({
|
||||
allowTriggerCloseWhileControlled: false,
|
||||
controlledOpen: stepByStepTourActionMenuOpen,
|
||||
})
|
||||
const isOperationsMenuOpen = operationsMenu.open
|
||||
const setIsOperationsMenuOpen = operationsMenu.onOpenChange
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const { mutateAsync: mutateDeleteApp, isPending: isDeleting } = useDeleteAppMutation()
|
||||
const { mutateAsync: mutateToggleAppStar, isPending: isTogglingStar } = useToggleAppStarMutation()
|
||||
@@ -1204,6 +1221,8 @@ export function AppCard({
|
||||
aria-disabled="true"
|
||||
aria-labelledby={appNameId}
|
||||
aria-describedby={app.description ? appDescriptionId : undefined}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={appCardClassName}
|
||||
onClick={showPreviewOnlyAccessWarning}
|
||||
onKeyDown={handlePreviewOnlyCardKeyDown}
|
||||
@@ -1215,6 +1234,8 @@ export function AppCard({
|
||||
href={appHref}
|
||||
aria-labelledby={appNameId}
|
||||
aria-describedby={app.description ? appDescriptionId : undefined}
|
||||
data-step-by-step-tour-target={stepByStepTourCardTarget}
|
||||
data-step-by-step-tour-highlight-part={stepByStepTourCardHighlightPart}
|
||||
className={appCardClassName}
|
||||
>
|
||||
{appCardContent}
|
||||
@@ -1297,6 +1318,10 @@ export function AppCard({
|
||||
placement="bottom-end"
|
||||
sideOffset={4}
|
||||
popupClassName={operationsMenuWidthClassName}
|
||||
{...getStepByStepTourDropdownMenuContentProps({
|
||||
highlightPart: stepByStepTourActionMenuHighlightPart,
|
||||
interactionMode: operationsMenu.controlled ? 'presentation' : 'interactive',
|
||||
})}
|
||||
>
|
||||
{systemFeatures.webapp_auth.enabled ? (
|
||||
<AppCardOperationsMenuContent
|
||||
|
||||
@@ -30,6 +30,9 @@ type AppListHeaderFiltersProps = {
|
||||
onImportDSL: () => void
|
||||
onOpenTagManagement: () => void
|
||||
showCreateButton: boolean
|
||||
stepByStepTourCreateMenuOpen?: boolean
|
||||
stepByStepTourCreateMenuTarget?: string
|
||||
stepByStepTourCreateMenuHighlightPart?: string
|
||||
}
|
||||
|
||||
export function AppListHeaderFilters({
|
||||
@@ -48,9 +51,11 @@ export function AppListHeaderFilters({
|
||||
onImportDSL,
|
||||
onOpenTagManagement,
|
||||
showCreateButton,
|
||||
stepByStepTourCreateMenuOpen,
|
||||
stepByStepTourCreateMenuTarget,
|
||||
stepByStepTourCreateMenuHighlightPart,
|
||||
}: AppListHeaderFiltersProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||
@@ -85,6 +90,9 @@ export function AppListHeaderFilters({
|
||||
onCreateBlank={onCreateBlank}
|
||||
onCreateTemplate={onCreateTemplate}
|
||||
onImportDSL={onImportDSL}
|
||||
stepByStepTourControlledOpen={stepByStepTourCreateMenuOpen}
|
||||
stepByStepTourTarget={stepByStepTourCreateMenuTarget}
|
||||
stepByStepTourHighlightPart={stepByStepTourCreateMenuHighlightPart}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,20 @@ import FilterEmptyState from '@/app/components/base/filter-empty-state'
|
||||
|
||||
type EmptyProps = {
|
||||
message?: string
|
||||
stepByStepTourTarget?: string
|
||||
}
|
||||
|
||||
const Empty = ({ message }: EmptyProps) => {
|
||||
const Empty = ({ message, stepByStepTourTarget }: EmptyProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return <FilterEmptyState title={message ?? t(($) => $['filterEmpty.noApps'], { ns: 'app' })} />
|
||||
return (
|
||||
<FilterEmptyState
|
||||
title={message ?? t(($) => $['filterEmpty.noApps'], { ns: 'app' })}
|
||||
contentDataAttributes={
|
||||
stepByStepTourTarget ? { 'data-step-by-step-tour-target': stepByStepTourTarget } : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Empty)
|
||||
|
||||
@@ -8,6 +8,7 @@ type VisualStyle = 'default' | 'compact' | 'list'
|
||||
type BaseProps = {
|
||||
badge?: string
|
||||
badgeVariant?: 'basic' | 'advanced'
|
||||
stepByStepTourTarget?: string
|
||||
description: string
|
||||
icon: ReactNode
|
||||
title: string
|
||||
@@ -144,7 +145,11 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) {
|
||||
|
||||
if (props.href) {
|
||||
return (
|
||||
<Link href={props.href} className={className}>
|
||||
<Link
|
||||
href={props.href}
|
||||
className={className}
|
||||
data-step-by-step-tour-target={props.stepByStepTourTarget}
|
||||
>
|
||||
<ActionCardContent
|
||||
badge={props.badge}
|
||||
badgeVariant={props.badgeVariant}
|
||||
@@ -158,7 +163,12 @@ function FirstEmptyActionCard(props: FirstEmptyActionCardProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={className} onClick={props.onClick}>
|
||||
<button
|
||||
type="button"
|
||||
className={className}
|
||||
data-step-by-step-tour-target={props.stepByStepTourTarget}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<ActionCardContent
|
||||
badge={props.badge}
|
||||
badgeVariant={props.badgeVariant}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user