diff --git a/.agents/skills/e2e-cucumber-playwright/SKILL.md b/.agents/skills/e2e-cucumber-playwright/SKILL.md index 75e79ea2e7f..5762bf2076d 100644 --- a/.agents/skills/e2e-cucumber-playwright/SKILL.md +++ b/.agents/skills/e2e-cucumber-playwright/SKILL.md @@ -32,12 +32,11 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - `e2e/` uses Cucumber for scenarios and Playwright as the browser layer. - `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions. - Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps. -- Browser session behavior comes from `features/support/hooks.ts`: - - default: authenticated session with shared storage state - - `@unauthenticated`: clean browser context - - `@authenticated`: readability/selective-run tag only unless implementation changes - - `@fresh`: only for `e2e:full*` flows +- Treat `e2e/AGENTS.md`, `features/support/hooks.ts`, and the Cucumber configuration as the owners of current session and tag semantics. Verify them when behavior depends on session state instead of copying a tag inventory into this skill. - Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture. +- Perform the behavior under test through Playwright. APIs are allowed for setup, seed preparation, persistence polling, and cleanup, but ordinary Console JSON and representable multipart operations must use the scenario- or process-owned generated oRPC client with request and response validation enabled. Keep the setup/cleanup API identity independent from an unauthenticated or logged-out behavior browser. +- Consume generated operations directly. Do not add one-to-one API wrappers, handwritten endpoint URLs, response DTO casts, duplicate schemas, global mutable clients, or TanStack Query caching in Cucumber. Keep helpers only for real fixture construction, multi-operation orchestration, invariants, polling, derived test views, or protocol adapters. +- Keep SSE, binary, redirect-only, external-service, and readiness exceptions centralized under their protocol owner. A contract mismatch must fail and be fixed at the backend schema owner followed by regeneration; never weaken validation to make E2E pass. ## Workflow @@ -66,7 +65,7 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id. 5. Validate narrowly. - Run the narrowest tagged scenario or flow that exercises the change. - - Run `vpr lint --fix --quiet` from the repository root and `pnpm -C e2e type-check`. + - Run the package-required static checks documented in `e2e/AGENTS.md`. - Broaden verification only when the change affects hooks, tags, setup, or shared step semantics. ## Review Checklist @@ -77,6 +76,8 @@ Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. - Are locators user-facing and assertions web-first? - Does the change introduce hidden coupling across scenarios, tags, or instance state? - Does it document or implement behavior that differs from the real hooks or configuration? +- Does setup/cleanup use the generated client directly, with any remaining helper owning more than a one-to-one endpoint forward? +- Is every raw HTTP call a documented protocol or infrastructure exception rather than an ordinary Console operation? Lead findings with correctness, flake risk, and architecture drift. diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 744378e2383..c06a5710cdd 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -729,6 +729,7 @@ class AgentBuildDraftCheckoutApi(Resource): @console_ns.route("/agent//build-draft") class AgentBuildDraftApi(Resource): @console_ns.response(200, "Agent build draft", console_ns.models[AgentBuildDraftResponse.__name__]) + @console_ns.response(404, "Agent build draft not found") @setup_required @login_required @account_initialization_required diff --git a/api/controllers/console/workspace/plugin.py b/api/controllers/console/workspace/plugin.py index d87c1a99b0a..33a4f54e69b 100644 --- a/api/controllers/console/workspace/plugin.py +++ b/api/controllers/console/workspace/plugin.py @@ -67,6 +67,15 @@ from services.plugin.plugin_parameter_service import PluginParameterService from services.plugin.plugin_permission_service import PluginPermissionService from services.tools.tools_transform_service import ToolTransformService +_PLUGIN_PACKAGE_UPLOAD_PARAMS = { + "pkg": { + "description": "Plugin package to upload", + "in": "formData", + "type": "file", + "required": True, + } +} + class AutoUpgradeSettingsResponse(TypedDict): strategy_setting: TenantPluginAutoUpgradeStrategySetting @@ -645,6 +654,7 @@ class PluginAssetApi(Resource): @console_ns.route("/workspaces/current/plugin/upload/pkg") class PluginUploadFromPkgApi(Resource): + @console_ns.doc(consumes=["multipart/form-data"], params=_PLUGIN_PACKAGE_UPLOAD_PARAMS) @console_ns.response(200, "Success", console_ns.models[PluginDecodeResponse.__name__]) @setup_required @login_required diff --git a/api/models/agent_config_entities.py b/api/models/agent_config_entities.py index a47129dcc20..a6a0cd544a2 100644 --- a/api/models/agent_config_entities.py +++ b/api/models/agent_config_entities.py @@ -44,18 +44,28 @@ _DECLARED_OUTPUT_CHILDREN_JSON_SCHEMA = { }, "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, "required": {"type": "boolean"}, - "file": {"type": "object", "additionalProperties": True}, + "file": { + "anyOf": [ + {"type": "object", "additionalProperties": True}, + {"type": "null"}, + ] + }, "array_item": { - "type": "object", - "additionalProperties": True, - "properties": { - "type": { - "type": "string", - "enum": [item.value for item in DeclaredOutputType], + "anyOf": [ + { + "type": "object", + "additionalProperties": True, + "properties": { + "type": { + "type": "string", + "enum": [item.value for item in DeclaredOutputType], + }, + "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, + "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, + }, }, - "description": {"anyOf": [{"type": "string"}, {"type": "null"}]}, - "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, - }, + {"type": "null"}, + ] }, "children": {"type": "array", "items": {"type": "object", "additionalProperties": True}}, }, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 7b3a11ccf91..bc3768f4ae1 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -531,6 +531,7 @@ Run a build-draft Agent App turn that asks the agent to push config updates | Code | Description | Schema | | ---- | ----------- | ------ | | 200 | Agent build draft | **application/json**: [AgentBuildDraftResponse](#agentbuilddraftresponse)
| +| 404 | Agent build draft not found | | ### [PUT] /agent/{agent_id}/build-draft #### Parameters @@ -11332,6 +11333,12 @@ Returns permission flags that control workspace features like member invitations | 200 | Success | **application/json**: [PluginDecodeResponse](#plugindecoderesponse)
| ### [POST] /workspaces/current/plugin/upload/pkg +#### Request Body + +| Required | Schema | +| -------- | ------ | +| Yes | **multipart/form-data**: { **"pkg"**: binary }
| + #### Responses | Code | Description | Schema | @@ -17176,7 +17183,7 @@ about. Stage 4 §4.2. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | +| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | type | [DeclaredOutputType](#declaredoutputtype) | | Yes | @@ -17205,7 +17212,7 @@ code can call ``output.failure_strategy.on_failure`` without None-guards. | ---- | ---- | ----------- | -------- | | array_item | [DeclaredArrayItem](#declaredarrayitem) | | No | | check | [DeclaredOutputCheckConfig](#declaredoutputcheckconfig) | | No | -| children | [ { **"array_item"**: { **"children"**: [ object ], **"description"**: , **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | +| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string,
**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No | | description | string | | No | | failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No | | file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No | diff --git a/api/tests/unit_tests/models/test_agent_config_entities.py b/api/tests/unit_tests/models/test_agent_config_entities.py index 5538a1981de..d95b7130f5c 100644 --- a/api/tests/unit_tests/models/test_agent_config_entities.py +++ b/api/tests/unit_tests/models/test_agent_config_entities.py @@ -139,6 +139,23 @@ def test_declared_output_child_validates_shape_and_defaults() -> None: ) +def test_declared_output_child_schema_matches_nullable_serialization() -> None: + config = DeclaredOutputConfig( + name="response", + type=DeclaredOutputType.OBJECT, + children=[DeclaredOutputChildConfig(name="text", type=DeclaredOutputType.STRING)], + ) + child = config.model_dump(mode="json")["children"][0] + + assert child["file"] is None + assert child["array_item"] is None + + children_schema = DeclaredOutputConfig.model_json_schema(mode="serialization")["properties"]["children"] + child_properties = children_schema["items"]["properties"] + assert {"type": "null"} in child_properties["file"]["anyOf"] + assert {"type": "null"} in child_properties["array_item"]["anyOf"] + + def test_declared_output_validates_shape_and_defaults() -> None: file_output = DeclaredOutputConfig(name="report", type=DeclaredOutputType.FILE) assert file_output.file is not None diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 8771bd27d68..abbf2a27b4b 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -81,7 +81,7 @@ flowchart TD A["Start E2E run"] --> B["run-cucumber.ts orchestrates setup/API/frontend"] B --> C["support/web-server.ts starts or reuses frontend directly"] C --> D["Cucumber loads config, steps, and support modules"] - D --> E["BeforeAll bootstraps shared auth state via /install"] + D --> E["The first Before hook lazily bootstraps shared auth state"] E --> F{"Which command is running?"} F -->|`pnpm -C e2e e2e`| G["Run deterministic scenarios; exclude @prepared and external runtime"] F -->|`pnpm -C e2e e2e:full*`| H["Reset and run deterministic scenarios; exclude @prepared and external runtime"] @@ -96,7 +96,7 @@ Ownership is split like this: - `run-cucumber.ts` orchestrates the E2E run and Cucumber invocation - `support/web-server.ts` manages frontend reuse, startup, readiness, and shutdown - `features/support/hooks.ts` manages auth bootstrap, scenario lifecycle, and diagnostics -- `features/support/world.ts` owns per-scenario typed context +- `features/support/world.ts` owns the per-scenario behavior BrowserContext and authenticated setup/cleanup client; their identities remain separate so unauthenticated and logout journeys cannot invalidate fixture ownership - `features/step-definitions/` holds domain-oriented glue so the official VS Code Cucumber plugin works with default conventions when `e2e/` is opened as the workspace root Package layout: @@ -353,6 +353,18 @@ Keep package-level support limited to broadly reusable primitives such as API cl Use generated API contracts for Console/Web/Service API request, response, and payload shapes. Import the concrete type directly from `@dify/contracts/.../types.gen` when it exists, and do not hand-write duplicate response shapes or wrap generated types in local aliases just to preserve an older helper name. Keep local E2E types only for scenario state, fixture registries, helper input options, and intentionally narrowed test view models that are not complete API responses. +### Console API and protocol boundaries + +The action under test belongs to the browser. `When` steps must use Playwright to perform the user action; do not replace the action with an API request. `Given` setup, seed preparation, persistence polling, and `After` cleanup may use APIs when that makes the scenario faster and more deterministic. `Then` should prefer a user-observable browser result; an API read is appropriate only when persistence itself is the asserted contract and the endpoint owns that state. + +For ordinary Console JSON operations and multipart uploads represented by Console OpenAPI, use the generated oRPC router with generated request and response validation enabled. A scenario client belongs to its `DifyWorld` and uses a scenario-owned authenticated request context that is independent from the behavior browser; seed processes own a standalone client for their process lifetime. Do not create a mutable cross-scenario API client, add TanStack Query caching to Cucumber, hand-write Console endpoint URLs, cast response JSON to an API DTO, or duplicate a generated Zod schema. When a browser action's captured response must provide an ID for cleanup, parse it with the generated response schema. + +Do not add a helper that only renames or forwards one generated operation. Call the generated client directly from the owning step, hook, or fixture orchestration. Keep a helper only when it owns a real test concern such as constructing a valid domain fixture, coordinating multiple operations, maintaining an invariant or cleanup registry, polling eventual consistency, deriving a narrowed test view, or adapting a non-OpenAPI protocol. + +SSE/event streams, binary downloads, redirect-only flows, external services, and infrastructure health/readiness checks may use a dedicated protocol adapter. Keep each exception centralized under its real owner and continue to use generated payload types where the contract covers the request. Multipart is not an exception merely because it carries a file: fix the backend OpenAPI schema and regenerate when the operation can be represented. + +Request or response validation failures are contract failures. Do not suppress them with casts, permissive fallback schemas, disabled validation, swallowed cleanup errors, or a second handwritten request path. Trace the mismatch to the endpoint's backend schema owner, update it according to `api/controllers/API_SCHEMA_GUIDE.md`, regenerate `@dify/contracts`, and keep the E2E assertion aligned with the product's real state owner rather than an internal backing resource. + Use typed cleanup fields on `DifyWorld` for resource types created by scenarios, and use `DifyWorld.registerCleanup(...)` when a scenario creates any resource type that is not covered by typed cleanup fields. Typed cleanup should remove child or referencing resources before their owners, such as Agent files before Agents and workflow apps before Agents they reference. Cleanup failures should be attached to the report instead of being swallowed silently. Cleanup callbacks run after typed cleanup queues, even when the scenario fails. Scenario-owned setup may create disposable apps, Agents, files, credentials, drafts, or access toggles when the scenario owns their lifecycle and cleanup. Do not use scenario setup to silently fix a shared fixture; a missing or drifted fixed resource is a seed failure. diff --git a/e2e/features/agent-v2/AGENTS.md b/e2e/features/agent-v2/AGENTS.md index 013af12d1e0..ab8a20a53c2 100644 --- a/e2e/features/agent-v2/AGENTS.md +++ b/e2e/features/agent-v2/AGENTS.md @@ -25,16 +25,7 @@ Use `@external-model` and `@external-tool` only for runtime calls. A scenario th ## Step organization -Keep steps grouped by user capability: - -- `configure.steps.ts` — navigation, editing, autosave, and saved draft behavior. -- `build-draft.steps.ts` — checkout, apply, discard, and isolation. -- `files.steps.ts`, `knowledge.steps.ts`, `tools.steps.ts` — resource configuration behavior. -- `advanced-settings.steps.ts`, `env-editor.steps.ts` — supported Advanced Settings behavior. -- `agent-roster.steps.ts`, `agent-edit.steps.ts`, `publish.steps.ts` — Agent lifecycle surfaces. -- `access-point*.steps.ts` — Web app, service API, and Workflow access. -- `fixtures.steps.ts` — strict fixture resolution for behavior scenarios. -- `speech-to-text.steps.ts` — voice input and transcription behavior. +Keep steps grouped by Agent product capability, such as configuration, Build draft, resource configuration, lifecycle, Access Point, and runtime behavior. Group by the domain action that owns the wording instead of mechanically pairing a step file with each feature file. Fixture-resolution steps should remain separate from behavior steps because they validate environment readiness rather than perform a user journey. Cucumber step definitions are globally registered. Do not duplicate step text across files. @@ -66,20 +57,11 @@ pnpm -C e2e e2e:post-merge:prepare pnpm -C e2e e2e:post-merge ``` -The strict seed must finish without blocked tasks. It prepares the stable and decision models, Speech-to-Text default, marketplace plugins, JSON Replace and Tavily tools, ready knowledge base, Full Config Agent, Tool States Agent, Dual Retrieval Agent, and Workflow reference. +The strict seed must finish without blocked tasks. The concrete resource inventory and defaults belong to the seed profile and environment configuration rather than this guidance. -Fixture helpers live under `features/agent-v2/support/fixtures/`: +Organize fixture helpers by the product resource or infrastructure capability they own, not by the feature file that happens to consume them. Keep runtime readiness adapters separate from Console resource fixtures, and keep all fixture state in the current `SeedContext` or scenario `DifyWorld` rather than module globals. -- `models.ts` — stable, decision, and Speech-to-Text models. -- `agents.ts` — fixed Agent and configuration contracts. -- `datasets.ts` — indexed knowledge contract. -- `tools.ts` — installed built-in tool contract. -- `access.ts` — Workflow reference contract. -- `agent-backend.ts` — runtime server and shellctl readiness. - -The stable model selectors default to `openai` / `gpt-5-nano` / `llm`. The decision model defaults to `openai` / `gpt-5.5` / `llm`. The Speech-to-Text model defaults to `openai` / `gpt-4o-mini-transcribe`. Provider credentials belong to seed/admin setup through `E2E_MODEL_PROVIDER_CREDENTIALS_JSON`, never to Cucumber steps. - -The Full Config Agent contract includes the stable model, prompt marker, checked-in files, Summary Skill, JSON Replace tool, and indexed knowledge reference. Tool States includes Summary Skill, JSON Replace, Tavily, and its credential reference. Dual Retrieval includes generated-query and custom-query knowledge sets. Workflow reference verifies the same Console API used by the Access Point table. +Provider credentials belong to seed/admin setup, never to Cucumber steps. ## Runtime contract @@ -94,3 +76,5 @@ Build mode covers Configure and Build draft persistence. Preview/Test Run covers ## API contracts Import generated Console/Web/Service API types directly from `@dify/contracts/.../types.gen`. Keep local types only for E2E-owned state, fixture registry entries, helper inputs, and intentionally narrowed views. If the generated contract is incomplete, fix the backend schema and regenerate it instead of duplicating the response shape. + +Agent detail is the state owner for Agent scenarios. An Agent's backing app identifier may be used to route a shared app command, but it is not a substitute query model and must not become the final assertion source. Derive Agent Web app URLs and persisted Agent state from the generated Agent detail contract, then assert the user-visible Access Point or runtime result in the browser. diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index 4d5dea98973..575cef3e662 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -1,23 +1,10 @@ -import type { - AgentApiAccessResponse, - AgentApiStatusPayload, - ApiKeyItem, -} from '@dify/contracts/api/console/agent/types.gen' -import type { - ChatRequestPayloadWithUser, - PostChatMessagesResponse, -} from '@dify/contracts/api/service/types.gen' -import { - zPostAgentByAgentIdApiEnableResponse, - zPostAgentByAgentIdApiKeysResponse, -} from '@dify/contracts/api/console/agent/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' -import { setAppSiteEnabled } from '../../../support/api/web-apps' -import { getTestAgent } from './agent' +import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' +import type { ChatRequestPayloadWithUser } from '@dify/contracts/api/service/types.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import { consumeServiceApiSse, SERVICE_API_STREAM_TIMEOUT_MS } from './service-api-sse' export type AgentServiceApiChatResult = { - body: PostChatMessagesResponse | unknown + body: unknown ok: boolean status: number } @@ -44,43 +31,27 @@ async function parseServiceApiChatResponse(response: Response) { } } -export async function setAgentSiteAccess(agentId: string, enabled: boolean): Promise { - const agent = await getTestAgent(agentId) +export function getAgentWebAppURL(agent: AgentAppDetailWithSite): string { + const token = agent.site?.access_token ?? agent.site?.code + if (!token) throw new Error(`Agent v2 ${agent.id} does not expose a Web app access token.`) + + const baseURL = agent.site?.app_base_url + if (!baseURL) throw new Error(`Agent v2 ${agent.id} does not expose a Web app base URL.`) + + return `${baseURL.replace(/\/$/, '')}/agent/${token}` +} + +export async function enableAgentWebApp(client: ConsoleClient, agentId: string): Promise { + const agent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) const appId = agent.app_id ?? agent.backing_app_id if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) - await setAppSiteEnabled(appId, enabled) -} - -export async function setAgentApiAccess( - agentId: string, - enabled: boolean, -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { enable_api: enabled } satisfies AgentApiStatusPayload - const response = await ctx.post(`/console/api/agent/${agentId}/api-enable`, { - data, - }) - await expectApiResponseOK( - response, - `${enabled ? 'Enable' : 'Disable'} Agent v2 API access for ${agentId}`, - ) - return zPostAgentByAgentIdApiEnableResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function createAgentApiKey(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/api-keys`) - await expectApiResponseOK(response, `Create Agent v2 API key for ${agentId}`) - return zPostAgentByAgentIdApiKeysResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + await client.apps.byAppId.siteEnable.post({ + body: { enable_site: true }, + params: { app_id: appId }, + }) + const updatedAgent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) + return getAgentWebAppURL(updatedAgent) } export async function sendAgentServiceApiChatMessage({ @@ -114,7 +85,7 @@ export async function sendAgentServiceApiChatMessage({ const responseBody = await parseServiceApiChatResponse(response) return { - body: responseBody as PostChatMessagesResponse | unknown, + body: responseBody, ok: response.ok, status: response.status, } diff --git a/e2e/features/agent-v2/support/agent-build-draft.ts b/e2e/features/agent-v2/support/agent-build-draft.ts index 6b1405654cb..7946739ac53 100644 --- a/e2e/features/agent-v2/support/agent-build-draft.ts +++ b/e2e/features/agent-v2/support/agent-build-draft.ts @@ -2,71 +2,33 @@ import type { AgentBuildDraftResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' - -export async function checkoutAgentBuildDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/build-draft/checkout`, { - data: { force: true }, - }) - await expectApiResponseOK(response, `Checkout Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } -} +import type { ConsoleClient } from '../../../support/api/console-client' +import { ORPCError } from '@orpc/client' export async function saveAgentBuildDraft( + client: ConsoleClient, agentId: string, agentSoul: AgentSoulConfig, ): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.put(`/console/api/agent/${agentId}/build-draft`, { - data: { - agent_soul: agentSoul, - save_strategy: 'save_to_current_version', - variant: 'agent_app', - }, - }) - await expectApiResponseOK(response, `Save Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } + return client.agent.byAgentId.buildDraft.put({ + body: { + agent_soul: agentSoul, + save_strategy: 'save_to_current_version', + variant: 'agent_app', + }, + params: { agent_id: agentId }, + }) } -export async function agentBuildDraftExists(agentId: string): Promise { - const ctx = await createConsoleApiContext() +export async function agentBuildDraftExists( + client: ConsoleClient, + agentId: string, +): Promise { try { - const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) - if (response.status() === 404) return false - - await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) + await client.agent.byAgentId.buildDraft.get({ params: { agent_id: agentId } }) return true - } finally { - await ctx.dispose() - } -} - -export async function getAgentBuildDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/build-draft`) - await expectApiResponseOK(response, `Get Agent v2 build draft for ${agentId}`) - return (await response.json()) as AgentBuildDraftResponse - } finally { - await ctx.dispose() - } -} - -export async function discardAgentBuildDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/agent/${agentId}/build-draft`) - await expectApiResponseOK(response, `Discard Agent v2 build draft for ${agentId}`) - } finally { - await ctx.dispose() + } catch (error) { + if (error instanceof ORPCError && error.status === 404) return false + throw error } } diff --git a/e2e/features/agent-v2/support/agent-drive.ts b/e2e/features/agent-v2/support/agent-drive.ts index 1b0fe20e189..c76e7b75c9b 100644 --- a/e2e/features/agent-v2/support/agent-drive.ts +++ b/e2e/features/agent-v2/support/agent-drive.ts @@ -7,17 +7,10 @@ import type { AgentDriveSkillListResponse, AgentSkillUploadResponse, } from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import { Buffer } from 'node:buffer' import { readFile } from 'node:fs/promises' import path from 'node:path' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' - -export type UploadedConsoleFile = { - id: string - mime_type?: string | null - name: string - size?: number | null -} const crc32Table = new Uint32Array(256) for (let i = 0; i < crc32Table.length; i++) { @@ -117,167 +110,98 @@ const toSkillArchiveUpload = async ({ } } -export async function uploadAgentDriveSkill({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createConsoleApiContext() - try { - const upload = await toSkillArchiveUpload({ fileName, filePath }) - const response = await ctx.post(`/console/api/agent/${agentId}/skills/upload`, { - multipart: { - file: { - buffer: upload.buffer, - mimeType: 'application/zip', - name: upload.name, - }, - }, +const createUploadFile = (content: Buffer, name: string, type: string) => + new File([Uint8Array.from(content)], name, { type }) + +export async function uploadAgentDriveSkill( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const upload = await toSkillArchiveUpload({ fileName, filePath }) + return client.agent.byAgentId.skills.upload.post({ + body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') }, + params: { agent_id: agentId }, + }) +} + +export async function uploadAgentConfigFileToDraft( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const uploadedFile = await client.files.upload.post({ + body: { file: createUploadFile(await readFile(filePath), fileName, 'text/plain') }, + }) + const body: AgentConfigFileUploadResponse = await client.agent.byAgentId.config.files.post({ + body: { upload_file_id: uploadedFile.id }, + params: { agent_id: agentId }, + }) + const file = body.file + if (!file.file_id) throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) + + return { + file_id: file.file_id, + file_kind: 'upload_file', + hash: file.hash, + mime_type: file.mime_type, + name: file.name, + size: file.size, + } +} + +export async function uploadAgentConfigSkillToDraft( + client: ConsoleClient, + { + agentId, + fileName, + filePath, + }: { + agentId: string + fileName: string + filePath: string + }, +): Promise { + const upload = await toSkillArchiveUpload({ fileName, filePath }) + const body: AgentConfigSkillUploadResponse = + await client.agent.byAgentId.config.skills.upload.post({ + body: { file: createUploadFile(upload.buffer, upload.name, 'application/zip') }, + params: { agent_id: agentId }, }) - await expectApiResponseOK(response, `Upload Agent v2 drive skill ${fileName} for ${agentId}`) - return (await response.json()) as AgentSkillUploadResponse - } finally { - await ctx.dispose() + const skill = body.skill + if (!skill.file_id) throw new Error(`Agent v2 config skill ${fileName} did not return a file_id.`) + + return { + description: skill.description, + file_id: skill.file_id, + file_kind: 'tool_file', + hash: skill.hash, + mime_type: skill.mime_type, + name: skill.name, + size: skill.size, } } -export async function uploadAgentConfigFileToDraft({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createConsoleApiContext() - try { - const uploadResponse = await ctx.post('/console/api/files/upload', { - multipart: { - file: { - buffer: await readFile(filePath), - mimeType: 'text/plain', - name: fileName, - }, - }, - }) - await expectApiResponseOK(uploadResponse, `Upload Agent v2 config source file ${fileName}`) - const uploadedFile = (await uploadResponse.json()) as UploadedConsoleFile - - const commitResponse = await ctx.post(`/console/api/agent/${agentId}/config/files`, { - data: { - upload_file_id: uploadedFile.id, - }, - }) - await expectApiResponseOK( - commitResponse, - `Commit Agent v2 config file ${fileName} for ${agentId}`, - ) - const body = (await commitResponse.json()) as AgentConfigFileUploadResponse - const file = body.file - if (!file.file_id) throw new Error(`Agent v2 config file ${fileName} did not return a file_id.`) - - return { - file_id: file.file_id, - file_kind: 'upload_file', - hash: file.hash, - mime_type: file.mime_type, - name: file.name, - size: file.size, - } - } finally { - await ctx.dispose() - } -} - -export async function uploadAgentConfigSkillToDraft({ - agentId, - fileName, - filePath, -}: { - agentId: string - fileName: string - filePath: string -}): Promise { - const ctx = await createConsoleApiContext() - try { - const upload = await toSkillArchiveUpload({ fileName, filePath }) - const response = await ctx.post(`/console/api/agent/${agentId}/config/skills/upload`, { - multipart: { - file: { - buffer: upload.buffer, - mimeType: 'application/zip', - name: upload.name, - }, - }, - }) - await expectApiResponseOK(response, `Upload Agent v2 config skill ${fileName} for ${agentId}`) - const body = (await response.json()) as AgentConfigSkillUploadResponse - const skill = body.skill - if (!skill.file_id) - throw new Error(`Agent v2 config skill ${fileName} did not return a file_id.`) - - return { - description: skill.description, - file_id: skill.file_id, - file_kind: 'tool_file', - hash: skill.hash, - mime_type: skill.mime_type, - name: skill.name, - size: skill.size, - } - } finally { - await ctx.dispose() - } -} - -export async function getAgentDriveSkills(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/drive/skills`) - await expectApiResponseOK(response, `Get Agent v2 drive skills for ${agentId}`) - const body = (await response.json()) as AgentDriveSkillListResponse - return body.items ?? [] - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentConfigFile(agentId: string, name: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete( - `/console/api/agent/${agentId}/config/files/${encodeURIComponent(name)}`, - ) - await expectApiResponseOK(response, `Delete Agent v2 config file ${name} for ${agentId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentConfigSkill(agentId: string, name: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete( - `/console/api/agent/${agentId}/config/skills/${encodeURIComponent(name)}`, - ) - await expectApiResponseOK(response, `Delete Agent v2 config skill ${name} for ${agentId}`) - } finally { - await ctx.dispose() - } -} - -export async function deleteAgentDriveFile(agentId: string, key: string): Promise { - const ctx = await createConsoleApiContext() - try { - const searchParams = new URLSearchParams({ key }) - const response = await ctx.delete(`/console/api/agent/${agentId}/files?${searchParams}`) - await expectApiResponseOK(response, `Delete Agent v2 drive file ${key} for ${agentId}`) - } finally { - await ctx.dispose() - } +export async function getAgentDriveSkills( + client: ConsoleClient, + agentId: string, +): Promise { + const body: AgentDriveSkillListResponse = await client.agent.byAgentId.drive.skills.get({ + params: { agent_id: agentId }, + }) + return body.items ?? [] } diff --git a/e2e/features/agent-v2/support/agent.ts b/e2e/features/agent-v2/support/agent.ts index dfcd407f754..c2fa98ea081 100644 --- a/e2e/features/agent-v2/support/agent.ts +++ b/e2e/features/agent-v2/support/agent.ts @@ -6,11 +6,7 @@ import type { AgentReferencingWorkflowsResponse, AgentSoulConfig, } from '@dify/contracts/api/console/agent/types.gen' -import { - zGetAgentByAgentIdResponse, - zPostAgentResponse, -} from '@dify/contracts/api/console/agent/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' +import type { ConsoleClient } from '../../../support/api/console-client' import { assertE2EResourceName, createE2EResourceName } from '../../../support/naming' import { createPublishableAgentSoulConfig, @@ -27,135 +23,87 @@ export type CreateTestAgentOptions = { export const getAgentConfigurePath = (agentId: string) => `/agents/${agentId}/configure` export const getAgentAccessPath = (agentId: string) => `/agents/${agentId}/access` -export async function createTestAgent({ - description = 'Created by Dify E2E.', - name = createE2EResourceName('Agent'), - role = 'E2E test assistant', -}: CreateTestAgentOptions = {}): Promise { +export async function createTestAgent( + client: ConsoleClient, + { + description = 'Created by Dify E2E.', + name = createE2EResourceName('Agent'), + role = 'E2E test assistant', + }: CreateTestAgentOptions = {}, +): Promise { assertE2EResourceName(name, 'Agent') - const ctx = await createConsoleApiContext() - try { - const data = { - description, - icon: '🤖', - icon_background: '#FFEAD5', - icon_type: 'emoji', - name, - role, - } satisfies AgentAppCreatePayload - const response = await ctx.post('/console/api/agent', { - data, - }) - await expectApiResponseOK(response, 'Create Agent v2 test agent') - return zPostAgentResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + const body = { + description, + icon: '🤖', + icon_background: '#FFEAD5', + icon_type: 'emoji', + name, + role, + } satisfies AgentAppCreatePayload + + return client.agent.post({ body }) } -export async function createConfiguredTestAgent({ - agentSoul = normalAgentSoulConfig, - seed, -}: { - agentSoul?: AgentSoulConfig - seed?: CreateTestAgentOptions -} = {}): Promise { - const agent = await createTestAgent(seed) - await saveAgentComposerDraft(agent.id, agentSoul) +export async function createConfiguredTestAgent( + client: ConsoleClient, + { + agentSoul = normalAgentSoulConfig, + seed, + }: { + agentSoul?: AgentSoulConfig + seed?: CreateTestAgentOptions + } = {}, +): Promise { + const agent = await createTestAgent(client, seed) + await saveAgentComposerDraft(client, agent.id, agentSoul) return agent } -export async function getTestAgent(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}`) - await expectApiResponseOK(response, `Get Agent v2 test agent ${agentId}`) - return zGetAgentByAgentIdResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function deleteTestAgent(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/agent/${agentId}`) - await expectApiResponseOK(response, `Delete Agent v2 test agent ${agentId}`) - } finally { - await ctx.dispose() - } -} - export async function saveAgentComposerDraft( + client: ConsoleClient, agentId: string, agentSoul: AgentSoulConfig = defaultAgentSoulConfig, ): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.put(`/console/api/agent/${agentId}/composer`, { - data: { - agent_soul: agentSoul, - save_strategy: 'save_to_current_version', - variant: 'agent_app', - }, - }) - await expectApiResponseOK(response, `Save Agent v2 composer draft for ${agentId}`) - return (await response.json()) as AgentAppComposerResponse - } finally { - await ctx.dispose() - } + return client.agent.byAgentId.composer.put({ + body: { + agent_soul: agentSoul, + save_strategy: 'save_to_current_version', + variant: 'agent_app', + }, + params: { agent_id: agentId }, + }) } export async function getAgentReferencingWorkflows( + client: ConsoleClient, agentId: string, ): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/referencing-workflows`) - await expectApiResponseOK(response, `Get Agent v2 referencing workflows for ${agentId}`) - const body = (await response.json()) as AgentReferencingWorkflowsResponse - return body.data ?? [] - } finally { - await ctx.dispose() - } + const body: AgentReferencingWorkflowsResponse = + await client.agent.byAgentId.referencingWorkflows.get({ params: { agent_id: agentId } }) + return body.data ?? [] } -export async function getAgentComposerDraft(agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agentId}/composer`) - await expectApiResponseOK(response, `Get Agent v2 composer draft for ${agentId}`) - return (await response.json()) as AgentAppComposerResponse - } finally { - await ctx.dispose() - } -} - -export async function ensureAgentComposerDraftIsPublishable(agentId: string): Promise { - const composer = await getAgentComposerDraft(agentId) +async function ensureAgentComposerDraftIsPublishable( + client: ConsoleClient, + agentId: string, +): Promise { + const composer = await client.agent.byAgentId.composer.get({ params: { agent_id: agentId } }) if (!composer.agent_soul?.model) await saveAgentComposerDraft( + client, agentId, createPublishableAgentSoulConfig(composer.agent_soul ?? defaultAgentSoulConfig), ) } -export async function publishAgent(agentId: string, versionNote = 'E2E publish'): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/agent/${agentId}/publish`, { - data: { version_note: versionNote }, - }) - await expectApiResponseOK(response, `Publish Agent v2 test agent ${agentId}`) - } finally { - await ctx.dispose() - } -} - export async function publishAgentWithPublishableDraft( + client: ConsoleClient, agentId: string, versionNote = 'E2E publish', ): Promise { - await ensureAgentComposerDraftIsPublishable(agentId) - await publishAgent(agentId, versionNote) + await ensureAgentComposerDraftIsPublishable(client, agentId) + await client.agent.byAgentId.publish.post({ + body: { version_note: versionNote }, + params: { agent_id: agentId }, + }) } diff --git a/e2e/features/agent-v2/support/fixtures/access.ts b/e2e/features/agent-v2/support/fixtures/access.ts index ba73df40c86..bb4ad8e7206 100644 --- a/e2e/features/agent-v2/support/fixtures/access.ts +++ b/e2e/features/agent-v2/support/fixtures/access.ts @@ -1,51 +1,43 @@ -import type { AgentReferencingWorkflowsResponse } from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { requirePreseededAgent, requirePreseededWorkflow } from './agents' import { failFixturePrerequisite } from './common' export async function requirePreseededAgentWorkflowReference( world: DifyWorld, + client: ConsoleClient, agentName: string, workflowName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) - const workflow = await requirePreseededWorkflow(world, workflowName) + const workflow = await requirePreseededWorkflow(world, client, workflowName) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/referencing-workflows`) - await expectApiResponseOK(response, `Check preseeded Agent workflow reference ${agentName}`) - const references = (await response.json()) as AgentReferencingWorkflowsResponse - const reference = references.data?.find( - (item) => item.app_id === workflow.id || item.app_name === workflow.name, + const references = await client.agent.byAgentId.referencingWorkflows.get({ + params: { agent_id: agent.id }, + }) + const reference = references.data?.find( + (item) => item.app_id === workflow.id || item.app_name === workflow.name, + ) + + if (!reference) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is not referenced by workflow "${workflowName}".`, ) + } - if (!reference) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is not referenced by workflow "${workflowName}".`, - ) - } + if (!reference.node_ids || reference.node_ids.length < 1) { + return failFixturePrerequisite( + world, + `Preseeded workflow "${workflowName}" does not expose Agent reference nodes for "${agentName}".`, + ) + } - if (!reference.node_ids || reference.node_ids.length < 1) { - return failFixturePrerequisite( - world, - `Preseeded workflow "${workflowName}" does not expose Agent reference nodes for "${agentName}".`, - ) - } - - return { - id: workflow.id, - kind: 'workflow', - name: workflow.name, - } - } finally { - await ctx.dispose() + return { + id: workflow.id, + kind: 'workflow', + name: workflow.name, } } diff --git a/e2e/features/agent-v2/support/fixtures/agents.ts b/e2e/features/agent-v2/support/fixtures/agents.ts index 303f2e448b6..e0ab6620d97 100644 --- a/e2e/features/agent-v2/support/fixtures/agents.ts +++ b/e2e/features/agent-v2/support/fixtures/agents.ts @@ -1,13 +1,6 @@ -import type { - AgentAppComposerResponse, - AgentDriveSkillListResponse, -} from '@dify/contracts/api/console/agent/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -18,9 +11,8 @@ import { asArray, asRecord, asString, - buildQuery, failFixturePrerequisite, - findConsoleResourceByName, + findResourceByName, hasNamedOrKeyedEntry, } from './common' import { requireReadyPreseededDataset } from './datasets' @@ -80,14 +72,13 @@ const hasKnowledgeSet = ( export async function requirePreseededAgent( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const query = buildQuery({ limit: '20', name: resourceName, page: '1' }) - const resource = await findConsoleResourceByName({ - action: `Check preseeded Agent ${resourceName}`, - path: `/console/api/agent?${query}`, - resourceName, + const response = await client.agent.get({ + query: { limit: 20, name: resourceName, page: 1 }, }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded Agent "${resourceName}" was not found.`) @@ -101,14 +92,13 @@ export async function requirePreseededAgent( export async function requirePreseededWorkflow( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const query = buildQuery({ limit: '20', mode: 'workflow', name: resourceName, page: '1' }) - const resource = await findConsoleResourceByName({ - action: `Check preseeded workflow ${resourceName}`, - path: `/console/api/apps?${query}`, - resourceName, + const response = await client.apps.get({ + query: { limit: 20, mode: 'workflow', name: resourceName, page: 1 }, }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded workflow "${resourceName}" was not found.`) @@ -122,236 +112,231 @@ export async function requirePreseededWorkflow( export async function requirePreseededAgentDriveSkill( world: DifyWorld, + client: ConsoleClient, agentName: string, skillName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/drive/skills`) - await expectApiResponseOK(response, `Check preseeded Agent skill ${skillName}`) - const body = (await response.json()) as AgentDriveSkillListResponse - const skill = body.items?.find((item) => item.name === skillName) + const response = await client.agent.byAgentId.drive.skills.get({ + params: { agent_id: agent.id }, + }) + const skill = response.items?.find((item) => item.name === skillName) - if (!skill) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`, - ) - } + if (!skill) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" does not include drive skill "${skillName}".`, + ) + } - return { - id: skill.path, - kind: 'skill', - name: skill.name, - } - } finally { - await ctx.dispose() + return { + id: skill.path, + kind: 'skill', + name: skill.name, } } export async function requirePreseededFullConfigAgentCoreConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const stableModel = await requireAgentBuilderStableChatModel(world) + const stableModel = await requireAgentBuilderStableChatModel(world, client) - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) await requirePreseededAgentDriveSkill( world, + client, agentName, agentBuilderPreseededResources.summarySkill, ) - const jsonTool = await requirePreseededTool(world, agentBuilderPreseededResources.jsonReplaceTool) + const jsonTool = await requirePreseededTool( + world, + client, + agentBuilderPreseededResources.jsonReplaceTool, + ) const knowledgeBase = await requireReadyPreseededDataset( world, + client, agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent core configuration ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const missing: string[] = [] - const model = asRecord(soul.model) - if (model.model_provider !== stableModel.provider || model.model !== stableModel.name) - missing.push(`${agentBuilderPreseededResources.stableChatModel} model config`) + const model = asRecord(soul.model) + if (model.model_provider !== stableModel.provider || model.model !== stableModel.name) + missing.push(`${agentBuilderPreseededResources.stableChatModel} model config`) - const prompt = asString(asRecord(soul.prompt).system_prompt) - if (!prompt.includes(agentBuilderExpectedTokens.agentReply)) - missing.push(`Prompt token ${agentBuilderExpectedTokens.agentReply}`) + const prompt = asString(asRecord(soul.prompt).system_prompt) + if (!prompt.includes(agentBuilderExpectedTokens.agentReply)) + missing.push(`Prompt token ${agentBuilderExpectedTokens.agentReply}`) - const files = asArray(soul.config_files) - for (const fileName of [ - agentBuilderTestMaterials.smallFile, - agentBuilderTestMaterials.specialFilename, - ]) { - if (!hasNamedOrKeyedEntry(files, fileName)) missing.push(`file ${fileName}`) - } - - const skills = asArray(soul.config_skills) - if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) - missing.push(agentBuilderPreseededResources.summarySkill) - - const { providerName, toolName } = splitToolResourceId(jsonTool.id) - const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) - if ( - parsedTool.ok && - !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { - providerDisplayName: parsedTool.providerName, - providerName, - toolDisplayName: parsedTool.toolName, - toolName, - }) - ) { - missing.push(agentBuilderPreseededResources.jsonReplaceTool) - } - - if (!hasKnowledgeDataset(soul, knowledgeBase)) - missing.push(agentBuilderPreseededResources.agentKnowledgeBase) - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing core fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + const files = asArray(soul.config_files) + for (const fileName of [ + agentBuilderTestMaterials.smallFile, + agentBuilderTestMaterials.specialFilename, + ]) { + if (!hasNamedOrKeyedEntry(files, fileName)) missing.push(`file ${fileName}`) } + + const skills = asArray(soul.config_skills) + if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) + missing.push(agentBuilderPreseededResources.summarySkill) + + const { providerName, toolName } = splitToolResourceId(jsonTool.id) + const parsedTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) + if ( + parsedTool.ok && + !hasToolEntry(asArray(asRecord(soul.tools).dify_tools), { + providerDisplayName: parsedTool.providerName, + providerName, + toolDisplayName: parsedTool.toolName, + toolName, + }) + ) { + missing.push(agentBuilderPreseededResources.jsonReplaceTool) + } + + if (!hasKnowledgeDataset(soul, knowledgeBase)) + missing.push(agentBuilderPreseededResources.agentKnowledgeBase) + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing core fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } export async function requirePreseededToolStatesAgentConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) await requirePreseededAgentDriveSkill( world, + client, agentName, agentBuilderPreseededResources.summarySkill, ) - const jsonTool = await requirePreseededTool(world, agentBuilderPreseededResources.jsonReplaceTool) + const jsonTool = await requirePreseededTool( + world, + client, + agentBuilderPreseededResources.jsonReplaceTool, + ) const tavilyTool = await requirePreseededTool( world, + client, agentBuilderPreseededResources.tavilySearchTool, ) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent tool states ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const toolItems = asArray(asRecord(soul.tools).dify_tools) - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const toolItems = asArray(asRecord(soul.tools).dify_tools) + const missing: string[] = [] - const skills = asArray(soul.config_skills) - if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) - missing.push(agentBuilderPreseededResources.summarySkill) + const skills = asArray(soul.config_skills) + if (!hasNamedOrKeyedEntry(skills, agentBuilderPreseededResources.summarySkill)) + missing.push(agentBuilderPreseededResources.summarySkill) - const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId( - jsonTool.id, - ) - const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) - if ( - parsedJsonTool.ok && - !findToolEntry(toolItems, { - providerDisplayName: parsedJsonTool.providerName, - providerName: jsonProviderName, - toolDisplayName: parsedJsonTool.toolName, - toolName: jsonToolName, - }) - ) { - missing.push(agentBuilderPreseededResources.jsonReplaceTool) - } - - const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId( - tavilyTool.id, - ) - const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) - const tavilyEntry = parsedTavilyTool.ok - ? findToolEntry(toolItems, { - providerDisplayName: parsedTavilyTool.providerName, - providerName: tavilyProviderName, - toolDisplayName: parsedTavilyTool.toolName, - toolName: tavilyToolName, - }) - : undefined - - if (!tavilyEntry) { - missing.push(agentBuilderPreseededResources.tavilySearchTool) - } else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { - missing.push( - `${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`, - ) - } - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing tool state fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + const { providerName: jsonProviderName, toolName: jsonToolName } = splitToolResourceId( + jsonTool.id, + ) + const parsedJsonTool = splitToolDisplayName(agentBuilderPreseededResources.jsonReplaceTool) + if ( + parsedJsonTool.ok && + !findToolEntry(toolItems, { + providerDisplayName: parsedJsonTool.providerName, + providerName: jsonProviderName, + toolDisplayName: parsedJsonTool.toolName, + toolName: jsonToolName, + }) + ) { + missing.push(agentBuilderPreseededResources.jsonReplaceTool) } + + const { providerName: tavilyProviderName, toolName: tavilyToolName } = splitToolResourceId( + tavilyTool.id, + ) + const parsedTavilyTool = splitToolDisplayName(agentBuilderPreseededResources.tavilySearchTool) + const tavilyEntry = parsedTavilyTool.ok + ? findToolEntry(toolItems, { + providerDisplayName: parsedTavilyTool.providerName, + providerName: tavilyProviderName, + toolDisplayName: parsedTavilyTool.toolName, + toolName: tavilyToolName, + }) + : undefined + + if (!tavilyEntry) { + missing.push(agentBuilderPreseededResources.tavilySearchTool) + } else if (!hasUnauthorizedToolCredentialState(tavilyEntry)) { + missing.push(`${agentBuilderPreseededResources.tavilySearchTool} unauthorized credential state`) + } + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing tool state fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } export async function requirePreseededDualRetrievalAgentConfiguration( world: DifyWorld, + client: ConsoleClient, agentName: string, ): Promise { - const agent = await requirePreseededAgent(world, agentName) + const agent = await requirePreseededAgent(world, client, agentName) const knowledgeBase = await requireReadyPreseededDataset( world, + client, agentBuilderPreseededResources.agentKnowledgeBase, ) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/agent/${agent.id}/composer`) - await expectApiResponseOK(response, `Check preseeded Agent dual retrieval ${agentName}`) - const body = (await response.json()) as AgentAppComposerResponse - const soul = body.agent_soul ?? {} - const missing: string[] = [] + const response = await client.agent.byAgentId.composer.get({ + params: { agent_id: agent.id }, + }) + const soul = response.agent_soul ?? {} + const missing: string[] = [] - if (!hasKnowledgeSet(soul, knowledgeBase, { queryMode: 'generated_query' })) - missing.push('Agent decide Knowledge Retrieval') + if (!hasKnowledgeSet(soul, knowledgeBase, { queryMode: 'generated_query' })) + missing.push('Agent decide Knowledge Retrieval') - if ( - !hasKnowledgeSet(soul, knowledgeBase, { - queryMode: 'user_query', - queryValue: agentBuilderFixedInputs.customKnowledgeQuery, - }) - ) { - missing.push('Custom query Knowledge Retrieval') - } - - if (missing.length > 0) { - return failFixturePrerequisite( - world, - `Preseeded Agent "${agentName}" is missing dual retrieval fixture configuration: ${missing.join(', ')}.`, - ) - } - - return agent - } finally { - await ctx.dispose() + if ( + !hasKnowledgeSet(soul, knowledgeBase, { + queryMode: 'user_query', + queryValue: agentBuilderFixedInputs.customKnowledgeQuery, + }) + ) { + missing.push('Custom query Knowledge Retrieval') } + + if (missing.length > 0) { + return failFixturePrerequisite( + world, + `Preseeded Agent "${agentName}" is missing dual retrieval fixture configuration: ${missing.join(', ')}.`, + ) + } + + return agent } diff --git a/e2e/features/agent-v2/support/fixtures/common.ts b/e2e/features/agent-v2/support/fixtures/common.ts index 802b7bf5ac3..6e30a3e0871 100644 --- a/e2e/features/agent-v2/support/fixtures/common.ts +++ b/e2e/features/agent-v2/support/fixtures/common.ts @@ -1,8 +1,4 @@ import type { DifyWorld } from '../../../support/world' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' export type PreseededResource = NonNullable< DifyWorld['agentBuilder']['fixtures']['preseededResources'][string] @@ -13,15 +9,6 @@ export type NamedResource = { name: string } -export type NamedResourceCollection = { - data: T[] -} - -export type LocalizedLabel = { - en_US?: string - zh_Hans?: string -} - export function failFixturePrerequisite( world: DifyWorld, reason: string, @@ -39,31 +26,8 @@ export function failFixturePrerequisite( throw new Error(message) } -export const findConsoleResourceByName = async ({ - action, - path, - resourceName, -}: { - action: string - path: string - resourceName: string -}) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(path) - await expectApiResponseOK(response, action) - const body = (await response.json()) as NamedResourceCollection - - return body.data.find((item) => item.name === resourceName) - } finally { - await ctx.dispose() - } -} - -export const buildQuery = (params: Record) => new URLSearchParams(params).toString() - -export const matchesNameOrLabel = (value: string, name: string, label?: LocalizedLabel) => - value === name || value === label?.en_US || value === label?.zh_Hans +export const findResourceByName = (resources: T[], resourceName: string) => + resources.find((item) => item.name === resourceName) export const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) @@ -74,6 +38,16 @@ export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? va export const asString = (value: unknown) => (typeof value === 'string' ? value : '') +export const matchesNameOrLabel = (value: string, name: string, label?: unknown) => { + const localizedLabel = asRecord(label) + + return ( + value === name || + value === asString(localizedLabel.en_US) || + value === asString(localizedLabel.zh_Hans) + ) +} + export const hasNamedOrKeyedEntry = (items: unknown[], expectedName: string) => items.some((item) => { const record = asRecord(item) diff --git a/e2e/features/agent-v2/support/fixtures/datasets.ts b/e2e/features/agent-v2/support/fixtures/datasets.ts index c7bc58601fb..bcfcc9369fb 100644 --- a/e2e/features/agent-v2/support/fixtures/datasets.ts +++ b/e2e/features/agent-v2/support/fixtures/datasets.ts @@ -1,21 +1,16 @@ import type { - ConsoleSegmentListResponse, DatasetListItemResponse, - DocumentStatusListResponse, DocumentWithSegmentsListResponse, } from '@dify/contracts/api/console/datasets/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' import type { PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../agent-builder-resources' -import { buildQuery, failFixturePrerequisite, findConsoleResourceByName } from './common' +import { failFixturePrerequisite, findResourceByName } from './common' type DocumentIndexingStatus = | 'cleaning' @@ -26,90 +21,58 @@ type DocumentIndexingStatus = | 'waiting' const completedDocumentIndexingStatus: DocumentIndexingStatus = 'completed' -export const getPreseededDataset = async (resourceName: string) => { - const query = buildQuery({ keyword: resourceName, limit: '20', page: '1' }) - - return findConsoleResourceByName({ - action: `Check preseeded dataset ${resourceName}`, - path: `/console/api/datasets?${query}`, - resourceName, - }) -} - -const getDatasetIndexingStatuses = async (datasetId: string, resourceName: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) - await expectApiResponseOK(response, `Check preseeded dataset indexing status ${resourceName}`) - const body = (await response.json()) as DocumentStatusListResponse - - return body.data - } finally { - await ctx.dispose() - } -} - -const getDatasetDocuments = async (datasetId: string, resourceName: string) => { +const getDatasetDocuments = async (client: ConsoleClient, datasetId: string) => { const documents: DocumentWithSegmentsListResponse['data'] = [] - const ctx = await createConsoleApiContext() - try { - let page = 1 - let hasMore = true + let page = 1 + let hasMore = true - while (hasMore) { - const query = buildQuery({ limit: '100', page: String(page) }) - const response = await ctx.get(`/console/api/datasets/${datasetId}/documents?${query}`) - await expectApiResponseOK(response, `List preseeded dataset documents ${resourceName}`) - const body = (await response.json()) as DocumentWithSegmentsListResponse + while (hasMore) { + const response = await client.datasets.byDatasetId.documents.get({ + params: { dataset_id: datasetId }, + query: { limit: '100', page: String(page) }, + }) - documents.push(...body.data) - hasMore = body.has_more - page += 1 - } - - return documents - } finally { - await ctx.dispose() + documents.push(...response.data) + hasMore = response.has_more + page += 1 } + + return documents } const datasetHasEnabledSegmentContainingTokens = async ( + client: ConsoleClient, datasetId: string, - resourceName: string, expectedTokens: string[], ) => { - const documents = await getDatasetDocuments(datasetId, resourceName) - const ctx = await createConsoleApiContext() - try { - for (const document of documents) { - const query = buildQuery({ + const documents = await getDatasetDocuments(client, datasetId) + for (const document of documents) { + const response = await client.datasets.byDatasetId.documents.byDocumentId.segments.get({ + params: { + dataset_id: datasetId, + document_id: document.id, + }, + query: { enabled: 'true', keyword: agentBuilderExpectedTokens.knowledgeReply, - limit: '20', - page: '1', - }) - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${query}`, - ) - await expectApiResponseOK(response, `Check preseeded dataset segment content ${resourceName}`) - const body = (await response.json()) as ConsoleSegmentListResponse - const matchingSegment = body.data.find( - (segment) => - segment.enabled && - expectedTokens.every( - (expectedToken) => - segment.content.includes(expectedToken) || - segment.keywords?.some((keyword) => keyword.includes(expectedToken)), - ), - ) + limit: 20, + page: 1, + }, + }) + const matchingSegment = response.data.find( + (segment) => + segment.enabled && + expectedTokens.every( + (expectedToken) => + segment.content.includes(expectedToken) || + segment.keywords?.some((keyword) => keyword.includes(expectedToken)), + ), + ) - if (matchingSegment) return true - } - - return false - } finally { - await ctx.dispose() + if (matchingSegment) return true } + + return false } const toDatasetResource = (resource: DatasetListItemResponse): PreseededResource => ({ @@ -120,9 +83,13 @@ const toDatasetResource = (resource: DatasetListItemResponse): PreseededResource export async function requireReadyPreseededDataset( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { - const resource = await getPreseededDataset(resourceName) + const response = await client.datasets.get({ + query: { keyword: resourceName, limit: 20, page: 1 }, + }) + const resource = findResourceByName(response.data, resourceName) if (!resource) return failFixturePrerequisite(world, `Preseeded dataset "${resourceName}" was not found.`) @@ -138,7 +105,10 @@ export async function requireReadyPreseededDataset( ) } - const statuses = await getDatasetIndexingStatuses(resource.id, resourceName) + const indexingStatus = await client.datasets.byDatasetId.indexingStatus.get({ + params: { dataset_id: resource.id }, + }) + const statuses = indexingStatus.data if (statuses.length < 1) { return failFixturePrerequisite( world, @@ -163,8 +133,8 @@ export async function requireReadyPreseededDataset( agentBuilderExpectedTokens.knowledgeReply, ] const hasExpectedToken = await datasetHasEnabledSegmentContainingTokens( + client, resource.id, - resourceName, requiredTokens, ) diff --git a/e2e/features/agent-v2/support/fixtures/models.ts b/e2e/features/agent-v2/support/fixtures/models.ts index d1acc1f2ec2..b00d1448a95 100644 --- a/e2e/features/agent-v2/support/fixtures/models.ts +++ b/e2e/features/agent-v2/support/fixtures/models.ts @@ -1,12 +1,5 @@ -import type { - DefaultModelDataResponse, - ProviderWithModelsResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' import { agentBuilderPreseededResources } from '../agent-builder-resources' import { failFixturePrerequisite } from './common' @@ -76,6 +69,7 @@ export function readAgentBuilderAgentDecisionChatModelConfig(): ModelFixtureConf async function requireAgentBuilderModel( world: DifyWorld, + client: ConsoleClient, config: ModelFixtureConfig, { requireActive, @@ -85,83 +79,70 @@ async function requireAgentBuilderModel( ): Promise> { if (!config.ok) return failFixturePrerequisite(world, config.reason) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/models/model-types/${config.type}`, + const response = await client.workspaces.current.models.modelTypes.byModelType.get({ + params: { model_type: config.type }, + }) + const provider = response.data.find((item) => matchesProvider(item.provider, config.provider)) + const model = provider?.models.find( + (item) => + item.model === config.value || + item.label?.en_US === config.value || + item.label?.zh_Hans === config.value, + ) + + if (!provider || !model) { + return failFixturePrerequisite( + world, + `${config.resourceName} was not found as ${config.provider}/${config.value} (${config.type}).`, ) - await expectApiResponseOK(response, `Check ${config.resourceName}`) - const body = (await response.json()) as { data: ProviderWithModelsResponse[] } - const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) - const model = provider?.models.find( - (item) => - item.model === config.value || - item.label?.en_US === config.value || - item.label?.zh_Hans === config.value, + } + + if (requireActive && model.status !== activeModelStatus) { + return failFixturePrerequisite( + world, + `${config.resourceName} is ${model.status ?? 'missing status'} instead of ${activeModelStatus}.`, ) + } - if (!provider || !model) { - return failFixturePrerequisite( - world, - `${config.resourceName} was not found as ${config.provider}/${config.value} (${config.type}).`, - ) - } - - if (requireActive && model.status !== activeModelStatus) { - return failFixturePrerequisite( - world, - `${config.resourceName} is ${model.status ?? 'missing status'} instead of ${activeModelStatus}.`, - ) - } - - return { - name: model.model, - provider: provider.provider, - type: config.type, - } - } finally { - await ctx.dispose() + return { + name: model.model, + provider: provider.provider, + type: config.type, } } export async function requireAgentBuilderStableChatModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - return requireAgentBuilderModel(world, readAgentBuilderStableChatModelConfig(), { + return requireAgentBuilderModel(world, client, readAgentBuilderStableChatModelConfig(), { requireActive: true, }) } export async function requireAgentBuilderSpeechToTextModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - const ctx = await createConsoleApiContext() - let defaultModel: NonNullable - - try { - const response = await ctx.get( - '/console/api/workspaces/current/default-model?model_type=speech2text', + const response = await client.workspaces.current.defaultModel.get({ + query: { model_type: 'speech2text' }, + }) + if (!response.data) { + return failFixturePrerequisite( + world, + `${agentBuilderPreseededResources.speechToTextModel} is not configured.`, + { + owner: 'model-provider/seed', + remediation: + 'Configure an active workspace default Speech-to-Text model before running the external scenario.', + }, ) - await expectApiResponseOK(response, `Check ${agentBuilderPreseededResources.speechToTextModel}`) - const body = (await response.json()) as DefaultModelDataResponse - if (!body.data) { - return failFixturePrerequisite( - world, - `${agentBuilderPreseededResources.speechToTextModel} is not configured.`, - { - owner: 'model-provider/seed', - remediation: - 'Configure an active workspace default Speech-to-Text model before running the external scenario.', - }, - ) - } - defaultModel = body.data - } finally { - await ctx.dispose() } + const defaultModel = response.data return requireAgentBuilderModel( world, + client, { ok: true, provider: defaultModel.provider.provider, @@ -177,8 +158,9 @@ export async function requireAgentBuilderSpeechToTextModel( export async function requireAgentBuilderAgentDecisionChatModel( world: DifyWorld, + client: ConsoleClient, ): Promise> { - return requireAgentBuilderModel(world, readAgentBuilderAgentDecisionChatModelConfig(), { + return requireAgentBuilderModel(world, client, readAgentBuilderAgentDecisionChatModelConfig(), { requireActive: true, }) } diff --git a/e2e/features/agent-v2/support/fixtures/tools.ts b/e2e/features/agent-v2/support/fixtures/tools.ts index b8be21c04ec..b380848920b 100644 --- a/e2e/features/agent-v2/support/fixtures/tools.ts +++ b/e2e/features/agent-v2/support/fixtures/tools.ts @@ -1,20 +1,8 @@ +import type { ConsoleClient } from '../../../../support/api/console-client' import type { DifyWorld } from '../../../support/world' -import type { LocalizedLabel, PreseededResource } from './common' -import { - createConsoleApiContext, - expectApiResponseOK, -} from '../../../../support/api/console-context' +import type { PreseededResource } from './common' import { asRecord, asString, failFixturePrerequisite, matchesNameOrLabel } from './common' -type BuiltinToolProvider = { - label?: LocalizedLabel - name: string - tools: Array<{ - label?: LocalizedLabel - name: string - }> -} - export const splitToolDisplayName = (resourceName: string) => { const [providerName, toolName] = resourceName.split('/').map((item) => item.trim()) @@ -91,32 +79,26 @@ export const hasUnauthorizedToolCredentialState = (item: unknown) => { export async function requirePreseededTool( world: DifyWorld, + client: ConsoleClient, resourceName: string, ): Promise { const parsed = splitToolDisplayName(resourceName) if (!parsed.ok) return failFixturePrerequisite(world, parsed.reason) - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get('/console/api/workspaces/current/tools/builtin') - await expectApiResponseOK(response, `Check preseeded tool ${resourceName}`) - const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find((item) => - matchesNameOrLabel(parsed.providerName, item.name, item.label), - ) - const tool = provider?.tools.find((item) => - matchesNameOrLabel(parsed.toolName, item.name, item.label), - ) + const providers = await client.workspaces.current.tools.builtin.get() + const provider = providers.find((item) => + matchesNameOrLabel(parsed.providerName, item.name, item.label), + ) + const tool = provider?.tools?.find((item) => + matchesNameOrLabel(parsed.toolName, item.name, item.label), + ) - if (!provider || !tool) - return failFixturePrerequisite(world, `Preseeded tool "${resourceName}" was not found.`) + if (!provider || !tool) + return failFixturePrerequisite(world, `Preseeded tool "${resourceName}" was not found.`) - return { - id: `${provider.name}/${tool.name}`, - kind: 'tool', - name: resourceName, - } - } finally { - await ctx.dispose() + return { + id: `${provider.name}/${tool.name}`, + kind: 'tool', + name: resourceName, } } diff --git a/e2e/features/agent-v2/support/seed.ts b/e2e/features/agent-v2/support/seed.ts index 08c592e8166..fd0ec8f8728 100644 --- a/e2e/features/agent-v2/support/seed.ts +++ b/e2e/features/agent-v2/support/seed.ts @@ -3,28 +3,15 @@ import type { AgentSoulConfig, AgentSoulDifyToolConfig, } from '@dify/contracts/api/console/agent/types.gen' -import type { - ConsoleSegmentListResponse, - DatasetListItemResponse, - DocumentStatusListResponse, - DocumentWithSegmentsListResponse, - KnowledgeConfig, -} from '@dify/contracts/api/console/datasets/types.gen' -import type { - AvailableModelListResponse, - DefaultModelDataResponse, - ModelProviderListResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' +import type { KnowledgeConfig } from '@dify/contracts/api/console/datasets/types.gen' +import type { ModelType } from '@dify/contracts/api/console/workspaces/types.gen' import type { SeedContext, SeedResource, SeedTask } from '../../../support/seed' -import type { UploadedConsoleFile } from './agent-drive' import { readFile } from 'node:fs/promises' import { createTestApp } from '../../../support/api/apps' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' -import { publishWorkflowApp } from '../../../support/api/workflows' import { bootstrapMarketplacePlugins } from '../../../support/marketplace-plugins' import { sleep } from '../../../support/process' import { blocked, created, skipped, updated, verified } from '../../../support/seed' -import { createTestAgent, publishAgent, saveAgentComposerDraft } from './agent' +import { createTestAgent, saveAgentComposerDraft } from './agent' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -41,12 +28,7 @@ import { createAgentSoulConfigWithModel, normalAgentSoulConfig, } from './agent-soul' -import { - buildQuery, - findConsoleResourceByName, - isRecord, - matchesNameOrLabel, -} from './fixtures/common' +import { isRecord, matchesNameOrLabel } from './fixtures/common' import { splitToolDisplayName } from './fixtures/tools' import { agentBuilderTestMaterials, getAgentBuilderTestMaterialPath } from './test-materials' import { syncAgentV2WorkflowDraft } from './workflow' @@ -54,7 +36,7 @@ import { syncAgentV2WorkflowDraft } from './workflow' type StableModel = { name: string provider: string - type: string + type: ModelType } type ToolResource = SeedResource & { @@ -88,16 +70,33 @@ const matchesProviderLabel = ( provider.label?.en_US === expected || provider.label?.zh_Hans === expected +const parseModelType = (value: string | undefined, fallback: ModelType): ModelType => { + const modelType = value?.trim() + if (!modelType) return fallback + + switch (modelType) { + case 'llm': + case 'moderation': + case 'rerank': + case 'speech2text': + case 'text-embedding': + case 'tts': + return modelType + default: + throw new Error(`Unsupported model type "${modelType}".`) + } +} + const stableModelConfig = (): StableModel => ({ name: process.env.E2E_STABLE_MODEL_NAME?.trim() || 'gpt-5-nano', provider: process.env.E2E_STABLE_MODEL_PROVIDER?.trim() || 'openai', - type: process.env.E2E_STABLE_MODEL_TYPE?.trim() || 'llm', + type: parseModelType(process.env.E2E_STABLE_MODEL_TYPE, 'llm'), }) const agentDecisionModelConfig = (): StableModel => ({ name: process.env.E2E_AGENT_DECISION_MODEL_NAME?.trim() || 'gpt-5.5', provider: process.env.E2E_AGENT_DECISION_MODEL_PROVIDER?.trim() || 'openai', - type: process.env.E2E_AGENT_DECISION_MODEL_TYPE?.trim() || 'llm', + type: parseModelType(process.env.E2E_AGENT_DECISION_MODEL_TYPE, 'llm'), }) const speechToTextModelConfig = (): StableModel => ({ @@ -123,120 +122,86 @@ const parseJsonEnv = (envName: string) => { } } -const findModel = async (config: StableModel, title: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/models/model-types/${config.type}`, - ) - await expectApiResponseOK(response, `Check ${title}`) - const body = (await response.json()) as AvailableModelListResponse - const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) - const model = provider?.models.find( - (item) => - item.model === config.name || - item.label?.en_US === config.name || - item.label?.zh_Hans === config.name, - ) +const findModel = async (client: SeedContext['consoleClient'], config: StableModel) => { + const body = await client.workspaces.current.models.modelTypes.byModelType.get({ + params: { model_type: config.type }, + }) + const provider = body.data.find((item) => matchesProvider(item.provider, config.provider)) + const model = provider?.models.find( + (item) => + item.model === config.name || + item.label?.en_US === config.name || + item.label?.zh_Hans === config.name, + ) - if (!provider || !model) return undefined + if (!provider || !model) return undefined - return { - name: model.model, - provider: provider.provider, - status: model.status, - type: config.type, - } - } finally { - await ctx.dispose() + return { + name: model.model, + provider: provider.provider, + status: model.status, + type: config.type, } } -const resolveProvider = async (config: StableModel) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/model-providers?${buildQuery({ model_type: config.type })}`, - ) - await expectApiResponseOK(response, `Resolve model provider ${config.provider}`) - const body = (await response.json()) as ModelProviderListResponse - const provider = body.data.find((item) => matchesProviderLabel(item, config.provider)) +const resolveProvider = async (client: SeedContext['consoleClient'], config: StableModel) => { + const body = await client.workspaces.current.modelProviders.get({ + query: { model_type: config.type }, + }) + const provider = body.data.find((item) => matchesProviderLabel(item, config.provider)) - return { - availableProviders: body.data.map((provider) => provider.provider), - credential: provider?.custom_configuration.available_credentials?.find( - (credential) => credential.credential_name === stableModelCredentialName, - ), - provider: provider?.provider, - } - } finally { - await ctx.dispose() + return { + availableProviders: body.data.map((provider) => provider.provider), + credential: provider?.custom_configuration.available_credentials?.find( + (credential) => credential.credential_name === stableModelCredentialName, + ), + provider: provider?.provider, } } -const selectCustomProviderCredential = async (provider: string, credentialId?: string) => { - const ctx = await createConsoleApiContext() - try { - if (credentialId) { - const switchResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/credentials/switch`, - { - data: { credential_id: credentialId }, - }, - ) - await expectApiResponseOK(switchResponse, `Switch model provider credential for ${provider}`) - } - - const preferredResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/preferred-provider-type`, - { - data: { preferred_provider_type: 'custom' }, - }, - ) - await expectApiResponseOK( - preferredResponse, - `Select custom provider credential for ${provider}`, - ) - } finally { - await ctx.dispose() +const selectCustomProviderCredential = async ( + client: SeedContext['consoleClient'], + provider: string, + credentialId?: string, +) => { + if (credentialId) { + await client.workspaces.current.modelProviders.byProvider.credentials.switch.post({ + body: { credential_id: credentialId }, + params: { provider }, + }) } + + await client.workspaces.current.modelProviders.byProvider.preferredProviderType.post({ + body: { preferred_provider_type: 'custom' }, + params: { provider }, + }) } const upsertStableProviderCredential = async ( + client: SeedContext['consoleClient'], provider: string, credentials: Record, credentialId?: string, ) => { - const ctx = await createConsoleApiContext() - try { - if (credentialId) { - const updateResponse = await ctx.put( - `/console/api/workspaces/current/model-providers/${provider}/credentials`, - { - data: { - credential_id: credentialId, - credentials, - name: stableModelCredentialName, - }, - }, - ) - await expectApiResponseOK(updateResponse, `Update model provider credential for ${provider}`) - return - } - - const createResponse = await ctx.post( - `/console/api/workspaces/current/model-providers/${provider}/credentials`, - { - data: { - credentials, - name: stableModelCredentialName, - }, + if (credentialId) { + await client.workspaces.current.modelProviders.byProvider.credentials.put({ + body: { + credential_id: credentialId, + credentials, + name: stableModelCredentialName, }, - ) - await expectApiResponseOK(createResponse, `Create model provider credential for ${provider}`) - } finally { - await ctx.dispose() + params: { provider }, + }) + return } + + await client.workspaces.current.modelProviders.byProvider.credentials.post({ + body: { + credentials, + name: stableModelCredentialName, + }, + params: { provider }, + }) } const seedModel = async ( @@ -249,7 +214,7 @@ const seedModel = async ( title: string }, ) => { - const existing = await findModel(config, title) + const existing = await findModel(context.consoleClient, config) const resource = { id: `${existing?.provider ?? config.provider}/${existing?.name ?? config.name}`, kind: 'model', @@ -268,7 +233,10 @@ const seedModel = async ( if (!credentials.ok) return blocked(title, `${config.provider}/${config.name} is not active; ${credentials.reason}`) - const { availableProviders, credential, provider } = await resolveProvider(config) + const { availableProviders, credential, provider } = await resolveProvider( + context.consoleClient, + config, + ) if (!provider) { const available = availableProviders.length > 0 ? availableProviders.join(', ') : 'none' return blocked( @@ -278,14 +246,19 @@ const seedModel = async ( } try { - await upsertStableProviderCredential(provider, credentials.value, credential?.credential_id) - await selectCustomProviderCredential(provider, credential?.credential_id) + await upsertStableProviderCredential( + context.consoleClient, + provider, + credentials.value, + credential?.credential_id, + ) + await selectCustomProviderCredential(context.consoleClient, provider, credential?.credential_id) } catch (error) { const message = error instanceof Error ? error.message : String(error) if (!message.includes(`Credential with name '${stableModelCredentialName}' already exists.`)) return blocked(title, message) - const refreshed = await resolveProvider(config) + const refreshed = await resolveProvider(context.consoleClient, config) if (!refreshed.provider || !refreshed.credential) { return blocked( title, @@ -295,17 +268,22 @@ const seedModel = async ( try { await upsertStableProviderCredential( + context.consoleClient, refreshed.provider, credentials.value, refreshed.credential.credential_id, ) - await selectCustomProviderCredential(refreshed.provider, refreshed.credential.credential_id) + await selectCustomProviderCredential( + context.consoleClient, + refreshed.provider, + refreshed.credential.credential_id, + ) } catch (retryError) { return blocked(title, retryError instanceof Error ? retryError.message : String(retryError)) } } - const seeded = await findModel(config, title) + const seeded = await findModel(context.consoleClient, config) if (seeded?.status !== activeModelStatus) { return blocked( title, @@ -332,47 +310,13 @@ const seedAgentDecisionModel = async (context: SeedContext) => title: agentBuilderPreseededResources.agentDecisionChatModel, }) -const getDefaultModel = async (modelType: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/workspaces/current/default-model?${buildQuery({ model_type: modelType })}`, - ) - await expectApiResponseOK(response, `Get default ${modelType} model`) - const body = (await response.json()) as DefaultModelDataResponse - return body.data - } finally { - await ctx.dispose() - } -} - -const setDefaultModel = async (model: StableModel) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/default-model', { - data: { - model_settings: [ - { - model: model.name, - model_type: model.type, - provider: model.provider, - }, - ], - }, - }) - await expectApiResponseOK(response, `Set default ${model.type} model`) - } finally { - await ctx.dispose() - } -} - const seedSpeechToTextModel = async (context: SeedContext) => { const config = speechToTextModelConfig() const title = agentBuilderPreseededResources.speechToTextModel const modelResult = await seedModel(context, { config, title }) if (modelResult.status === 'blocked' || modelResult.status === 'skipped') return modelResult - const model = await findModel(config, title) + const model = await findModel(context.consoleClient, config) if (!model || model.status !== activeModelStatus) return blocked(title, `${config.provider}/${config.name} is not active after model setup.`) @@ -381,7 +325,10 @@ const seedSpeechToTextModel = async (context: SeedContext) => { kind: 'model', name: title, } - const defaultModel = await getDefaultModel(config.type) + const defaultModelResponse = await context.consoleClient.workspaces.current.defaultModel.get({ + query: { model_type: config.type }, + }) + const defaultModel = defaultModelResponse.data const isExpectedDefault = defaultModel?.model === model.name && matchesProvider(defaultModel.provider.provider, model.provider) @@ -395,13 +342,23 @@ const seedSpeechToTextModel = async (context: SeedContext) => { `Would set ${model.provider}/${model.name} as the workspace default Speech-to-Text model.`, ) - await setDefaultModel({ - name: model.name, - provider: model.provider, - type: config.type, + await context.consoleClient.workspaces.current.defaultModel.post({ + body: { + model_settings: [ + { + model: model.name, + model_type: config.type, + provider: model.provider, + }, + ], + }, }) - const updatedDefaultModel = await getDefaultModel(config.type) + const updatedDefaultModelResponse = + await context.consoleClient.workspaces.current.defaultModel.get({ + query: { model_type: config.type }, + }) + const updatedDefaultModel = updatedDefaultModelResponse.data if ( updatedDefaultModel?.model !== model.name || !matchesProvider(updatedDefaultModel.provider.provider, model.provider) @@ -415,103 +372,48 @@ const seedSpeechToTextModel = async (context: SeedContext) => { return updated(title, resource) } -type BuiltinToolProvider = { - label?: { en_US?: string; zh_Hans?: string } - name: string - tools: Array<{ - label?: { en_US?: string; zh_Hans?: string } - name: string - }> -} - -const findBuiltinTool = async (displayName: string) => { +const findBuiltinTool = async (client: SeedContext['consoleClient'], displayName: string) => { const parsed = splitToolDisplayName(displayName) if (!parsed.ok) return { ok: false as const, reason: parsed.reason } - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get('/console/api/workspaces/current/tools/builtin') - await expectApiResponseOK(response, `Check built-in tool ${displayName}`) - const providers = (await response.json()) as BuiltinToolProvider[] - const provider = providers.find((item) => - matchesNameOrLabel(parsed.providerName, item.name, item.label), - ) - const tool = provider?.tools.find((item) => - matchesNameOrLabel(parsed.toolName, item.name, item.label), - ) + const providers = await client.workspaces.current.tools.builtin.get() + const provider = providers.find((item) => + matchesNameOrLabel(parsed.providerName, item.name, item.label), + ) + const tool = provider?.tools?.find((item) => + matchesNameOrLabel(parsed.toolName, item.name, item.label), + ) - if (!provider || !tool) - return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } + if (!provider || !tool) + return { ok: false as const, reason: `Built-in tool "${displayName}" was not found.` } - return { - ok: true as const, - resource: { - id: `${provider.name}/${tool.name}`, - kind: 'tool', - name: displayName, - providerName: provider.name, - toolName: tool.name, - } satisfies ToolResource, - } - } finally { - await ctx.dispose() + return { + ok: true as const, + resource: { + id: `${provider.name}/${tool.name}`, + kind: 'tool', + name: displayName, + providerName: provider.name, + toolName: tool.name, + } satisfies ToolResource, } } const seedTool = (displayName: string): SeedTask => ({ id: `tool:${displayName}`, title: displayName, - async run() { - const result = await findBuiltinTool(displayName) + async run(context) { + const result = await findBuiltinTool(context.consoleClient, displayName) if (!result.ok) return blocked(displayName, result.reason) return verified(displayName, result.resource) }, }) -const uploadConsoleFile = async ( - fileName: string, - filePath: string, -): Promise => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/files/upload', { - multipart: { - file: { - buffer: await readFile(filePath), - mimeType: 'text/plain', - name: fileName, - }, - }, - }) - await expectApiResponseOK(response, `Upload seed file ${fileName}`) - return (await response.json()) as UploadedConsoleFile - } finally { - await ctx.dispose() - } -} - -const findDataset = (name: string) => { - const query = buildQuery({ keyword: name, limit: '20', page: '1' }) - return findConsoleResourceByName({ - action: `Find seed dataset ${name}`, - path: `/console/api/datasets?${query}`, - resourceName: name, - }) -} - -const getDatasetDocuments = async (datasetId: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents?${buildQuery({ limit: '100', page: '1' })}`, - ) - await expectApiResponseOK(response, `List dataset documents ${datasetId}`) - const body = (await response.json()) as DocumentWithSegmentsListResponse - return body.data - } finally { - await ctx.dispose() - } +const findDataset = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.datasets.get({ query: { keyword: name, limit: 20, page: 1 } }) + const dataset = body.data.find((dataset) => dataset.name === name) + return dataset ? { id: dataset.id, name: dataset.name } : undefined } const requiredKnowledgeSegmentTokens = [ @@ -520,62 +422,54 @@ const requiredKnowledgeSegmentTokens = [ agentBuilderExpectedTokens.knowledgeReply, ] -const datasetHasKnowledgeSegment = async (datasetId: string) => { - const documents = await getDatasetDocuments(datasetId) - const ctx = await createConsoleApiContext() - try { - for (const document of documents) { - const response = await ctx.get( - `/console/api/datasets/${datasetId}/documents/${document.id}/segments?${buildQuery({ - enabled: 'true', - keyword: agentBuilderExpectedTokens.knowledgeReply, - limit: '20', - page: '1', - })}`, +const datasetHasKnowledgeSegment = async ( + client: SeedContext['consoleClient'], + datasetId: string, +) => { + const documents = await client.datasets.byDatasetId.documents.get({ + params: { dataset_id: datasetId }, + query: { limit: '100', page: '1' }, + }) + for (const document of documents.data) { + const body = await client.datasets.byDatasetId.documents.byDocumentId.segments.get({ + params: { dataset_id: datasetId, document_id: document.id }, + query: { + enabled: 'true', + keyword: agentBuilderExpectedTokens.knowledgeReply, + limit: 20, + page: 1, + }, + }) + if ( + body.data.some( + (segment) => + segment.enabled && + requiredKnowledgeSegmentTokens.every((token) => segment.content.includes(token)), ) - await expectApiResponseOK( - response, - `Check dataset knowledge segment ${agentBuilderExpectedTokens.knowledgeReply}`, - ) - const body = (await response.json()) as ConsoleSegmentListResponse - if ( - body.data.some( - (segment) => - segment.enabled && - requiredKnowledgeSegmentTokens.every((token) => segment.content.includes(token)), - ) - ) { - return true - } + ) { + return true } - - return false - } finally { - await ctx.dispose() } + + return false } -const waitForDatasetCompleted = async (datasetId: string) => { +const waitForDatasetCompleted = async (client: SeedContext['consoleClient'], datasetId: string) => { const deadline = Date.now() + 180_000 let status = 'missing' while (Date.now() < deadline) { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/datasets/${datasetId}/indexing-status`) - await expectApiResponseOK(response, `Check dataset indexing ${datasetId}`) - const body = (await response.json()) as DocumentStatusListResponse - status = - body.data.length < 1 - ? 'missing' - : body.data.every((item) => item.indexing_status === 'completed') - ? 'completed' - : body.data.map((item) => item.indexing_status ?? 'missing').join(',') + const body = await client.datasets.byDatasetId.indexingStatus.get({ + params: { dataset_id: datasetId }, + }) + status = + body.data.length < 1 + ? 'missing' + : body.data.every((item) => item.indexing_status === 'completed') + ? 'completed' + : body.data.map((item) => item.indexing_status ?? 'missing').join(',') - if (status === 'completed') return { ok: true as const } - } finally { - await ctx.dispose() - } + if (status === 'completed') return { ok: true as const } await sleep(1_000) } @@ -583,11 +477,17 @@ const waitForDatasetCompleted = async (datasetId: string) => { return { ok: false as const, status } } -const addKnowledgeDocument = async (datasetId: string) => { - const uploadedFile = await uploadConsoleFile( - agentBuilderTestMaterials.knowledgeSource, - getAgentBuilderTestMaterialPath('knowledgeSource'), - ) +const addKnowledgeDocument = async (client: SeedContext['consoleClient'], datasetId: string) => { + const fileName = agentBuilderTestMaterials.knowledgeSource + const uploadedFile = await client.files.upload.post({ + body: { + file: new File( + [Uint8Array.from(await readFile(getAgentBuilderTestMaterialPath('knowledgeSource')))], + fileName, + { type: 'text/plain' }, + ), + }, + }) const body = { data_source: { info_list: { @@ -611,36 +511,15 @@ const addKnowledgeDocument = async (datasetId: string) => { }, } satisfies KnowledgeConfig - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post(`/console/api/datasets/${datasetId}/documents`, { data: body }) - await expectApiResponseOK(response, `Seed knowledge document ${datasetId}`) - } finally { - await ctx.dispose() - } -} - -const createDataset = async (name: string) => { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/datasets', { - data: { - indexing_technique: 'economy', - name, - permission: 'only_me', - provider: 'vendor', - }, - }) - await expectApiResponseOK(response, `Create dataset ${name}`) - return (await response.json()) as DatasetListItemResponse - } finally { - await ctx.dispose() - } + await client.datasets.byDatasetId.documents.post({ + body, + params: { dataset_id: datasetId }, + }) } const seedReadyKnowledge = async (context: SeedContext) => { const title = agentBuilderPreseededResources.agentKnowledgeBase - let dataset = await findDataset(title) + let dataset = await findDataset(context.consoleClient, title) if (context.dryRun) { return dataset @@ -649,12 +528,22 @@ const seedReadyKnowledge = async (context: SeedContext) => { } const wasCreated = !dataset - dataset ??= await createDataset(title) + if (!dataset) { + const createdDataset = await context.consoleClient.datasets.post({ + body: { + indexing_technique: 'economy', + name: title, + permission: 'only_me', + provider: 'vendor', + }, + }) + dataset = { id: createdDataset.id, name: createdDataset.name } + } - const hasKnowledgeSegment = await datasetHasKnowledgeSegment(dataset.id) - if (!hasKnowledgeSegment) await addKnowledgeDocument(dataset.id) + const hasKnowledgeSegment = await datasetHasKnowledgeSegment(context.consoleClient, dataset.id) + if (!hasKnowledgeSegment) await addKnowledgeDocument(context.consoleClient, dataset.id) - const indexing = await waitForDatasetCompleted(dataset.id) + const indexing = await waitForDatasetCompleted(context.consoleClient, dataset.id) if (!indexing.ok) { return blocked( title, @@ -662,7 +551,7 @@ const seedReadyKnowledge = async (context: SeedContext) => { ) } - return datasetHasKnowledgeSegment(dataset.id).then((ready) => { + return datasetHasKnowledgeSegment(context.consoleClient, dataset.id).then((ready) => { if (!ready) { return blocked( title, @@ -675,17 +564,13 @@ const seedReadyKnowledge = async (context: SeedContext) => { }) } -const ensureAgent = async (name: string) => { - const query = buildQuery({ limit: '20', name, page: '1' }) - const existing = await findConsoleResourceByName({ - action: `Find seed Agent ${name}`, - path: `/console/api/agent?${query}`, - resourceName: name, - }) +const ensureAgent = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.agent.get({ query: { limit: 20, name, page: 1 } }) + const existing = body.data.find((agent) => agent.name === name) if (existing) return { agent: existing, created: false } - const agent = await createTestAgent({ + const agent = await createTestAgent(client, { description: 'Created by Dify E2E seed.', name, role: 'E2E seeded assistant', @@ -721,24 +606,32 @@ const toolConfig = (tool: ToolResource) => tool_name: tool.toolName, }) satisfies AgentSoulDifyToolConfig -const saveSeededAgentComposer = async ({ - agentId, - config, - shouldPublish = false, -}: { - agentId: string - config: AgentSoulConfig - shouldPublish?: boolean -}) => { - await saveAgentComposerDraft(agentId, config) - if (shouldPublish) await publishAgent(agentId, 'E2E seed') +const saveSeededAgentComposer = async ( + client: SeedContext['consoleClient'], + { + agentId, + config, + shouldPublish = false, + }: { + agentId: string + config: AgentSoulConfig + shouldPublish?: boolean + }, +) => { + await saveAgentComposerDraft(client, agentId, config) + if (shouldPublish) { + await client.agent.byAgentId.publish.post({ + body: { version_note: 'E2E seed' }, + params: { agent_id: agentId }, + }) + } } -const ensureDriveSkill = async (agentId: string) => { - const skills = await getAgentDriveSkills(agentId) +const ensureDriveSkill = async (client: SeedContext['consoleClient'], agentId: string) => { + const skills = await getAgentDriveSkills(client, agentId) if (skills.some((skill) => skill.name === agentBuilderPreseededResources.summarySkill)) return - await uploadAgentDriveSkill({ + await uploadAgentDriveSkill(client, { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -759,26 +652,26 @@ const seedFullConfigAgent = async (context: SeedContext) => { if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) const agentId = agent.id - const smallFile = await uploadAgentConfigFileToDraft({ + const smallFile = await uploadAgentConfigFileToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.smallFile, filePath: getAgentBuilderTestMaterialPath('smallFile'), }) - const specialFile = await uploadAgentConfigFileToDraft({ + const specialFile = await uploadAgentConfigFileToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.specialFilename, filePath: getAgentBuilderTestMaterialPath('specialFilename'), }) - const summarySkill = await uploadAgentConfigSkillToDraft({ + const summarySkill = await uploadAgentConfigSkillToDraft(context.consoleClient, { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(agentId) + await ensureDriveSkill(context.consoleClient, agentId) - await saveSeededAgentComposer({ + await saveSeededAgentComposer(context.consoleClient, { agentId, config: createAgentSoulConfigWithKnowledgeDataset( createAgentSoulConfigWithModel( @@ -813,14 +706,14 @@ const seedToolStatesAgent = async (context: SeedContext) => { return blocked(title, `${agentBuilderPreseededResources.tavilySearchTool} is not ready.`) if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) - const summarySkill = await uploadAgentConfigSkillToDraft({ + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) + const summarySkill = await uploadAgentConfigSkillToDraft(context.consoleClient, { agentId: agent.id, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), }) - await ensureDriveSkill(agent.id) - await saveSeededAgentComposer({ + await ensureDriveSkill(context.consoleClient, agent.id) + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: { ...normalAgentSoulConfig, @@ -842,9 +735,9 @@ const seedDualRetrievalAgent = async (context: SeedContext) => { return blocked(title, `${agentBuilderPreseededResources.agentKnowledgeBase} is not ready.`) if (context.dryRun) return skipped(title, `Would create or update Agent "${title}".`) - const { agent, created: wasCreated } = await ensureAgent(title) + const { agent, created: wasCreated } = await ensureAgent(context.consoleClient, title) const datasetConfig = { id: dataset.id, name: dataset.name } satisfies AgentKnowledgeDatasetConfig - await saveSeededAgentComposer({ + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: { ...normalAgentSoulConfig, @@ -876,13 +769,12 @@ const seedDualRetrievalAgent = async (context: SeedContext) => { return wasCreated ? created(title, resource) : updated(title, resource) } -const findWorkflow = (name: string) => { - const query = buildQuery({ limit: '20', mode: 'workflow', name, page: '1' }) - return findConsoleResourceByName({ - action: `Find seed workflow ${name}`, - path: `/console/api/apps?${query}`, - resourceName: name, +const findWorkflow = async (client: SeedContext['consoleClient'], name: string) => { + const body = await client.apps.get({ + query: { limit: 20, mode: 'workflow', name, page: 1 }, }) + const workflow = body.data.find((workflow) => workflow.name === name) + return workflow ? { id: workflow.id, name: workflow.name } : undefined } const seedWorkflowReference = async (context: SeedContext) => { @@ -894,21 +786,25 @@ const seedWorkflowReference = async (context: SeedContext) => { if (context.dryRun) return skipped(title, `Would create or update Agent "${title}" and workflow "${workflowName}".`) - const { agent, created: wasAgentCreated } = await ensureAgent(title) - await saveSeededAgentComposer({ + const { agent, created: wasAgentCreated } = await ensureAgent(context.consoleClient, title) + await saveSeededAgentComposer(context.consoleClient, { agentId: agent.id, config: createAgentSoulConfigWithModel(normalAgentSoulConfig, model), shouldPublish: true, }) - let workflow = await findWorkflow(workflowName) + let workflow = await findWorkflow(context.consoleClient, workflowName) let wasWorkflowCreated = false if (!workflow) { - workflow = await createTestApp(workflowName, 'workflow') + const createdWorkflow = await createTestApp(context.consoleClient, workflowName, 'workflow') + workflow = { id: createdWorkflow.id, name: createdWorkflow.name } wasWorkflowCreated = true } - await syncAgentV2WorkflowDraft(workflow.id, agent.id) - await publishWorkflowApp(workflow.id) + await syncAgentV2WorkflowDraft(context.consoleClient, workflow.id, agent.id) + await context.consoleClient.apps.byAppId.workflows.publish.post({ + body: {}, + params: { app_id: workflow.id }, + }) const resource = { id: workflow.id, kind: 'workflow', name: workflowName } return wasAgentCreated || wasWorkflowCreated diff --git a/e2e/features/agent-v2/support/workflow.ts b/e2e/features/agent-v2/support/workflow.ts index 54605223b05..19ca155f6d8 100644 --- a/e2e/features/agent-v2/support/workflow.ts +++ b/e2e/features/agent-v2/support/workflow.ts @@ -1,8 +1,6 @@ import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen' -import { zPostAppsByAppIdWorkflowsDraftResponse } from '@dify/contracts/api/console/apps/zod.gen' +import type { ConsoleClient } from '../../../support/api/console-client' import * as z from 'zod' -import { createConsoleApiContext, expectApiResponseOK } from '../../../support/api/console-context' -import { getWorkflowDraft } from '../../../support/api/workflows' const agentV2WorkflowNodeId = 'agent-v2' const zWorkflowGraph = z.object({ @@ -14,8 +12,8 @@ const zWorkflowGraph = z.object({ ), }) -export async function getAgentV2WorkflowNodeData(appId: string) { - const draft = await getWorkflowDraft(appId) +export async function getAgentV2WorkflowNodeData(client: ConsoleClient, appId: string) { + const draft = await client.apps.byAppId.workflows.draft.get({ params: { app_id: appId } }) const graph = zWorkflowGraph.parse(draft.graph) const agentNode = graph.nodes.find((node) => node.id === agentV2WorkflowNodeId) if (!agentNode) @@ -26,47 +24,44 @@ export async function getAgentV2WorkflowNodeData(appId: string) { return agentNode.data ?? {} } -export async function syncAgentV2WorkflowDraft(appId: string, agentId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { +export async function syncAgentV2WorkflowDraft( + client: ConsoleClient, + appId: string, + agentId: string, +): Promise { + const body = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'agent-v2', + type: 'custom', + position: { x: 420, y: 282 }, + data: { id: 'agent-v2', - type: 'custom', - position: { x: 420, y: 282 }, - data: { - id: 'agent-v2', - type: 'agent', - title: 'Agent', - desc: '', - agent_binding: { - binding_type: 'roster_agent', - agent_id: agentId, - }, - agent_node_kind: 'dify_agent', - version: '2', + type: 'agent', + title: 'Agent', + desc: '', + agent_binding: { + binding_type: 'roster_agent', + agent_id: agentId, }, + agent_node_kind: 'dify_agent', + version: '2', }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - } satisfies SyncDraftWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) - await expectApiResponseOK(response, `Sync Agent v2 workflow draft for ${appId}`) - zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) } diff --git a/e2e/features/agent-v2/tools.feature b/e2e/features/agent-v2/tools.feature index c4d3d286ebe..262389b8f69 100644 --- a/e2e/features/agent-v2/tools.feature +++ b/e2e/features/agent-v2/tools.feature @@ -26,13 +26,3 @@ Feature: Agent v2 tools Then the Agent v2 draft should be published and up to date When I send the Agent v2 Backend service API JSON Replace request Then the Agent v2 Backend service API response should include the JSON Replace E2E marker - - @core - Scenario: Tool selector recovers after an unavailable installed-tool search - Given I am signed in as the default E2E admin - And a basic configured Agent v2 test agent has been created via API - When I open the Agent v2 configure page - And I search for the missing Agent v2 tool from the Tools selector - Then I should see the unavailable Agent v2 installed-tool search applied - When I clear the Agent v2 tool selector search - Then I should see the Agent v2 tool selector ready for another search diff --git a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts index 32a58440429..2c9999e9e11 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts @@ -1,11 +1,7 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { - createAgentApiKey, - sendAgentServiceApiChatMessage, - setAgentApiAccess, -} from '../../agent-v2/support/access-point' +import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -15,8 +11,12 @@ import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers' async function enableAgentApiAccessWithKey(world: DifyWorld) { const agentId = getCurrentAgentId(world) - const apiAccess = await setAgentApiAccess(agentId, true) - const apiKey = await createAgentApiKey(agentId) + const client = world.getConsoleClient() + const apiAccess = await client.agent.byAgentId.apiEnable.post({ + body: { enable_api: true }, + params: { agent_id: agentId }, + }) + const apiKey = await client.agent.byAgentId.apiKeys.post({ params: { agent_id: agentId } }) world.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url world.agentBuilder.accessPoint.generatedApiKey = apiKey.token diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index dabe1269416..9182bc7ad5a 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -2,7 +2,6 @@ import type { Page } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens } from '../../agent-v2/support/agent-builder-resources' import { getCurrentAgentId, getDialog, getWebAppCard } from './access-point-helpers' @@ -11,7 +10,10 @@ const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000 const getWebAppMessageInput = (webAppPage: Page) => webAppPage.getByPlaceholder(/^Talk to /).last() const recordComposerDraftSnapshot = async (world: DifyWorld) => { - const draft = await getAgentComposerDraft(getCurrentAgentId(world)) + const agentId = getCurrentAgentId(world) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) world.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {}) } @@ -170,7 +172,10 @@ Then( const snapshot = this.agentBuilder.accessPoint.composerDraftSnapshot if (!snapshot) throw new Error('No Agent v2 orchestration draft snapshot was recorded.') - const draft = await getAgentComposerDraft(getCurrentAgentId(this)) + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) expect(JSON.stringify(draft.agent_soul ?? {})).toBe(snapshot) }, diff --git a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts index 5fdb3d72577..472bad694d2 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-workflow.steps.ts @@ -14,7 +14,7 @@ Then( agentBuilderPreseededResources.workflowReferenceAgent, 'agent', ) - const references = await getAgentReferencingWorkflows(agent.id) + const references = await getAgentReferencingWorkflows(this.getConsoleClient(), agent.id) const reference = references.find( (item) => item.app_id === workflow.id || item.app_name === workflow.name, ) diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index e3ba96d1635..79b0f06aa40 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -2,7 +2,7 @@ import type { DifyWorld } from '../../support/world' import type { AccessSurfaceName } from './access-point-helpers' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { setAgentApiAccess, setAgentSiteAccess } from '../../agent-v2/support/access-point' +import { enableAgentWebApp } from '../../agent-v2/support/access-point' import { publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { getAccessRegion, @@ -12,18 +12,25 @@ import { } from './access-point-helpers' Given('the Agent v2 draft has been published via API', async function (this: DifyWorld) { - await publishAgentWithPublishableDraft(getCurrentAgentId(this)) + await publishAgentWithPublishableDraft(this.getConsoleClient(), getCurrentAgentId(this)) }) Given( /^Agent v2 (Web app|Backend service API) access has been enabled via API$/, async function (this: DifyWorld, surface: AccessSurfaceName) { if (surface === 'Web app') { - await setAgentSiteAccess(getCurrentAgentId(this), true) + this.agentBuilder.accessPoint.webAppURL = await enableAgentWebApp( + this.getConsoleClient(), + getCurrentAgentId(this), + ) return } - const apiAccess = await setAgentApiAccess(getCurrentAgentId(this), true) + const agentId = getCurrentAgentId(this) + const apiAccess = await this.getConsoleClient().agent.byAgentId.apiEnable.post({ + body: { enable_api: true }, + params: { agent_id: agentId }, + }) this.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url }, ) diff --git a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts index a462bb830f7..bce34d4ff54 100644 --- a/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-edit.steps.ts @@ -1,9 +1,8 @@ -import type { PostAgentByAgentIdCopyResponse } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' +import { zPostAgentByAgentIdCopyResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' -import { getAgentComposerDraft, getTestAgent, publishAgent } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens, agentBuilderFixedInputs, @@ -19,8 +18,10 @@ import { openAgentKnowledgeRetrievalDialog, } from './configure-helpers' -const getComposerInheritanceSnapshot = async (agentId: string) => { - const draft = await getAgentComposerDraft(agentId) +const getComposerInheritanceSnapshot = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) const soul = draft.agent_soul ?? {} const model = asRecord(soul.model) const prompt = asRecord(soul.prompt) @@ -68,7 +69,11 @@ const getComposerInheritanceSnapshot = async (agentId: string) => { Given( 'the preseeded Agent v2 {string} has been published via API', async function (this: DifyWorld, agentName: string) { - await publishAgent(getPreseededAgent(this, agentName).id) + const agentId = getPreseededAgent(this, agentName).id + await this.getConsoleClient().agent.byAgentId.publish.post({ + body: { version_note: 'E2E publish' }, + params: { agent_id: agentId }, + }) }, ) @@ -100,7 +105,7 @@ When( const copyResponse = await copyResponsePromise expect(copyResponse.status()).toBe(201) - const copiedAgent = (await copyResponse.json()) as PostAgentByAgentIdCopyResponse + const copiedAgent = zPostAgentByAgentIdCopyResponse.parse(await copyResponse.json()) if (!copiedAgent.id) throw new Error('Agent v2 duplicate response did not include a copied Agent ID.') @@ -183,11 +188,12 @@ Then( 'Stable chat model fixture setup must run before asserting the duplicated Agent.', ) + const client = this.getConsoleClient() const [sourceDetail, duplicatedDetail, sourceSnapshot, duplicatedSnapshot] = await Promise.all([ - getTestAgent(sourceAgent.id), - getTestAgent(duplicatedAgentId), - getComposerInheritanceSnapshot(sourceAgent.id), - getComposerInheritanceSnapshot(duplicatedAgentId), + client.agent.byAgentId.get({ params: { agent_id: sourceAgent.id } }), + client.agent.byAgentId.get({ params: { agent_id: duplicatedAgentId } }), + getComposerInheritanceSnapshot(this, sourceAgent.id), + getComposerInheritanceSnapshot(this, duplicatedAgentId), ]) expect(duplicatedDetail.id).toBe(duplicatedAgentId) @@ -226,7 +232,9 @@ Then( await expect .poll( async () => { - const draft = await getAgentComposerDraft(sourceAgent.id) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: sourceAgent.id }, + }) return asString(asRecord(draft.agent_soul?.prompt).system_prompt) }, diff --git a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts index 25c0cb69973..228a54eaa99 100644 --- a/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts +++ b/e2e/features/step-definitions/agent-v2/agent-roster.steps.ts @@ -1,6 +1,6 @@ -import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zPostAgentResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' import { createE2EResourceName } from '../../../support/naming' @@ -30,7 +30,7 @@ When('I create an Agent v2 test agent from the Agent Roster', async function (th const createResponse = await createResponsePromise expect(createResponse.ok()).toBe(true) - const createdAgent = (await createResponse.json()) as AgentAppDetailWithSite + const createdAgent = zPostAgentResponse.parse(await createResponse.json()) this.createdAgentIds.push(createdAgent.id) this.lastCreatedAgentName = createdAgent.name this.lastCreatedAgentRole = createdAgent.role ?? undefined diff --git a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts index ff2e1c62e40..4e07595de4f 100644 --- a/e2e/features/step-definitions/agent-v2/build-draft.steps.ts +++ b/e2e/features/step-definitions/agent-v2/build-draft.steps.ts @@ -1,12 +1,12 @@ +import type { AgentBuildDraftResponse } from '@dify/contracts/api/console/agent/types.gen' import type { Page, Response } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { readFile } from 'node:fs/promises' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft, saveAgentComposerDraft } from '../../agent-v2/support/agent' +import { saveAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuildDraftExists, - getAgentBuildDraft, saveAgentBuildDraft, } from '../../agent-v2/support/agent-build-draft' import { @@ -47,8 +47,7 @@ const getBuildNoteFileButton = (page: Page) => .filter({ hasText: BUILD_NOTE_FILE_NAME }) .filter({ hasText: BUILD_NOTE_GENERATED_BADGE }) -const getConfigNote = (value: Awaited>) => - value.agent_soul?.config_note ?? '' +const getConfigNote = (value: AgentBuildDraftResponse) => value.agent_soul?.config_note ?? '' const getLastBuildChatAnswerText = async (page: Page) => { const answer = page.getByTestId('chat-answer-container').last() @@ -65,7 +64,8 @@ const saveSupportedBuildDraft = async ( { retainSkillInNormalDraft }: { retainSkillInNormalDraft: boolean }, ) => { const agentId = getCurrentAgentId(world) - const configFile = await uploadAgentConfigFileToDraft({ + const client = world.getConsoleClient() + const configFile = await uploadAgentConfigFileToDraft(client, { agentId, fileName: agentBuilderTestMaterials.smallFile, filePath: getAgentBuilderTestMaterialPath('smallFile'), @@ -85,11 +85,11 @@ const saveSupportedBuildDraft = async ( : updatedAgentSoulConfig const configSkills = [skill] - await saveAgentComposerDraft(agentId, { + await saveAgentComposerDraft(client, agentId, { ...normalConfig, ...(retainSkillInNormalDraft ? { config_skills: configSkills } : {}), }) - await saveAgentBuildDraft(agentId, { + await saveAgentBuildDraft(client, agentId, { ...updatedConfig, config_files: [configFile], config_skills: configSkills, @@ -123,7 +123,11 @@ Given( ) Given('an Agent v2 Build draft uses the updated E2E prompt', async function (this: DifyWorld) { - await saveAgentBuildDraft(getCurrentAgentId(this), updatedAgentSoulConfig) + await saveAgentBuildDraft( + this.getConsoleClient(), + getCurrentAgentId(this), + updatedAgentSoulConfig, + ) }) Given( @@ -135,6 +139,7 @@ Given( ) await saveAgentBuildDraft( + this.getConsoleClient(), getCurrentAgentId(this), createAgentSoulConfigWithModel( updatedAgentSoulConfig, @@ -287,9 +292,16 @@ Then( async function (this: DifyWorld) { try { await expect - .poll(async () => getConfigNote(await getAgentBuildDraft(getCurrentAgentId(this))), { - timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.buildDraft.get({ + params: { agent_id: agentId }, + }) + return getConfigNote(draft) + }, + { timeout: BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS }, + ) .toContain(BUILD_NOTE_MARKER) } catch (error) { const lastAnswerText = await getLastBuildChatAnswerText(this.getPage()) @@ -317,7 +329,9 @@ Then( Then('the Agent v2 Build draft should not be checked out', async function (this: DifyWorld) { await expect - .poll(async () => agentBuildDraftExists(getCurrentAgentId(this)), { timeout: 30_000 }) + .poll(async () => agentBuildDraftExists(this.getConsoleClient(), getCurrentAgentId(this)), { + timeout: 30_000, + }) .toBe(false) }) @@ -371,8 +385,13 @@ Then( async function (this: DifyWorld) { await expect .poll( - async () => - (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.config_note ?? '', + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + return draft.agent_soul?.config_note ?? '' + }, { timeout: 30_000 }, ) .not.toContain(BUILD_NOTE_MARKER) @@ -385,7 +404,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul const variables = agentSoul?.env?.variables ?? [] return { @@ -412,7 +435,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul const variables = agentSoul?.env?.variables ?? [] return { @@ -439,7 +466,11 @@ Then( await expect .poll( async () => { - const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const agentSoul = draft.agent_soul return ( agentSoul?.config_skills?.filter( (skill) => skill.name === agentBuilderPreseededResources.summarySkill, diff --git a/e2e/features/step-definitions/agent-v2/configure-helpers.ts b/e2e/features/step-definitions/agent-v2/configure-helpers.ts index 8ae2917b759..da76a65d44c 100644 --- a/e2e/features/step-definitions/agent-v2/configure-helpers.ts +++ b/e2e/features/step-definitions/agent-v2/configure-helpers.ts @@ -1,8 +1,8 @@ import type { Locator } from '@playwright/test' import type { AgentComposerEnvVariable } from '../../agent-v2/support/agent-soul' import type { DifyWorld } from '../../support/world' +import { zPostAgentByAgentIdConfigFilesResponse } from '@dify/contracts/api/console/agent/zod.gen' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { uploadAgentConfigSkillToDraft } from '../../agent-v2/support/agent-drive' import { normalAgentPrompt } from '../../agent-v2/support/agent-soul' import { @@ -42,8 +42,12 @@ export const getEnvVariableKey = (variable: AgentComposerEnvVariable) => export const getAgentEnvVariableValue = (variables: AgentComposerEnvVariable[], key: string) => variables.find((variable) => getEnvVariableKey(variable) === key)?.value -export const getAgentEnvVariables = async (agentId: string) => - (await getAgentComposerDraft(agentId)).agent_soul?.env?.variables ?? [] +export const getAgentEnvVariables = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.env?.variables ?? [] +} export const uploadAgentConfigFile = async ( world: DifyWorld, @@ -71,7 +75,7 @@ export const uploadAgentConfigFile = async ( await dialog.getByRole('button', { name: 'Upload' }).click() const commitResponse = await commitResponsePromise expect(commitResponse.status()).toBe(201) - const committed = (await commitResponse.json()) as { file?: { name?: string } } + const committed = zPostAgentByAgentIdConfigFilesResponse.parse(await commitResponse.json()) await expect(dialog).not.toBeVisible({ timeout: 30_000 }) const committedName = committed.file?.name @@ -118,9 +122,10 @@ export const expectAgentConfigFileSaved = async ( await expect .poll( async () => { - const file = (await getAgentComposerDraft(agentId)).agent_soul?.config_files?.find( - (file) => file.name === fileName, - ) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + const file = draft.agent_soul?.config_files?.find((file) => file.name === fileName) return file ? { @@ -145,7 +150,7 @@ export const expectAgentModelRequiredFeedback = async (page: ReturnType { const agentId = getCurrentAgentId(world) - const skill = await uploadAgentConfigSkillToDraft({ + const skill = await uploadAgentConfigSkillToDraft(world.getConsoleClient(), { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -241,9 +246,16 @@ export const expectAgentEnvVariableHidden = async (world: DifyWorld, key: string export const expectNormalAgentPromptDraft = async (world: DifyWorld) => { await expect - .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(world))).agent_soul?.prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(world) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.prompt + }, + { timeout: 30_000 }, + ) .toEqual({ system_prompt: normalAgentPrompt }) } diff --git a/e2e/features/step-definitions/agent-v2/configure.steps.ts b/e2e/features/step-definitions/agent-v2/configure.steps.ts index 89595a852f7..b4ada8ef204 100644 --- a/e2e/features/step-definitions/agent-v2/configure.steps.ts +++ b/e2e/features/step-definitions/agent-v2/configure.steps.ts @@ -6,7 +6,6 @@ import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure import { createConfiguredTestAgent, createTestAgent, - getAgentComposerDraft, getAgentConfigurePath, saveAgentComposerDraft, } from '../../agent-v2/support/agent' @@ -49,16 +48,22 @@ async function selectAgentModel(page: Page, modelName: string) { await page.getByRole('option', { name: new RegExp(`${escapedModelName}(?:\\s|$)`) }).click() } -async function expectAgentComposerPrompt(agentId: string, prompt: string) { +async function expectAgentComposerPrompt(world: DifyWorld, agentId: string, prompt: string) { await expect - .poll(async () => (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) + return draft.agent_soul?.prompt?.system_prompt + }, + { timeout: 30_000 }, + ) .toBe(prompt) } Given('an Agent v2 test agent has been created via API', async function (this: DifyWorld) { - const agent = await createTestAgent() + const agent = await createTestAgent(this.getConsoleClient()) this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined @@ -67,7 +72,7 @@ Given('an Agent v2 test agent has been created via API', async function (this: D Given( 'a basic configured Agent v2 test agent has been created via API', async function (this: DifyWorld) { - const agent = await createConfiguredTestAgent() + const agent = await createConfiguredTestAgent(this.getConsoleClient()) this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined @@ -78,7 +83,7 @@ Given('a runnable Agent v2 test agent has been created via API', async function if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create a runnable Agent v2 test agent after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.stableModel, @@ -98,7 +103,7 @@ Given( ) } - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.agentDecisionModel, @@ -113,15 +118,20 @@ Given( Given('a minimal Agent v2 composer draft has been synced', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - await saveAgentComposerDraft(agentId) + await saveAgentComposerDraft(this.getConsoleClient(), agentId) }) Given('the Agent v2 composer draft uses the normal E2E prompt', async function (this: DifyWorld) { - await saveAgentComposerDraft(getCurrentAgentId(this), normalAgentSoulConfig) + await saveAgentComposerDraft( + this.getConsoleClient(), + getCurrentAgentId(this), + normalAgentSoulConfig, + ) }) Given('the Agent v2 composer draft is publishable', async function (this: DifyWorld) { await saveAgentComposerDraft( + this.getConsoleClient(), getCurrentAgentId(this), createPublishableAgentSoulConfig(normalAgentSoulConfig), ) @@ -131,7 +141,7 @@ Given( 'the e2e-summary-skill Skill is available to the Agent v2 test agent', async function (this: DifyWorld) { const agentId = getCurrentAgentId(this) - const upload = await uploadAgentDriveSkill({ + const upload = await uploadAgentDriveSkill(this.getConsoleClient(), { agentId, fileName: agentBuilderTestMaterials.summarySkill, filePath: getAgentBuilderTestMaterialPath('summarySkill'), @@ -145,7 +155,7 @@ Given( Then( 'the Agent v2 test agent should include drive skill {string}', async function (this: DifyWorld, skillName: string) { - const skills = await getAgentDriveSkills(getCurrentAgentId(this)) + const skills = await getAgentDriveSkills(this.getConsoleClient(), getCurrentAgentId(this)) expect(skills.map((skill) => skill.name)).toContain(skillName) }, @@ -257,7 +267,7 @@ When('I save the Agent v2 prompt from the first configure tab', async function ( await fillAgentPromptEditor(this.getPage(), concurrentFirstAgentPrompt) await waitForAgentConfigureAutosaved(this.getPage()) - await expectAgentComposerPrompt(agentId, concurrentFirstAgentPrompt) + await expectAgentComposerPrompt(this, agentId, concurrentFirstAgentPrompt) }) When('I save the Agent v2 prompt from the second configure tab', async function (this: DifyWorld) { @@ -268,7 +278,7 @@ When('I save the Agent v2 prompt from the second configure tab', async function await fillAgentPromptEditor(concurrentPage, concurrentSecondAgentPrompt) await waitForAgentConfigureAutosaved(concurrentPage) - await expectAgentComposerPrompt(agentId, concurrentSecondAgentPrompt) + await expectAgentComposerPrompt(this, agentId, concurrentSecondAgentPrompt) }) When('I refresh both Agent v2 configure tabs', async function (this: DifyWorld) { @@ -360,7 +370,10 @@ Then( await expect .poll( async () => { - const prompt = (await getAgentComposerDraft(agentId)).agent_soul?.prompt?.system_prompt + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const prompt = draft.agent_soul?.prompt?.system_prompt if (prompt && concurrentAgentPrompts.includes(prompt)) savedPrompt = prompt return !!savedPrompt @@ -392,9 +405,16 @@ Then( 'the normal Agent v2 draft should use the updated E2E prompt', async function (this: DifyWorld) { await expect - .poll(async () => (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.prompt, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + return draft.agent_soul?.prompt + }, + { timeout: 30_000 }, + ) .toEqual({ system_prompt: updatedAgentPrompt }) }, ) @@ -407,7 +427,11 @@ Then('the Agent v2 draft should use the stable E2E model', async function (this: await expect .poll( async () => { - const model = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul?.model + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const model = draft.agent_soul?.model const modelConfig = typeof model === 'object' && model !== null && !Array.isArray(model) ? (model as Record) @@ -438,8 +462,11 @@ Then( await expect .poll( async () => { - const draftModel = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul - ?.model + const agentId = getCurrentAgentId(this) + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const draftModel = draft.agent_soul?.model const modelConfig = typeof draftModel === 'object' && draftModel !== null && !Array.isArray(draftModel) ? (draftModel as Record) diff --git a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts index 38573073441..6713a0acad7 100644 --- a/e2e/features/step-definitions/agent-v2/env-editor.steps.ts +++ b/e2e/features/step-definitions/agent-v2/env-editor.steps.ts @@ -1,7 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { getAgentComposerDraft } from '../../agent-v2/support/agent' import { agentBuilderFixedInputs } from '../../agent-v2/support/agent-builder-resources' import { getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials' import { @@ -97,7 +96,10 @@ Then( await expect .poll( async () => { - const env = (await getAgentComposerDraft(agentId)).agent_soul?.env + const draft = await this.getConsoleClient().agent.byAgentId.composer.get({ + params: { agent_id: agentId }, + }) + const env = draft.agent_soul?.env const variable = env?.variables?.find( (item) => getEnvVariableKey(item) === agentBuilderFixedInputs.envPlainKey, ) @@ -126,7 +128,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -152,7 +154,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -178,7 +180,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { modeValue: getAgentEnvVariableValue(variables, agentBuilderFixedInputs.envModeKey), @@ -211,7 +213,7 @@ Then( await expect .poll( async () => { - const variables = await getAgentEnvVariables(agentId) + const variables = await getAgentEnvVariables(this, agentId) return { importedValue: getAgentEnvVariableValue( diff --git a/e2e/features/step-definitions/agent-v2/fixtures.steps.ts b/e2e/features/step-definitions/agent-v2/fixtures.steps.ts index b6e52d9422d..1fb531ecc7a 100644 --- a/e2e/features/step-definitions/agent-v2/fixtures.steps.ts +++ b/e2e/features/step-definitions/agent-v2/fixtures.steps.ts @@ -18,19 +18,25 @@ import { import { requirePreseededTool } from '../../agent-v2/support/fixtures/tools' Given('the Agent Builder stable chat model is available', async function (this: DifyWorld) { - const stableModel = await requireAgentBuilderStableChatModel(this) + const stableModel = await requireAgentBuilderStableChatModel(this, this.getConsoleClient()) this.agentBuilder.fixtures.stableModel = stableModel }) Given('the workspace default speech-to-text model is active', async function (this: DifyWorld) { - const speechToTextModel = await requireAgentBuilderSpeechToTextModel(this) + const speechToTextModel = await requireAgentBuilderSpeechToTextModel( + this, + this.getConsoleClient(), + ) this.agentBuilder.fixtures.speechToTextModel = speechToTextModel }) Given('the Agent Builder agent-decision chat model is available', async function (this: DifyWorld) { - const agentDecisionModel = await requireAgentBuilderAgentDecisionChatModel(this) + const agentDecisionModel = await requireAgentBuilderAgentDecisionChatModel( + this, + this.getConsoleClient(), + ) this.agentBuilder.fixtures.agentDecisionModel = agentDecisionModel }) @@ -42,7 +48,7 @@ Given('the Agent v2 runtime backend is available', async function (this: DifyWor Given( 'the Agent Builder preseeded Agent {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededAgent(this, resourceName) + const resource = await requirePreseededAgent(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -51,7 +57,7 @@ Given( Given( 'the Agent Builder preseeded workflow {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededWorkflow(this, resourceName) + const resource = await requirePreseededWorkflow(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -60,7 +66,7 @@ Given( Given( 'the Agent Builder preseeded dataset {string} is indexed and ready', async function (this: DifyWorld, resourceName: string) { - const resource = await requireReadyPreseededDataset(this, resourceName) + const resource = await requireReadyPreseededDataset(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -69,7 +75,7 @@ Given( Given( 'the Agent Builder preseeded tool {string} is available', async function (this: DifyWorld, resourceName: string) { - const resource = await requirePreseededTool(this, resourceName) + const resource = await requirePreseededTool(this, this.getConsoleClient(), resourceName) this.agentBuilder.fixtures.preseededResources[resourceName] = resource }, @@ -78,7 +84,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the core fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededFullConfigAgentCoreConfiguration(this, agentName) + const resource = await requirePreseededFullConfigAgentCoreConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[`${agentName} / core fixture configuration`] = resource @@ -88,7 +98,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the tool state fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededToolStatesAgentConfiguration(this, agentName) + const resource = await requirePreseededToolStatesAgentConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[ `${agentName} / tool state fixture configuration` @@ -99,7 +113,11 @@ Given( Given( 'the Agent Builder preseeded Agent {string} includes the dual retrieval fixture configuration', async function (this: DifyWorld, agentName: string) { - const resource = await requirePreseededDualRetrievalAgentConfiguration(this, agentName) + const resource = await requirePreseededDualRetrievalAgentConfiguration( + this, + this.getConsoleClient(), + agentName, + ) this.agentBuilder.fixtures.preseededResources[ `${agentName} / dual retrieval fixture configuration` @@ -110,7 +128,12 @@ Given( Given( 'the Agent Builder preseeded Agent {string} is referenced by workflow {string}', async function (this: DifyWorld, agentName: string, workflowName: string) { - const resource = await requirePreseededAgentWorkflowReference(this, agentName, workflowName) + const resource = await requirePreseededAgentWorkflowReference( + this, + this.getConsoleClient(), + agentName, + workflowName, + ) this.agentBuilder.fixtures.preseededResources[`${agentName} / ${workflowName}`] = resource }, diff --git a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts index 54cf64b1ab9..a409ebbea56 100644 --- a/e2e/features/step-definitions/agent-v2/knowledge.steps.ts +++ b/e2e/features/step-definitions/agent-v2/knowledge.steps.ts @@ -2,7 +2,7 @@ import type { Locator } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { agentBuilderFixedInputs, agentBuilderPreseededResources, @@ -31,8 +31,10 @@ const getPreseededKnowledgeBase = (world: DifyWorld) => { const getKnowledgeSection = (world: DifyWorld) => world.getPage().getByRole('region', { name: 'Knowledge Retrieval' }) -const getKnowledgeSets = async (agentId: string) => { - const draft = await getAgentComposerDraft(agentId) +const getKnowledgeSets = async (world: DifyWorld, agentId: string) => { + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) return asArray(asRecord(draft.agent_soul?.knowledge).sets) } @@ -96,7 +98,7 @@ const expectKnowledgeRetrievalDraft = async ( await expect .poll( async () => { - const knowledgeSets = await getKnowledgeSets(agentId) + const knowledgeSets = await getKnowledgeSets(world, agentId) const knowledgeSet = asRecord(knowledgeSets[0]) const datasets = asArray(knowledgeSet.datasets) const query = asRecord(knowledgeSet.query) @@ -125,7 +127,7 @@ Given( 'a knowledge-backed Agent v2 test agent has been created via API', async function (this: DifyWorld) { const knowledgeBase = getPreseededKnowledgeBase(this) - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithKnowledgeDataset(normalAgentSoulConfig, { id: knowledgeBase.id, name: knowledgeBase.name, @@ -254,7 +256,7 @@ Then( await expect .poll( async () => { - const knowledgeSets = await getKnowledgeSets(agentId) + const knowledgeSets = await getKnowledgeSets(this, agentId) return knowledgeSets.some((set) => asArray(asRecord(set).datasets).some((dataset) => { diff --git a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts index ead1abf1bcd..77917464595 100644 --- a/e2e/features/step-definitions/agent-v2/output-variables.steps.ts +++ b/e2e/features/step-definitions/agent-v2/output-variables.steps.ts @@ -22,12 +22,16 @@ const getCurrentAppId = (world: DifyWorld) => { const parseDeclaredOutputs = (value: unknown): DeclaredOutputConfig[] => z.array(zDeclaredOutputConfig).optional().default([]).parse(value) -const getDeclaredOutputsFromDraft = async (appId: string): Promise => { - const data = await getAgentV2WorkflowNodeData(appId) +const getDeclaredOutputsFromDraft = async ( + world: DifyWorld, + appId: string, +): Promise => { + const data = await getAgentV2WorkflowNodeData(world.getConsoleClient(), appId) return parseDeclaredOutputs(data.agent_declared_outputs) } -const getOutputVariablesFromDraft = async (appId: string) => getDeclaredOutputsFromDraft(appId) +const getOutputVariablesFromDraft = async (world: DifyWorld, appId: string) => + getDeclaredOutputsFromDraft(world, appId) const waitForWorkflowDraftSave = (world: DifyWorld, appId: string) => world @@ -181,7 +185,7 @@ Then( await expect .poll( async () => { - const outputs = await getOutputVariablesFromDraft(appId) + const outputs = await getOutputVariablesFromDraft(this, appId) return expectedOutputVariables.map((expected) => { const output = outputs.find((item) => item.name === expected.name) @@ -223,7 +227,7 @@ Then( await expect .poll( async () => { - const outputs = await getDeclaredOutputsFromDraft(appId) + const outputs = await getDeclaredOutputsFromDraft(this, appId) const response = outputs.find((output) => output.name === 'response') return { @@ -299,7 +303,7 @@ async function expectAgentTaskOutputReference( await expect .poll( async () => { - const data = await getAgentV2WorkflowNodeData(appId) + const data = await getAgentV2WorkflowNodeData(world.getConsoleClient(), appId) const outputs = parseDeclaredOutputs(data.agent_declared_outputs) const expectedOutput = outputs.find((output) => output.name === expectedName) diff --git a/e2e/features/step-definitions/agent-v2/publish.steps.ts b/e2e/features/step-definitions/agent-v2/publish.steps.ts index aaf2a81868f..dcfaaf69e5f 100644 --- a/e2e/features/step-definitions/agent-v2/publish.steps.ts +++ b/e2e/features/step-definitions/agent-v2/publish.steps.ts @@ -2,7 +2,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { waitForAgentConfigureAutosaved } from '../../../support/agent-configure' -import { getTestAgent } from '../../agent-v2/support/agent' import { expectAgentModelRequiredFeedback, getCurrentAgentId } from './configure-helpers' When('I publish the Agent v2 draft', async function (this: DifyWorld) { @@ -30,9 +29,16 @@ Then( Then('the Agent v2 draft should remain unpublished', async function (this: DifyWorld) { await expect - .poll(async () => (await getTestAgent(getCurrentAgentId(this))).active_config_is_published, { - timeout: 30_000, - }) + .poll( + async () => { + const agentId = getCurrentAgentId(this) + const agent = await this.getConsoleClient().agent.byAgentId.get({ + params: { agent_id: agentId }, + }) + return agent.active_config_is_published + }, + { timeout: 30_000 }, + ) .toBe(false) }) @@ -47,7 +53,14 @@ Then('the Agent v2 draft should be published and up to date', async function (th await expect(page.getByRole('button', { name: 'Published' })).toBeVisible({ timeout: 30_000 }) await expect(page.getByRole('status', { name: /^Up to date\./ })).toBeVisible() await expect(page.getByText('Up to date')).toBeVisible() - await expect.poll(async () => (await getTestAgent(agentId)).active_config_is_published).toBe(true) + await expect + .poll(async () => { + const agent = await this.getConsoleClient().agent.byAgentId.get({ + params: { agent_id: agentId }, + }) + return agent.active_config_is_published + }) + .toBe(true) }) Then( diff --git a/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts b/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts index e78769e9623..b0a0d36afbc 100644 --- a/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts +++ b/e2e/features/step-definitions/agent-v2/speech-to-text.steps.ts @@ -27,7 +27,7 @@ Given( ) } - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithSpeechToText(normalAgentSoulConfig), }) this.createdAgentIds.push(agent.id) diff --git a/e2e/features/step-definitions/agent-v2/tools.steps.ts b/e2e/features/step-definitions/agent-v2/tools.steps.ts index bdd5c26e4e5..d1fd0099a0b 100644 --- a/e2e/features/step-definitions/agent-v2/tools.steps.ts +++ b/e2e/features/step-definitions/agent-v2/tools.steps.ts @@ -3,10 +3,9 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { sendAgentServiceApiChatMessage } from '../../agent-v2/support/access-point' -import { createConfiguredTestAgent, getAgentComposerDraft } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { agentBuilderExpectedTokens, - agentBuilderFixedInputs, agentBuilderPreseededResources, } from '../../agent-v2/support/agent-builder-resources' import { @@ -40,7 +39,9 @@ const expectJsonReplaceToolDraft = async (world: DifyWorld) => { await expect .poll( async () => { - const draft = await getAgentComposerDraft(agentId) + const draft = await world + .getConsoleClient() + .agent.byAgentId.composer.get({ params: { agent_id: agentId } }) const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools) return hasToolEntry(tools, tool) @@ -116,7 +117,7 @@ Given( if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create a JSON Replace runtime Agent after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const agent = await createConfiguredTestAgent(this.getConsoleClient(), { agentSoul: createAgentSoulConfigWithDifyTool( createAgentSoulConfigWithModel( { @@ -158,26 +159,6 @@ When( }, ) -When( - 'I search for the missing Agent v2 tool from the Tools selector', - async function (this: DifyWorld) { - const toolsSection = getToolsSection(this) - - await expect(toolsSection).toBeVisible({ timeout: 30_000 }) - await toolsSection.getByRole('button', { name: 'Add tool' }).click() - - const search = getToolSelectorSearch(this) - await expect(search).toBeVisible() - await search.fill(agentBuilderFixedInputs.missingToolSearchWithSuffix) - }, -) - -When('I clear the Agent v2 tool selector search', async function (this: DifyWorld) { - const search = getToolSelectorSearch(this) - - await search.fill('') -}) - Then( 'the Agent v2 JSON Replace tool should be saved in the Agent v2 draft', async function (this: DifyWorld) { @@ -248,26 +229,3 @@ Then( ) }, ) - -Then( - 'I should see the unavailable Agent v2 installed-tool search applied', - async function (this: DifyWorld) { - const page = this.getPage() - const search = getToolSelectorSearch(this) - - await expect(search).toHaveValue(agentBuilderFixedInputs.missingToolSearchWithSuffix) - await expect(page.getByText('All tools', { exact: true })).not.toBeVisible() - }, -) - -Then( - 'I should see the Agent v2 tool selector ready for another search', - async function (this: DifyWorld) { - const page = this.getPage() - const search = getToolSelectorSearch(this) - - await expect(search).toHaveValue('') - await expect(page.getByText('No integrations were found')).not.toBeVisible() - await expect(page.getByText('All tools')).toBeVisible() - }, -) diff --git a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts index 36bb0fa0db2..3a8d63fc65b 100644 --- a/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts +++ b/e2e/features/step-definitions/agent-v2/workflow-node.steps.ts @@ -3,7 +3,7 @@ import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' -import { createConfiguredTestAgent, publishAgent } from '../../agent-v2/support/agent' +import { createConfiguredTestAgent } from '../../agent-v2/support/agent' import { createAgentSoulConfigWithModel, normalAgentPrompt, @@ -17,7 +17,8 @@ Given( if (!this.agentBuilder.fixtures.stableModel) throw new Error('Create an Agent v2 workflow node after stable model fixture setup.') - const agent = await createConfiguredTestAgent({ + const client = this.getConsoleClient() + const agent = await createConfiguredTestAgent(client, { agentSoul: createAgentSoulConfigWithModel( normalAgentSoulConfig, this.agentBuilder.fixtures.stableModel, @@ -26,13 +27,20 @@ Given( this.createdAgentIds.push(agent.id) this.lastCreatedAgentName = agent.name this.lastCreatedAgentRole = agent.role ?? undefined - await publishAgent(agent.id) + await client.agent.byAgentId.publish.post({ + body: { version_note: 'E2E publish' }, + params: { agent_id: agent.id }, + }) - const app = await createTestApp(createE2EResourceName('App', 'workflow-agent-v2'), 'workflow') + const app = await createTestApp( + client, + createE2EResourceName('App', 'workflow-agent-v2'), + 'workflow', + ) this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncAgentV2WorkflowDraft(app.id, agent.id) + await syncAgentV2WorkflowDraft(client, app.id, agent.id) }, ) diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index 3285e63baac..88113afa3fb 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -1,5 +1,6 @@ import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' +import { zPostAppsResponse } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' import { openBlankAppCreation } from '../../../support/apps' import { createE2EResourceName } from '../../../support/naming' @@ -43,7 +44,7 @@ When('I confirm app creation', async function (this: DifyWorld) { const response = await responsePromise expect(response.ok()).toBe(true) - const createdApp = (await response.json()) as { id?: string; mode?: string } + const createdApp = zPostAppsResponse.parse(await response.json()) if (!createdApp.id) throw new Error('Create app response did not include an app ID.') const expectedMode = this.lastSelectedAppType diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts index 989defab4ce..c572efa7ca8 100644 --- a/e2e/features/step-definitions/apps/duplicate-app.steps.ts +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -1,12 +1,13 @@ import type { DifyWorld } from '../../support/world' import { Given, When } from '@cucumber/cucumber' +import { zPostAppsByAppIdCopyResponse } from '@dify/contracts/api/console/apps/zod.gen' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' import { createE2EResourceName } from '../../../support/naming' Given('there is an existing E2E app available for testing', async function (this: DifyWorld) { const name = createE2EResourceName('App', 'Test') - const app = await createTestApp(name, 'completion') + const app = await createTestApp(this.getConsoleClient(), name, 'completion') this.lastCreatedAppName = app.name this.createdAppIds.push(app.id) }) @@ -40,7 +41,7 @@ When('I confirm the app duplication', async function (this: DifyWorld) { await page.getByRole('button', { exact: true, name: 'Duplicate' }).click() const response = await responsePromise expect(response.ok()).toBe(true) - const copiedApp = (await response.json()) as { id?: string } + const copiedApp = zPostAppsByAppIdCopyResponse.parse(await response.json()) if (!copiedApp.id) throw new Error('Duplicate app response did not include an app ID.') expect(copiedApp.id).not.toBe(sourceAppId) this.createdAppIds.push(copiedApp.id) diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index b06d63942b5..93af0e8865d 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -2,17 +2,21 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' -import { enableAppSiteAndGetURL } from '../../../support/api/web-apps' -import { publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api/workflows' +import { getAppSiteURL } from '../../../support/api/web-apps' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' import { createE2EResourceName } from '../../../support/naming' Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { - const app = await createTestApp(createE2EResourceName('App', 'Share'), 'workflow') + const client = this.getConsoleClient() + const app = await createTestApp(client, createE2EResourceName('App', 'Share'), 'workflow') this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncRunnableWorkflowDraft(app.id) - await publishWorkflowApp(app.id) - this.shareURL = await enableAppSiteAndGetURL(app.id) + await syncRunnableWorkflowDraft(client, app.id) + await client.apps.byAppId.workflows.publish.post({ + body: { marked_comment: '', marked_name: '' }, + params: { app_id: app.id }, + }) + this.shareURL = getAppSiteURL(await client.apps.byAppId.get({ params: { app_id: app.id } })) }) When('I open the shared app URL', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts index 0ea4c980f5d..caf5b22e1ae 100644 --- a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts +++ b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts @@ -8,7 +8,7 @@ Given( 'there is an existing E2E completion app available for testing', async function (this: DifyWorld) { const name = createE2EResourceName('App', 'Test') - const app = await createTestApp(name, 'completion') + const app = await createTestApp(this.getConsoleClient(), name, 'completion') this.lastCreatedAppName = app.name this.createdAppIds.push(app.id) }, diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts index dc4baafee42..d7f9d39d2e9 100644 --- a/e2e/features/step-definitions/apps/web-app-service.steps.ts +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -2,19 +2,23 @@ import type { DifyWorld } from '../../support/world' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' import { createTestApp } from '../../../support/api/apps' -import { getAppSiteDetail, getAppSiteURL } from '../../../support/api/web-apps' -import { publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api/workflows' +import { getAppSiteURL } from '../../../support/api/web-apps' +import { syncRunnableWorkflowDraft } from '../../../support/api/workflows' import { createE2EResourceName } from '../../../support/naming' import { baseURL, defaultLocale } from '../../../test-env' Given('a new runnable workflow app has been published', async function (this: DifyWorld) { - const app = await createTestApp(createE2EResourceName('App', 'WebApp'), 'workflow') + const client = this.getConsoleClient() + const app = await createTestApp(client, createE2EResourceName('App', 'WebApp'), 'workflow') this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name - await syncRunnableWorkflowDraft(app.id) - await publishWorkflowApp(app.id) + await syncRunnableWorkflowDraft(client, app.id) + await client.apps.byAppId.workflows.publish.post({ + body: { marked_comment: '', marked_name: '' }, + params: { app_id: app.id }, + }) - const appDetail = await getAppSiteDetail(app.id) + const appDetail = await client.apps.byAppId.get({ params: { app_id: app.id } }) expect(appDetail.enable_site).toBe(true) this.shareURL = getAppSiteURL(appDetail) }) diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts index 76e0aa2d369..e6e7cd4362a 100644 --- a/e2e/features/step-definitions/apps/workflow-run.steps.ts +++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts @@ -7,7 +7,7 @@ Given('a minimal runnable workflow draft has been synced', async function (this: const appId = this.createdAppIds.at(-1) if (!appId) throw new Error('No app ID found. Run "a \\"workflow\\" app has been created via API" first.') - await syncRunnableWorkflowDraft(appId) + await syncRunnableWorkflowDraft(this.getConsoleClient(), appId) }) When('I run the workflow', async function (this: DifyWorld) { diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index fedec7a3944..c647af69f7e 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -9,7 +9,11 @@ import { createE2EResourceName } from '../../../support/naming' Given('a {string} app has been created via API', async function (this: DifyWorld, mode: string) { const appMode = zCreateAppPayload.shape.mode.parse(mode) - const app = await createTestApp(createE2EResourceName('App', appMode), appMode) + const app = await createTestApp( + this.getConsoleClient(), + createE2EResourceName('App', appMode), + appMode, + ) this.createdAppIds.push(app.id) this.lastCreatedAppName = app.name }) @@ -17,7 +21,7 @@ Given('a {string} app has been created via API', async function (this: DifyWorld Given('a minimal workflow draft has been synced', async function (this: DifyWorld) { const appId = this.createdAppIds.at(-1) if (!appId) throw new Error('No app is available for workflow draft setup.') - await syncMinimalWorkflowDraft(appId) + await syncMinimalWorkflowDraft(this.getConsoleClient(), appId) }) When('I open the app from the app list', async function (this: DifyWorld) { diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 9155684a7a3..006277d5649 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -8,18 +8,9 @@ import { fileURLToPath } from 'node:url' import { After, AfterAll, Before, setDefaultTimeout, Status } from '@cucumber/cucumber' import { chromium, webkit } from '@playwright/test' import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth' -import { deleteTestApp } from '../../support/api/apps' -import { deleteTestDataset } from '../../support/api/datasets' -import { deleteBuiltinToolCredential } from '../../support/api/tools' import { runCleanupTasks, shouldFailForCleanupErrors } from '../../support/cleanup' import { getVoiceInputTestMaterialPath } from '../../support/test-materials' import { baseURL, cucumberHeadless, cucumberSlowMo, e2eBrowser } from '../../test-env' -import { deleteTestAgent } from '../agent-v2/support/agent' -import { - deleteAgentConfigFile, - deleteAgentConfigSkill, - deleteAgentDriveFile, -} from '../agent-v2/support/agent-drive' const e2eRoot = fileURLToPath(new URL('../..', import.meta.url)) const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts') @@ -165,31 +156,57 @@ After( const cleanupTasks: CleanupTask[] = [ ...this.createdAgentConfigSkills.toReversed().map((skill) => ({ label: `Delete Agent config skill ${skill.name}`, - run: () => deleteAgentConfigSkill(skill.agentId, skill.name), + run: async () => { + await this.getConsoleClient().agent.byAgentId.config.skills.byName.delete({ + params: { agent_id: skill.agentId, name: skill.name }, + }) + }, })), ...this.createdAgentConfigFiles.toReversed().map((file) => ({ label: `Delete Agent config file ${file.name}`, - run: () => deleteAgentConfigFile(file.agentId, file.name), + run: async () => { + await this.getConsoleClient().agent.byAgentId.config.files.byName.delete({ + params: { agent_id: file.agentId, name: file.name }, + }) + }, })), ...this.createdAgentDriveFiles.toReversed().map((file) => ({ label: `Delete Agent drive file ${file.key}`, - run: () => deleteAgentDriveFile(file.agentId, file.key), + run: async () => { + await this.getConsoleClient().agent.byAgentId.files.delete({ + params: { agent_id: file.agentId }, + query: { key: file.key }, + }) + }, })), ...this.createdAppIds.toReversed().map((id) => ({ label: `Delete app ${id}`, - run: () => deleteTestApp(id), + run: async () => { + await this.getConsoleClient().apps.byAppId.delete({ params: { app_id: id } }) + }, })), ...this.createdAgentIds.toReversed().map((id) => ({ label: `Delete Agent ${id}`, - run: () => deleteTestAgent(id), + run: async () => { + await this.getConsoleClient().agent.byAgentId.delete({ params: { agent_id: id } }) + }, })), ...this.createdDatasetIds.toReversed().map((id) => ({ label: `Delete dataset ${id}`, - run: () => deleteTestDataset(id), + run: async () => { + await this.getConsoleClient().datasets.byDatasetId.delete({ params: { dataset_id: id } }) + }, })), ...this.createdBuiltinToolCredentials.toReversed().map((credential) => ({ label: `Delete builtin tool credential ${credential.provider}/${credential.credentialId}`, - run: () => deleteBuiltinToolCredential(credential.provider, credential.credentialId), + run: async () => { + await this.getConsoleClient().workspaces.current.toolProvider.builtin.byProvider.delete.post( + { + body: { credential_id: credential.credentialId }, + params: { provider: credential.provider }, + }, + ) + }, })), ] diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 53a13f40163..3cbabd84542 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -1,10 +1,13 @@ import type { IWorldOptions } from '@cucumber/cucumber' -import type { Browser, BrowserContext, Download, Page } from '@playwright/test' +import type { APIRequestContext, Browser, BrowserContext, Download, Page } from '@playwright/test' import type { AuthSessionMetadata } from '../../fixtures/auth' +import type { ConsoleClient } from '../../support/api/console-client' import { setWorldConstructor, World } from '@cucumber/cucumber' +import { request } from '@playwright/test' import { authStatePath, readAuthSessionMetadata } from '../../fixtures/auth' +import { createConsoleClient } from '../../support/api/console-client' import { runCleanupTasks } from '../../support/cleanup' -import { baseURL, defaultLocale } from '../../test-env' +import { apiURL, baseURL, defaultLocale } from '../../test-env' export type ScenarioCleanup = () => Promise | void export type CreatedAgentDriveFile = { @@ -76,6 +79,8 @@ export type AgentBuilderWorldState = ReturnType { if (message.type() === 'error') this.consoleErrors.push(message.text()) }) @@ -158,6 +168,13 @@ export class DifyWorld extends World { return this.page } + getConsoleClient() { + if (!this.consoleClient) + throw new Error('Console API client has not been initialized for this scenario.') + + return this.consoleClient + } + async getAuthSession() { this.session ??= await readAuthSessionMetadata() return this.session @@ -180,7 +197,10 @@ export class DifyWorld extends World { try { await this.context?.close() } finally { + await this.consoleRequestContext?.dispose() this.context = undefined + this.consoleRequestContext = undefined + this.consoleClient = undefined this.page = undefined this.session = undefined this.scenarioStartedAt = undefined diff --git a/e2e/fixtures/auth.ts b/e2e/fixtures/auth.ts index 1467e66644f..582f23fadba 100644 --- a/e2e/fixtures/auth.ts +++ b/e2e/fixtures/auth.ts @@ -1,9 +1,10 @@ -import type { APIResponse, Browser, BrowserContext } from '@playwright/test' +import type { Browser } from '@playwright/test' import { Buffer } from 'node:buffer' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { apiURL, defaultBaseURL, defaultLocale } from '../test-env' +import { createConsoleClient } from '../support/api/console-client' +import { defaultBaseURL, defaultLocale } from '../test-env' export type AuthSessionMetadata = { adminEmail: string @@ -36,16 +37,6 @@ export const readAuthSessionMetadata = async () => { return JSON.parse(content) as AuthSessionMetadata } -const apiEndpoint = (pathname: string) => new URL(pathname, apiURL).toString() - -type SetupStatusResponse = { - step: 'not_started' | 'finished' -} - -type InitStatusResponse = { - status: 'not_started' | 'finished' -} - type AuthBootstrapResult = { mode: AuthSessionMetadata['mode'] usedInitPassword: boolean @@ -55,65 +46,41 @@ const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now() const encodeField = (value: string) => Buffer.from(value, 'utf8').toString('base64') -const assertAPIResponse = async (response: APIResponse, action: string) => { - if (response.ok()) return - - const body = await response.text().catch(() => '') - throw new Error( - `${action} failed with ${response.status()} ${response.statusText()}${body ? `: ${body}` : ''}`, - ) -} - -const getConsoleAPI = async (context: BrowserContext, pathname: string, deadline: number) => { - const response = await context.request.get(apiEndpoint(pathname), { - timeout: getRemainingTimeout(deadline), - }) - await assertAPIResponse(response, `GET ${pathname}`) - return response.json() as Promise -} - -const postConsoleAPI = async ( - context: BrowserContext, - pathname: string, +const validateInitPasswordIfNeeded = async ( + client: ReturnType, deadline: number, - data: Record, ) => { - const response = await context.request.post(apiEndpoint(pathname), { - data, - timeout: getRemainingTimeout(deadline), - }) - await assertAPIResponse(response, `POST ${pathname}`) -} - -const validateInitPasswordIfNeeded = async (context: BrowserContext, deadline: number) => { - const initStatus = await getConsoleAPI(context, '/console/api/init', deadline) + const options = { context: { timeoutMs: getRemainingTimeout(deadline) } } + const initStatus = await client.init.get(undefined, options) if (initStatus.status === 'finished') return false console.warn('[e2e] auth bootstrap: validating init password') - await postConsoleAPI(context, '/console/api/init', deadline, { password: initPassword }) + await client.init.post({ body: { password: initPassword } }, options) return true } const ensureAdminAccount = async ( - context: BrowserContext, + client: ReturnType, deadline: number, ): Promise => { - const setupStatus = await getConsoleAPI( - context, - '/console/api/setup', - deadline, - ) + const options = { context: { timeoutMs: getRemainingTimeout(deadline) } } + const setupStatus = await client.setup.get(undefined, options) let usedInitPassword = false if (setupStatus.step === 'not_started') { - usedInitPassword = await validateInitPasswordIfNeeded(context, deadline) + usedInitPassword = await validateInitPasswordIfNeeded(client, deadline) console.warn('[e2e] auth bootstrap: creating admin account') - await postConsoleAPI(context, '/console/api/setup', deadline, { - email: adminCredentials.email, - name: adminCredentials.name, - password: adminCredentials.password, - language: defaultLocale, - }) + await client.setup.post( + { + body: { + email: adminCredentials.email, + name: adminCredentials.name, + password: adminCredentials.password, + language: defaultLocale, + }, + }, + options, + ) return { mode: 'install', usedInitPassword } } @@ -121,13 +88,18 @@ const ensureAdminAccount = async ( return { mode: 'login', usedInitPassword } } -const loginAdmin = async (context: BrowserContext, deadline: number) => { +const loginAdmin = async (client: ReturnType, deadline: number) => { console.warn('[e2e] auth bootstrap: logging in admin') - await postConsoleAPI(context, '/console/api/login', deadline, { - email: adminCredentials.email, - password: encodeField(adminCredentials.password), - remember_me: true, - }) + await client.login.post( + { + body: { + email: adminCredentials.email, + password: encodeField(adminCredentials.password), + remember_me: true, + }, + }, + { context: { timeoutMs: getRemainingTimeout(deadline) } }, + ) } export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => { @@ -140,10 +112,11 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU baseURL, locale: defaultLocale, }) + const client = createConsoleClient({ requestContext: context.request, requireCsrfToken: false }) try { - const { mode, usedInitPassword } = await ensureAdminAccount(context, deadline) - await loginAdmin(context, deadline) + const { mode, usedInitPassword } = await ensureAdminAccount(client, deadline) + await loginAdmin(client, deadline) await context.storageState({ path: authStatePath }) diff --git a/e2e/package.json b/e2e/package.json index 52ec01c30bd..562cfb6a46c 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -23,6 +23,9 @@ "@cucumber/cucumber": "catalog:", "@dify/contracts": "workspace:*", "@dify/tsconfig": "workspace:*", + "@orpc/client": "catalog:", + "@orpc/contract": "catalog:", + "@orpc/openapi-client": "catalog:", "@playwright/test": "catalog:", "@t3-oss/env-core": "catalog:", "@types/node": "catalog:", diff --git a/e2e/scripts/seed.ts b/e2e/scripts/seed.ts index d609612c2db..9b1b13dc5c6 100644 --- a/e2e/scripts/seed.ts +++ b/e2e/scripts/seed.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { chromium } from '@playwright/test' import { createAgentV2SeedTasks } from '../features/agent-v2/support/seed' import { ensureAuthenticatedState } from '../fixtures/auth' +import { createStandaloneConsoleSession } from '../support/api/console-session' import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process' import { runSeedTasks, writeSeedReport } from '../support/seed' import { startWebServer, stopWebServer } from '../support/web-server' @@ -110,6 +111,7 @@ const main = async () => { const logDir = path.join(e2eDir, '.logs') let apiProcess: ManagedProcess | undefined let celeryProcess: ManagedProcess | undefined + let consoleSession: Awaited> | undefined await mkdir(logDir, { recursive: true }) @@ -128,8 +130,10 @@ const main = async () => { console.warn(`[seed] bootstrapping auth state against ${baseURL}`) await ensureAuth() + consoleSession = await createStandaloneConsoleSession() const results = await runSeedTasks(getTasks(options.pack, options.profile), { + consoleClient: consoleSession.client, dryRun: options.dryRun, resources: new Map(), }) @@ -144,6 +148,7 @@ const main = async () => { ) } } finally { + await consoleSession?.dispose() await stopWebServer() await stopManagedProcess(celeryProcess) await stopManagedProcess(apiProcess) diff --git a/e2e/support/api/apps.ts b/e2e/support/api/apps.ts index ae7ae30ffaf..a6976325013 100644 --- a/e2e/support/api/apps.ts +++ b/e2e/support/api/apps.ts @@ -1,36 +1,20 @@ import type { CreateAppPayload, PostAppsResponse } from '@dify/contracts/api/console/apps/types.gen' -import { zPostAppsResponse } from '@dify/contracts/api/console/apps/zod.gen' +import type { ConsoleClient } from './console-client' import { assertE2EResourceName, createE2EResourceName } from '../naming' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' export async function createTestApp( + client: ConsoleClient, name = createE2EResourceName('App'), mode: CreateAppPayload['mode'] = 'workflow', ): Promise { assertE2EResourceName(name, 'App') - const ctx = await createConsoleApiContext() - try { - const data = { - name, - mode, - icon_type: 'emoji', - icon: '🤖', - icon_background: '#FFEAD5', - } satisfies CreateAppPayload - const response = await ctx.post('/console/api/apps', { data }) - await expectApiResponseOK(response, `Create ${mode} app ${name}`) - return zPostAppsResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} + const body = { + name, + mode, + icon_type: 'emoji', + icon: '🤖', + icon_background: '#FFEAD5', + } satisfies CreateAppPayload -export async function deleteTestApp(id: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/apps/${id}`) - await expectApiResponseOK(response, `Delete app ${id}`) - } finally { - await ctx.dispose() - } + return client.apps.post({ body }) } diff --git a/e2e/support/api/console-client.ts b/e2e/support/api/console-client.ts new file mode 100644 index 00000000000..7a6a24604b8 --- /dev/null +++ b/e2e/support/api/console-client.ts @@ -0,0 +1,52 @@ +import type { ContractRouterClient } from '@orpc/contract' +import type { JsonifiedClient } from '@orpc/openapi-client' +import type { APIRequestContext } from '@playwright/test' +import type { ConsoleClientContext } from './playwright-fetch' +import { consoleRouterContract } from '@dify/contracts/api/console/router.gen' +import { createORPCClient } from '@orpc/client' +import { RequestValidationPlugin, ResponseValidationPlugin } from '@orpc/contract/plugins' +import { OpenAPILink } from '@orpc/openapi-client/fetch' +import { apiURL } from '../../test-env' +import { createPlaywrightFetch } from './playwright-fetch' + +type ConsoleRequestContext = Pick + +export type ConsoleClient = JsonifiedClient< + ContractRouterClient +> + +export type CreateConsoleClientOptions = { + apiBaseURL?: string + requestContext: ConsoleRequestContext + requireCsrfToken?: boolean +} + +const getCsrfToken = async (requestContext: ConsoleRequestContext) => { + const state = await requestContext.storageState() + return state.cookies.find((cookie) => cookie.name.endsWith('csrf_token'))?.value +} + +export function createConsoleClient({ + apiBaseURL = apiURL, + requestContext, + requireCsrfToken = true, +}: CreateConsoleClientOptions): ConsoleClient { + const link = new OpenAPILink(consoleRouterContract, { + fetch: createPlaywrightFetch(requestContext), + headers: async () => { + const headers = new Headers({ Accept: 'application/json' }) + const csrfToken = await getCsrfToken(requestContext) + if (!csrfToken && requireCsrfToken) + throw new Error('The Console API client requires an authenticated CSRF token.') + if (csrfToken) headers.set('X-CSRF-Token', csrfToken) + return headers + }, + plugins: [ + new RequestValidationPlugin(consoleRouterContract), + new ResponseValidationPlugin(consoleRouterContract), + ], + url: new URL('/console/api/', apiBaseURL).toString(), + }) + + return createORPCClient(link) +} diff --git a/e2e/support/api/console-context.ts b/e2e/support/api/console-context.ts deleted file mode 100644 index 6ba5c4c33df..00000000000 --- a/e2e/support/api/console-context.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { APIResponse } from '@playwright/test' -import { readFile } from 'node:fs/promises' -import { request } from '@playwright/test' -import * as z from 'zod' -import { authStatePath } from '../../fixtures/auth' -import { apiURL } from '../../test-env' - -const zStorageState = z.object({ - cookies: z.array( - z.object({ - name: z.string(), - value: z.string(), - }), - ), -}) - -export async function createConsoleApiContext() { - const state = zStorageState.parse(JSON.parse(await readFile(authStatePath, 'utf8'))) - const csrfToken = state.cookies.find((cookie) => cookie.name.endsWith('csrf_token'))?.value - if (!csrfToken) throw new Error(`No CSRF token found in E2E auth state: ${authStatePath}`) - - return request.newContext({ - baseURL: apiURL, - extraHTTPHeaders: { 'X-CSRF-Token': csrfToken }, - storageState: authStatePath, - }) -} - -export async function expectApiResponseOK(response: APIResponse, action: string): Promise { - if (response.ok()) return - - const body = await response.text().catch(() => '') - throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`) -} diff --git a/e2e/support/api/console-session.ts b/e2e/support/api/console-session.ts new file mode 100644 index 00000000000..ab7fc8790ac --- /dev/null +++ b/e2e/support/api/console-session.ts @@ -0,0 +1,16 @@ +import { request } from '@playwright/test' +import { authStatePath } from '../../fixtures/auth' +import { apiURL } from '../../test-env' +import { createConsoleClient } from './console-client' + +export async function createStandaloneConsoleSession() { + const requestContext = await request.newContext({ + baseURL: apiURL, + storageState: authStatePath, + }) + + return { + client: createConsoleClient({ requestContext }), + dispose: () => requestContext.dispose(), + } +} diff --git a/e2e/support/api/datasets.ts b/e2e/support/api/datasets.ts deleted file mode 100644 index 9a696a24d8b..00000000000 --- a/e2e/support/api/datasets.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createConsoleApiContext, expectApiResponseOK } from './console-context' - -export async function deleteTestDataset(datasetId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.delete(`/console/api/datasets/${datasetId}`) - await expectApiResponseOK(response, `Delete dataset ${datasetId}`) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/marketplace-plugins.ts b/e2e/support/api/marketplace-plugins.ts deleted file mode 100644 index 1dea0c7d95c..00000000000 --- a/e2e/support/api/marketplace-plugins.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { - GetWorkspacesCurrentPluginTasksByTaskIdResponse, - ParserLatest, - ParserPluginIdentifiers, - PostWorkspacesCurrentPluginInstallMarketplaceResponse, - PostWorkspacesCurrentPluginInstallPkgResponse, - PostWorkspacesCurrentPluginListInstallationsIdsResponse, - PostWorkspacesCurrentPluginListLatestVersionsResponse, - PostWorkspacesCurrentPluginUploadPkgResponse, -} from '@dify/contracts/api/console/workspaces/types.gen' -import type { Buffer } from 'node:buffer' -import { - zGetWorkspacesCurrentPluginTasksByTaskIdResponse, - zPostWorkspacesCurrentPluginInstallMarketplaceResponse, - zPostWorkspacesCurrentPluginInstallPkgResponse, - zPostWorkspacesCurrentPluginListInstallationsIdsResponse, - zPostWorkspacesCurrentPluginListLatestVersionsResponse, - zPostWorkspacesCurrentPluginUploadPkgResponse, -} from '@dify/contracts/api/console/workspaces/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' - -export async function getLatestMarketplacePluginVersions( - pluginIds: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { plugin_ids: pluginIds } satisfies ParserLatest - const response = await ctx.post('/console/api/workspaces/current/plugin/list/latest-versions', { - data, - }) - await expectApiResponseOK(response, 'Resolve latest marketplace plugin versions') - return zPostWorkspacesCurrentPluginListLatestVersionsResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function getInstalledMarketplacePlugins( - pluginIds: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { plugin_ids: pluginIds } satisfies ParserLatest - const response = await ctx.post( - '/console/api/workspaces/current/plugin/list/installations/ids', - { data }, - ) - await expectApiResponseOK(response, 'List installed marketplace plugins') - return zPostWorkspacesCurrentPluginListInstallationsIdsResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function getMarketplacePluginInstallTask( - taskId: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/workspaces/current/plugin/tasks/${taskId}`) - await expectApiResponseOK(response, `Fetch marketplace plugin install task ${taskId}`) - return zGetWorkspacesCurrentPluginTasksByTaskIdResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function startMarketplacePluginInstall( - pluginUniqueIdentifiers: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - plugin_unique_identifiers: pluginUniqueIdentifiers, - } satisfies ParserPluginIdentifiers - const response = await ctx.post('/console/api/workspaces/current/plugin/install/marketplace', { - data, - }) - await expectApiResponseOK(response, 'Install marketplace plugins') - return zPostWorkspacesCurrentPluginInstallMarketplaceResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function uploadMarketplacePluginPackageFile( - pkg: Buffer, - fileName: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.post('/console/api/workspaces/current/plugin/upload/pkg', { - multipart: { - pkg: { - buffer: pkg, - mimeType: 'application/octet-stream', - name: fileName, - }, - }, - }) - await expectApiResponseOK(response, `Upload marketplace package ${fileName}`) - return zPostWorkspacesCurrentPluginUploadPkgResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function startUploadedPluginPackageInstall( - pluginUniqueIdentifiers: string[], -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - plugin_unique_identifiers: pluginUniqueIdentifiers, - } satisfies ParserPluginIdentifiers - const response = await ctx.post('/console/api/workspaces/current/plugin/install/pkg', { data }) - await expectApiResponseOK(response, 'Install uploaded plugin packages') - return zPostWorkspacesCurrentPluginInstallPkgResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/playwright-fetch.ts b/e2e/support/api/playwright-fetch.ts new file mode 100644 index 00000000000..baae3e01257 --- /dev/null +++ b/e2e/support/api/playwright-fetch.ts @@ -0,0 +1,61 @@ +import type { OpenAPILinkOptions } from '@orpc/openapi-client/fetch' +import type { APIRequestContext } from '@playwright/test' +import { Buffer } from 'node:buffer' + +export type ConsoleClientContext = { + timeoutMs?: number +} + +type PlaywrightRequestContext = Pick +type OpenAPIFetch = NonNullable['fetch']> + +const defaultRequestTimeoutMs = 30_000 +const bodylessResponseStatuses = new Set([204, 205, 304]) + +export function createPlaywrightFetch(requestContext: PlaywrightRequestContext): OpenAPIFetch { + return async (request, _init, options, path) => { + request.signal.throwIfAborted() + + const headers = Object.fromEntries(request.headers.entries()) + delete headers['content-length'] + + const data = request.body ? Buffer.from(await request.arrayBuffer()) : undefined + const apiResponse = await requestContext.fetch(request.url, { + ...(data === undefined ? {} : { data }), + failOnStatusCode: false, + headers, + maxRedirects: 0, + method: request.method, + timeout: options.context.timeoutMs ?? defaultRequestTimeoutMs, + }) + + try { + const status = apiResponse.status() + if (status >= 300 && status < 400) { + const location = apiResponse.headers().location + throw new Error( + `Console API ${path.join('.')} redirected with ${status}${location ? ` to ${location}` : ''}.`, + ) + } + + const responseHeaders = new Headers() + for (const { name, value } of apiResponse.headersArray()) responseHeaders.append(name, value) + responseHeaders.delete('content-encoding') + responseHeaders.delete('content-length') + responseHeaders.delete('transfer-encoding') + + const body = + request.method === 'HEAD' || bodylessResponseStatuses.has(status) + ? null + : Uint8Array.from(await apiResponse.body()) + + return new Response(body, { + headers: responseHeaders, + status, + statusText: apiResponse.statusText(), + }) + } finally { + await apiResponse.dispose() + } + } +} diff --git a/e2e/support/api/tools.ts b/e2e/support/api/tools.ts deleted file mode 100644 index 10db3d50713..00000000000 --- a/e2e/support/api/tools.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { BuiltinToolCredentialDeletePayload } from '@dify/contracts/api/console/workspaces/types.gen' -import { zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse } from '@dify/contracts/api/console/workspaces/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' - -export async function deleteBuiltinToolCredential( - provider: string, - credentialId: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { credential_id: credentialId } satisfies BuiltinToolCredentialDeletePayload - const response = await ctx.post( - `/console/api/workspaces/current/tool-provider/builtin/${provider}/delete`, - { data }, - ) - await expectApiResponseOK( - response, - `Delete built-in tool credential ${credentialId} for ${provider}`, - ) - zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/web-apps.ts b/e2e/support/api/web-apps.ts index c1f557285dd..fe97aee87ef 100644 --- a/e2e/support/api/web-apps.ts +++ b/e2e/support/api/web-apps.ts @@ -1,10 +1,4 @@ -import type { - AppDetailWithSite, - AppSiteStatusPayload, - GetAppsByAppIdResponse, -} from '@dify/contracts/api/console/apps/types.gen' -import { zGetAppsByAppIdResponse } from '@dify/contracts/api/console/apps/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { if (!site?.app_base_url || !site.access_token) @@ -18,30 +12,3 @@ export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { return `${site.app_base_url}/${webAppMode}/${site.access_token}` } - -export async function getAppSiteDetail(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/apps/${appId}`) - await expectApiResponseOK(response, `Get app site detail for ${appId}`) - return zGetAppsByAppIdResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function enableAppSiteAndGetURL(appId: string): Promise { - await setAppSiteEnabled(appId, true) - return getAppSiteURL(await getAppSiteDetail(appId)) -} - -export async function setAppSiteEnabled(appId: string, enabled: boolean): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { enable_site: enabled } satisfies AppSiteStatusPayload - const enableResponse = await ctx.post(`/console/api/apps/${appId}/site-enable`, { data }) - await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`) - } finally { - await ctx.dispose() - } -} diff --git a/e2e/support/api/workflows.ts b/e2e/support/api/workflows.ts index cc826c508fb..00ee6544d4e 100644 --- a/e2e/support/api/workflows.ts +++ b/e2e/support/api/workflows.ts @@ -1,115 +1,70 @@ -import type { - GetAppsByAppIdWorkflowsDraftResponse, - PublishWorkflowPayload, - SyncDraftWorkflowPayload, -} from '@dify/contracts/api/console/apps/types.gen' -import { - zGetAppsByAppIdWorkflowsDraftResponse, - zPostAppsByAppIdWorkflowsDraftResponse, - zPostAppsByAppIdWorkflowsPublishResponse, -} from '@dify/contracts/api/console/apps/zod.gen' -import { createConsoleApiContext, expectApiResponseOK } from './console-context' +import type { SyncDraftWorkflowPayload } from '@dify/contracts/api/console/apps/types.gen' +import type { ConsoleClient } from './console-client' -export async function getWorkflowDraft( +export async function syncMinimalWorkflowDraft( + client: ConsoleClient, appId: string, -): Promise { - const ctx = await createConsoleApiContext() - try { - const response = await ctx.get(`/console/api/apps/${appId}/workflows/draft`) - await expectApiResponseOK(response, `Get workflow draft for ${appId}`) - return zGetAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } +): Promise { + const body = { + graph: { + nodes: [ + { + id: '1', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: '1', type: 'start', title: 'Start', variables: [] }, + }, + ], + edges: [], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) } -export async function syncMinimalWorkflowDraft(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - graph: { - nodes: [ - { - id: '1', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: '1', type: 'start', title: 'Start', variables: [] }, - }, - ], - edges: [], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - } satisfies SyncDraftWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) - await expectApiResponseOK(response, `Sync minimal workflow draft for ${appId}`) - zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function syncRunnableWorkflowDraft(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - graph: { - nodes: [ - { - id: 'start', - type: 'custom', - position: { x: 80, y: 282 }, - data: { id: 'start', type: 'start', title: 'Start', variables: [] }, - }, - { +export async function syncRunnableWorkflowDraft( + client: ConsoleClient, + appId: string, +): Promise { + const body = { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'end', + type: 'custom', + position: { x: 480, y: 282 }, + data: { id: 'end', - type: 'custom', - position: { x: 480, y: 282 }, - data: { - id: 'end', - type: 'end', - title: 'End', - outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], - }, + type: 'end', + title: 'End', + outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], }, - ], - edges: [ - { - id: 'start-end', - type: 'custom', - source: 'start', - target: 'end', - sourceHandle: 'source', - targetHandle: 'target', - }, - ], - viewport: { x: 0, y: 0, zoom: 1 }, - }, - features: {}, - environment_variables: [], - conversation_variables: [], - } satisfies SyncDraftWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data }) - await expectApiResponseOK(response, `Sync runnable workflow draft for ${appId}`) - zPostAppsByAppIdWorkflowsDraftResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } -} - -export async function publishWorkflowApp(appId: string): Promise { - const ctx = await createConsoleApiContext() - try { - const data = { - marked_name: '', - marked_comment: '', - } satisfies PublishWorkflowPayload - const response = await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { data }) - await expectApiResponseOK(response, `Publish workflow app ${appId}`) - zPostAppsByAppIdWorkflowsPublishResponse.parse(await response.json()) - } finally { - await ctx.dispose() - } + }, + ], + edges: [ + { + id: 'start-end', + type: 'custom', + source: 'start', + target: 'end', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + } satisfies SyncDraftWorkflowPayload + await client.apps.byAppId.workflows.draft.post({ body, params: { app_id: appId } }) } diff --git a/e2e/support/marketplace-plugins.ts b/e2e/support/marketplace-plugins.ts index 4932bf14eb6..cb8b837ef11 100644 --- a/e2e/support/marketplace-plugins.ts +++ b/e2e/support/marketplace-plugins.ts @@ -1,14 +1,8 @@ import type { PluginInstallTask } from '@dify/contracts/api/console/workspaces/types.gen' +import type { ConsoleClient } from './api/console-client' import type { SeedContext, SeedResult } from './seed' import { Buffer } from 'node:buffer' -import { - getInstalledMarketplacePlugins, - getLatestMarketplacePluginVersions, - getMarketplacePluginInstallTask, - startMarketplacePluginInstall, - startUploadedPluginPackageInstall, - uploadMarketplacePluginPackageFile, -} from './api/marketplace-plugins' +import { ORPCError } from '@orpc/client' import { sleep } from './process' import { blocked, created, skipped, verified } from './seed' @@ -34,10 +28,12 @@ const unique = (values: string[]) => Array.from(new Set(values)) const getPluginId = (pluginUniqueIdentifier: string) => pluginUniqueIdentifier.split(':')[0]?.trim() || pluginUniqueIdentifier.trim() -const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { +const resolveLatestPluginIdentifiers = async (client: ConsoleClient, pluginIds: string[]) => { if (pluginIds.length === 0) return { identifiers: [] as string[], missing: [] as string[] } - const body = await getLatestMarketplacePluginVersions(pluginIds) + const body = await client.workspaces.current.plugin.list.latestVersions.post({ + body: { plugin_ids: pluginIds }, + }) const identifiers: string[] = [] const missing: string[] = [] @@ -50,18 +46,30 @@ const resolveLatestPluginIdentifiers = async (pluginIds: string[]) => { return { identifiers, missing } } -const listInstalledPlugins = async (pluginIds: string[]) => { +const listInstalledPlugins = async (client: ConsoleClient, pluginIds: string[]) => { if (pluginIds.length === 0) return [] - return (await getInstalledMarketplacePlugins(pluginIds)).plugins + return ( + await client.workspaces.current.plugin.list.installations.ids.post({ + body: { plugin_ids: pluginIds }, + }) + ).plugins } -const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => { +const waitForPluginInstallTask = async ( + client: ConsoleClient, + taskId: string, + timeoutMs = 300_000, +) => { const deadline = Date.now() + timeoutMs let lastTask: PluginInstallTask | undefined while (Date.now() < deadline) { - lastTask = (await getMarketplacePluginInstallTask(taskId)).task + lastTask = ( + await client.workspaces.current.plugin.tasks.byTaskId.get({ + params: { task_id: taskId }, + }) + ).task if (lastTask?.status === terminalSuccessTaskStatus) return { ok: true as const, task: lastTask } @@ -90,10 +98,6 @@ const waitForPluginInstallTask = async (taskId: string, timeoutMs = 300_000) => return { ok: false as const, reason: `Plugin install task did not finish within ${timeoutMs}ms.` } } -const installMarketplacePlugins = async (pluginUniqueIdentifiers: string[]) => { - return startMarketplacePluginInstall(pluginUniqueIdentifiers) -} - const getMarketplaceDownloadUrl = (pluginUniqueIdentifier: string) => { const url = new URL( '/api/v1/plugins/download', @@ -114,25 +118,49 @@ const downloadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) return Buffer.from(await response.arrayBuffer()) } -const uploadMarketplacePluginPackage = async (pluginUniqueIdentifier: string) => { +const uploadMarketplacePluginPackage = async ( + client: ConsoleClient, + pluginUniqueIdentifier: string, +) => { const pkg = await downloadMarketplacePluginPackage(pluginUniqueIdentifier) const fileName = `${getPluginId(pluginUniqueIdentifier).replaceAll('/', '-')}.difypkg` - return (await uploadMarketplacePluginPackageFile(pkg, fileName)).unique_identifier + const response = await client.workspaces.current.plugin.upload.pkg.post({ + body: { + pkg: new File([Uint8Array.from(pkg)], fileName, { type: 'application/octet-stream' }), + }, + }) + return response.unique_identifier } -const installLocalPluginPackages = async (pluginUniqueIdentifiers: string[]) => { - return startUploadedPluginPackageInstall(pluginUniqueIdentifiers) +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null + +const getMarketplaceInstallErrorText = (error: unknown) => { + const messages = [error instanceof Error ? error.message : String(error)] + + if (error instanceof ORPCError && isRecord(error.data)) { + const body = error.data.body + if (isRecord(body) && typeof body.message === 'string') messages.push(body.message) + } + + return messages.join('\n') } -const shouldFallbackToLocalPackageInstall = (error: string) => - error.includes('/plugins/download') || error.includes('Reached maximum retries') +const shouldFallbackToLocalPackageInstall = (error: unknown) => { + const message = getMarketplaceInstallErrorText(error) + return message.includes('/plugins/download') || message.includes('Reached maximum retries') +} -const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: string[]) => { +const installMarketplacePluginsWithFallback = async ( + client: ConsoleClient, + pluginUniqueIdentifiers: string[], +) => { try { - return await installMarketplacePlugins(pluginUniqueIdentifiers) + return await client.workspaces.current.plugin.install.marketplace.post({ + body: { plugin_unique_identifiers: pluginUniqueIdentifiers }, + }) } catch (error) { - const message = error instanceof Error ? error.message : String(error) - if (!shouldFallbackToLocalPackageInstall(message)) throw error + if (!shouldFallbackToLocalPackageInstall(error)) throw error console.warn( '[seed] marketplace install download failed in API process; falling back to local package upload.', @@ -140,10 +168,12 @@ const installMarketplacePluginsWithFallback = async (pluginUniqueIdentifiers: st const uploadedPluginUniqueIdentifiers: string[] = [] for (const pluginUniqueIdentifier of pluginUniqueIdentifiers) uploadedPluginUniqueIdentifiers.push( - await uploadMarketplacePluginPackage(pluginUniqueIdentifier), + await uploadMarketplacePluginPackage(client, pluginUniqueIdentifier), ) - return await installLocalPluginPackages(uploadedPluginUniqueIdentifiers) + return await client.workspaces.current.plugin.install.pkg.post({ + body: { plugin_unique_identifiers: uploadedPluginUniqueIdentifiers }, + }) } } @@ -152,12 +182,13 @@ export const bootstrapMarketplacePlugins = async ( config: MarketplacePluginBootstrapConfig, ): Promise => { const requestedPluginIds = parseListEnv(config.pluginIdsEnv) + const client = context.consoleClient const pluginIds = unique( requestedPluginIds.length > 0 ? requestedPluginIds : config.defaultPluginIds, ) if (pluginIds.length > 0) { - const installedPlugins = await listInstalledPlugins(pluginIds) + const installedPlugins = await listInstalledPlugins(client, pluginIds) const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) if (pluginIds.every((pluginId) => installedPluginIds.has(pluginId))) { return verified(config.title, { @@ -168,7 +199,7 @@ export const bootstrapMarketplacePlugins = async ( } } - const resolved = await resolveLatestPluginIdentifiers(pluginIds) + const resolved = await resolveLatestPluginIdentifiers(client, pluginIds) if (resolved.missing.length > 0) { return blocked( @@ -183,7 +214,7 @@ export const bootstrapMarketplacePlugins = async ( if (requiredPluginUniqueIdentifiers.length === 0) return skipped(config.title, 'No marketplace plugins were requested.') - const installedPlugins = await listInstalledPlugins(requiredPluginIds) + const installedPlugins = await listInstalledPlugins(client, requiredPluginIds) const installedPluginIds = new Set(installedPlugins.map((plugin) => plugin.plugin_id)) const missingPluginUniqueIdentifiers = requiredPluginUniqueIdentifiers.filter( (identifier) => !installedPluginIds.has(getPluginId(identifier)), @@ -204,9 +235,10 @@ export const bootstrapMarketplacePlugins = async ( } const startedTask = await installMarketplacePluginsWithFallback( + client, missingPluginUniqueIdentifiers, ).catch((error) => { - return { error: error instanceof Error ? error.message : String(error) } + return { error: getMarketplaceInstallErrorText(error) } }) if ('error' in startedTask) return blocked(config.title, startedTask.error) @@ -215,7 +247,7 @@ export const bootstrapMarketplacePlugins = async ( const taskId = startedTask.task_id || startedTask.task?.id if (!taskId) return blocked(config.title, 'Marketplace plugin install did not return a task id.') - const taskResult = await waitForPluginInstallTask(taskId) + const taskResult = await waitForPluginInstallTask(client, taskId) if (!taskResult.ok) return blocked(config.title, taskResult.reason) return created(config.title, resource) diff --git a/e2e/support/seed.ts b/e2e/support/seed.ts index eddac03dd46..e0ec106de0d 100644 --- a/e2e/support/seed.ts +++ b/e2e/support/seed.ts @@ -1,3 +1,4 @@ +import type { ConsoleClient } from './api/console-client' import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { e2eDir } from '../scripts/common' @@ -18,6 +19,7 @@ export type SeedResult = { } export type SeedContext = { + consoleClient: ConsoleClient dryRun: boolean resources: Map } diff --git a/e2e/tests/console-client.test.ts b/e2e/tests/console-client.test.ts new file mode 100644 index 00000000000..40078b08554 --- /dev/null +++ b/e2e/tests/console-client.test.ts @@ -0,0 +1,208 @@ +import type { APIRequestContext, APIResponse } from '@playwright/test' +import { Buffer } from 'node:buffer' +import { describe, expect, it, vi } from 'vitest' +import { createConsoleClient } from '../support/api/console-client' +import { createPlaywrightFetch } from '../support/api/playwright-fetch' + +const createApiResponse = ({ + body = '', + headers = { 'content-type': 'application/json' }, + status = 200, + statusText = 'OK', + url = 'http://api.test/console/api/apps/app-1', +}: { + body?: string + headers?: Record + status?: number + statusText?: string + url?: string +} = {}): APIResponse => { + const bodyBuffer = Buffer.from(body) + + return { + body: async () => bodyBuffer, + dispose: async () => {}, + headers: () => headers, + headersArray: () => Object.entries(headers).map(([name, value]) => ({ name, value })), + json: async () => JSON.parse(body), + ok: () => status >= 200 && status < 300, + securityDetails: async () => null, + serverAddr: async () => null, + status: () => status, + statusText: () => statusText, + text: async () => body, + url: () => url, + [Symbol.asyncDispose]: async () => {}, + } +} + +const createRequestContext = (response: APIResponse, csrfToken = 'csrf-token') => { + const fetch = vi.fn(async () => response) + const context = { + fetch, + storageState: vi.fn(async () => ({ + cookies: [ + { + domain: 'api.test', + expires: -1, + httpOnly: false, + name: 'csrf_token', + path: '/', + sameSite: 'Lax', + secure: false, + value: csrfToken, + }, + ], + origins: [], + })), + } satisfies Pick + + return { context, fetch } +} + +const callPlaywrightFetch = (requestContext: Pick, request: Request) => + createPlaywrightFetch(requestContext)(request, {}, { context: {} }, ['test'], undefined) + +describe('createPlaywrightFetch', () => { + it('forwards the Fetch request without following redirects and returns a standard Response', async () => { + const apiResponse = createApiResponse({ + body: '{"ok":true}', + status: 201, + statusText: 'Created', + }) + const { context, fetch } = createRequestContext(apiResponse) + const response = await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/apps', { + body: '{"name":"E2E App"}', + headers: { 'content-type': 'application/json', 'x-test': 'value' }, + method: 'POST', + }), + ) + + expect(fetch).toHaveBeenCalledWith( + 'http://api.test/console/api/apps', + expect.objectContaining({ + data: Buffer.from('{"name":"E2E App"}'), + failOnStatusCode: false, + headers: expect.objectContaining({ 'content-type': 'application/json', 'x-test': 'value' }), + maxRedirects: 0, + method: 'POST', + }), + ) + expect(response.status).toBe(201) + await expect(response.json()).resolves.toEqual({ ok: true }) + }) + + it('represents a 204 response without an invalid response body', async () => { + const { context } = createRequestContext(createApiResponse({ body: '', status: 204 })) + + const response = await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/apps/app-1', { method: 'DELETE' }), + ) + + expect(response.status).toBe(204) + await expect(response.text()).resolves.toBe('') + }) + + it('forwards generated multipart bodies as raw bytes with their boundary', async () => { + const { context, fetch } = createRequestContext(createApiResponse({ body: '{"ok":true}' })) + const formData = new FormData() + formData.append('pkg', new File(['plugin-package'], 'plugin.difypkg')) + + await callPlaywrightFetch( + context, + new Request('http://api.test/console/api/workspaces/current/plugin/upload/pkg', { + body: formData, + method: 'POST', + }), + ) + + const options = fetch.mock.calls[0]?.[1] + expect(options?.headers).toEqual( + expect.objectContaining({ 'content-type': expect.stringContaining('multipart/form-data') }), + ) + expect(Buffer.isBuffer(options?.data)).toBe(true) + expect((options?.data as Buffer).toString()).toContain('plugin.difypkg') + expect((options?.data as Buffer).toString()).toContain('plugin-package') + }) + + it('rejects redirects as an authentication or routing infrastructure failure', async () => { + const dispose = vi.fn(async () => {}) + const { context } = createRequestContext({ + ...createApiResponse({ + body: '', + headers: { location: 'http://web.test/signin' }, + status: 302, + statusText: 'Found', + }), + dispose, + }) + + await expect( + callPlaywrightFetch(context, new Request('http://api.test/console/api/apps')), + ).rejects.toThrow('redirected with 302') + expect(dispose).toHaveBeenCalledOnce() + }) +}) + +describe('createConsoleClient', () => { + it('validates generated request inputs before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse()) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.get({ + params: { app_id: 1 as unknown as string }, + }), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) + + it('rejects invalid generated multipart values before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse()) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.workspaces.current.plugin.upload.pkg.post({ + body: { pkg: 1 as unknown as File }, + }), + ).rejects.toThrow() + expect(fetch).not.toHaveBeenCalled() + }) + + it('validates server responses against the generated response schema', async () => { + const { context } = createRequestContext(createApiResponse({ body: '{"id":1}' })) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.get({ params: { app_id: '00000000-0000-4000-8000-000000000001' } }), + ).rejects.toThrow() + }) + + it('adds the current CSRF token and accepts generated 204 responses', async () => { + const { context, fetch } = createRequestContext(createApiResponse({ body: '', status: 204 })) + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.delete({ + params: { app_id: '00000000-0000-4000-8000-000000000001' }, + }), + ).resolves.toBeUndefined() + const requestHeaders = fetch.mock.calls[0]?.[1]?.headers + expect(requestHeaders).toEqual(expect.objectContaining({ 'x-csrf-token': 'csrf-token' })) + }) + + it('rejects authenticated calls without a CSRF token before transport', async () => { + const { context, fetch } = createRequestContext(createApiResponse(), '') + const client = createConsoleClient({ apiBaseURL: 'http://api.test', requestContext: context }) + + await expect( + client.apps.byAppId.delete({ + params: { app_id: '00000000-0000-4000-8000-000000000001' }, + }), + ).rejects.toThrow('requires an authenticated CSRF token') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/e2e/tests/marketplace-plugins.test.ts b/e2e/tests/marketplace-plugins.test.ts new file mode 100644 index 00000000000..dda4008adb7 --- /dev/null +++ b/e2e/tests/marketplace-plugins.test.ts @@ -0,0 +1,108 @@ +import type { ConsoleClient } from '../support/api/console-client' +import { ORPCError } from '@orpc/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { bootstrapMarketplacePlugins } from '../support/marketplace-plugins' + +const createMarketplaceConsoleClient = (installError: unknown) => { + const installMarketplace = vi.fn().mockRejectedValue(installError) + const uploadPackage = vi.fn().mockResolvedValue({ + unique_identifier: 'langgenius/test:1.0.0@package', + }) + const installPackage = vi.fn().mockResolvedValue({ all_installed: true }) + const consoleClient = { + workspaces: { + current: { + plugin: { + install: { + marketplace: { post: installMarketplace }, + pkg: { post: installPackage }, + }, + list: { + installations: { + ids: { post: vi.fn().mockResolvedValue({ plugins: [] }) }, + }, + latestVersions: { + post: vi.fn().mockResolvedValue({ + versions: { + 'langgenius/test': { + unique_identifier: 'langgenius/test:1.0.0@marketplace', + }, + }, + }), + }, + }, + upload: { pkg: { post: uploadPackage } }, + }, + }, + }, + } as unknown as ConsoleClient + + return { consoleClient, installPackage, uploadPackage } +} + +const bootstrapTestPlugin = (consoleClient: ConsoleClient) => + bootstrapMarketplacePlugins( + { consoleClient, dryRun: false, resources: new Map() }, + { + defaultPluginIds: ['langgenius/test'], + pluginIdsEnv: 'E2E_TEST_MARKETPLACE_PLUGIN_IDS', + title: 'Test marketplace plugin', + }, + ) + +describe('bootstrapMarketplacePlugins', () => { + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllEnvs() + }) + + it('uses generated package upload when the API process cannot download from Marketplace', async () => { + vi.stubEnv('E2E_TEST_MARKETPLACE_PLUGIN_IDS', '') + vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response('plugin-package')) + const { consoleClient, installPackage, uploadPackage } = createMarketplaceConsoleClient( + new ORPCError('INTERNAL_SERVER_ERROR', { + data: { + body: { + message: + 'Reached maximum retries (3) for URL https://marketplace.test/plugins/download', + }, + }, + status: 500, + }), + ) + const result = await bootstrapTestPlugin(consoleClient) + + expect(result.status).toBe('verified') + expect(uploadPackage).toHaveBeenCalledWith({ + body: { pkg: expect.any(File) }, + }) + expect(installPackage).toHaveBeenCalledWith({ + body: { plugin_unique_identifiers: ['langgenius/test:1.0.0@package'] }, + }) + }) + + it('does not hide unrelated generated client failures behind package upload', async () => { + vi.stubEnv('E2E_TEST_MARKETPLACE_PLUGIN_IDS', '') + const marketplaceFetch = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('plugin-package')) + const { consoleClient, installPackage, uploadPackage } = createMarketplaceConsoleClient( + new ORPCError('INTERNAL_SERVER_ERROR', { + data: { body: { message: 'Database unavailable' } }, + status: 500, + }), + ) + + const result = await bootstrapTestPlugin(consoleClient) + + expect(result).toEqual( + expect.objectContaining({ + reason: expect.stringContaining('Database unavailable'), + status: 'blocked', + }), + ) + expect(marketplaceFetch).not.toHaveBeenCalled() + expect(uploadPackage).not.toHaveBeenCalled() + expect(installPackage).not.toHaveBeenCalled() + }) +}) diff --git a/packages/contracts/binary-zod.test.ts b/packages/contracts/binary-zod.test.ts new file mode 100644 index 00000000000..35a6a96b290 --- /dev/null +++ b/packages/contracts/binary-zod.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' +import { zPostFilesUploadBody } from './generated/api/console/files/zod.gen' +import { zPostWorkspacesCurrentPluginUploadPkgBody } from './generated/api/console/workspaces/zod.gen' + +describe('generated binary schemas', () => { + it.each([ + ['file upload', zPostFilesUploadBody, 'file'], + ['plugin package upload', zPostWorkspacesCurrentPluginUploadPkgBody, 'pkg'], + ] as const)('validates %s values at runtime', (_, schema, field) => { + const file = new File(['test'], 'test.txt', { type: 'text/plain' }) + + expect(schema.safeParse({ [field]: file }).success).toBe(true) + expect(schema.safeParse({ [field]: 123 }).success).toBe(false) + }) +}) diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 6dc95e1cd7f..2a5e0dcb3b2 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -1234,14 +1234,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -1620,14 +1620,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -2268,6 +2268,10 @@ export type GetAgentByAgentIdBuildDraftData = { url: '/agent/{agent_id}/build-draft' } +export type GetAgentByAgentIdBuildDraftErrors = { + 404: unknown +} + export type GetAgentByAgentIdBuildDraftResponses = { 200: AgentBuildDraftResponse } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 4e60c853ab8..085f6551390 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -1732,10 +1732,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -2201,10 +2201,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -2887,7 +2887,7 @@ export const zDeleteAgentByAgentIdApiKeysByApiKeyIdResponse = z.void() export const zPostAgentByAgentIdAudioToTextBody = z.object({ draft_type: z.enum(['debug_build', 'draft']).optional().default('draft'), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdAudioToTextPath = z.object({ @@ -3137,7 +3137,7 @@ export const zGetAgentByAgentIdConfigSkillsQuery = z.object({ export const zGetAgentByAgentIdConfigSkillsResponse = zAgentConfigSkillListResponse export const zPostAgentByAgentIdConfigSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdConfigSkillsUploadPath = z.object({ @@ -3511,7 +3511,7 @@ export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({ export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse export const zPostAgentByAgentIdSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAgentByAgentIdSkillsUploadPath = z.object({ diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 5be8467e1d2..abf5b16538a 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -2070,14 +2070,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -2504,14 +2504,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index bba24bfa40a..a41672798e6 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -2938,10 +2938,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -3668,10 +3668,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -4721,7 +4721,7 @@ export const zGetAppsByAppIdAgentConfigSkillsQuery = z.object({ export const zGetAppsByAppIdAgentConfigSkillsResponse = zAgentConfigSkillListResponse export const zPostAppsByAppIdAgentConfigSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAgentConfigSkillsUploadPath = z.object({ @@ -4952,7 +4952,7 @@ export const zGetAppsByAppIdAgentLogsQuery = z.object({ export const zGetAppsByAppIdAgentLogsResponse = zAgentLogResponse export const zPostAppsByAppIdAgentSkillsUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAgentSkillsUploadPath = z.object({ @@ -5163,7 +5163,7 @@ export const zPostAppsByAppIdApiEnablePath = z.object({ export const zPostAppsByAppIdApiEnableResponse = zAppDetail export const zPostAppsByAppIdAudioToTextBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostAppsByAppIdAudioToTextPath = z.object({ diff --git a/packages/contracts/generated/api/console/files/zod.gen.ts b/packages/contracts/generated/api/console/files/zod.gen.ts index 34fe6d2aa3d..d3d35b401a3 100644 --- a/packages/contracts/generated/api/console/files/zod.gen.ts +++ b/packages/contracts/generated/api/console/files/zod.gen.ts @@ -64,7 +64,7 @@ export const zGetFilesSupportTypeResponse = zAllowedExtensionsResponse export const zGetFilesUploadResponse = zUploadConfig export const zPostFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), source: z.enum(['datasets']).optional(), }) diff --git a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts index e52940bb8a4..f0acbe06c0e 100644 --- a/packages/contracts/generated/api/console/installed-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/installed-apps/zod.gen.ts @@ -144,7 +144,9 @@ export const zTextToAudioPayload = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * WorkflowRunPayload diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts index 28cb3250d68..d5b6a62cfc4 100644 --- a/packages/contracts/generated/api/console/snippets/types.gen.ts +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -436,14 +436,14 @@ export type DeclaredOutputConfig = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' @@ -652,14 +652,14 @@ export type DeclaredArrayItem = { description?: string | null type?: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' [key: string]: unknown - } + } | null children?: Array<{ [key: string]: unknown }> description?: string | null file?: { [key: string]: unknown - } + } | null name: string required?: boolean type: 'array' | 'boolean' | 'file' | 'number' | 'object' | 'string' diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts index 491b9acdada..4474c465df9 100644 --- a/packages/contracts/generated/api/console/snippets/zod.gen.ts +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -660,10 +660,10 @@ export const zDeclaredArrayItem = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), @@ -1214,10 +1214,10 @@ export const zDeclaredOutputConfig = z.object({ description: z.string().nullish(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']).optional(), }) - .optional(), + .nullish(), children: z.array(z.record(z.string(), z.unknown())).optional(), description: z.string().nullish(), - file: z.record(z.string(), z.unknown()).optional(), + file: z.record(z.string(), z.unknown()).nullish(), name: z.string(), required: z.boolean().optional(), type: z.enum(['array', 'boolean', 'file', 'number', 'object', 'string']), diff --git a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts index 14d8bed2c92..9f6536e5190 100644 --- a/packages/contracts/generated/api/console/trial-apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/trial-apps/zod.gen.ts @@ -115,7 +115,9 @@ export const zTextToSpeechRequest = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * WorkflowRunRequest @@ -457,7 +459,7 @@ export const zGetTrialAppsByAppIdDatasetsQuery = z.object({ export const zGetTrialAppsByAppIdDatasetsResponse = zTrialDatasetListResponse export const zPostTrialAppsByAppIdFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), source: z.enum(['datasets']).optional(), }) diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index d06495b1e31..730d89367a7 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -307,6 +307,7 @@ import { zPostWorkspacesCurrentPluginUploadBundleResponse, zPostWorkspacesCurrentPluginUploadGithubBody, zPostWorkspacesCurrentPluginUploadGithubResponse, + zPostWorkspacesCurrentPluginUploadPkgBody, zPostWorkspacesCurrentPluginUploadPkgResponse, zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyPath, zPostWorkspacesCurrentRbacAccessPoliciesByPolicyIdCopyResponse, @@ -2055,6 +2056,7 @@ export const post43 = oc path: '/workspaces/current/plugin/upload/pkg', tags: ['console'], }) + .input(z.object({ body: zPostWorkspacesCurrentPluginUploadPkgBody })) .output(zPostWorkspacesCurrentPluginUploadPkgResponse) export const pkg3 = { diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 1600000227c..3a3b4ad620c 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -3865,7 +3865,9 @@ export type PostWorkspacesCurrentPluginUploadGithubResponse = PostWorkspacesCurrentPluginUploadGithubResponses[keyof PostWorkspacesCurrentPluginUploadGithubResponses] export type PostWorkspacesCurrentPluginUploadPkgData = { - body?: never + body: { + pkg: Blob | File + } path?: never query?: never url: '/workspaces/current/plugin/upload/pkg' diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index 0fd5ae091f6..1f1530897fa 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -245,7 +245,9 @@ export const zWorkspacePermissionResponse = z.object({ /** * BinaryFileResponse */ -export const zBinaryFileResponse = z.custom() +export const zBinaryFileResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * PluginAutoUpgradeChangeResponse @@ -4223,6 +4225,10 @@ export const zPostWorkspacesCurrentPluginUploadGithubBody = zParserGithubUpload */ export const zPostWorkspacesCurrentPluginUploadGithubResponse = zPluginDecodeResponse +export const zPostWorkspacesCurrentPluginUploadPkgBody = z.object({ + pkg: z.custom((value) => value instanceof Blob || value instanceof File), +}) + /** * Success */ @@ -5248,7 +5254,7 @@ export const zPostWorkspacesCustomConfigBody = zWorkspaceCustomConfigPayload export const zPostWorkspacesCustomConfigResponse = zWorkspaceTenantResultResponse export const zPostWorkspacesCustomConfigWebappLogoUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) /** diff --git a/packages/contracts/generated/api/service/zod.gen.ts b/packages/contracts/generated/api/service/zod.gen.ts index 2ec5f11174b..78fcc65cdad 100644 --- a/packages/contracts/generated/api/service/zod.gen.ts +++ b/packages/contracts/generated/api/service/zod.gen.ts @@ -112,7 +112,9 @@ export const zAppMetaResponse = z.object({ /** * AudioBinaryResponse */ -export const zAudioBinaryResponse = z.custom() +export const zAudioBinaryResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * AudioTranscriptResponse @@ -124,7 +126,9 @@ export const zAudioTranscriptResponse = z.object({ /** * BinaryFileResponse */ -export const zBinaryFileResponse = z.custom() +export const zBinaryFileResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) /** * ButtonStyle @@ -2452,7 +2456,7 @@ export const zPutAppsAnnotationsByAnnotationIdPath = z.object({ export const zPutAppsAnnotationsByAnnotationIdResponse = zAnnotation export const zPostAudioToTextBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), user: z.string().optional(), }) @@ -2591,7 +2595,7 @@ export const zPostDatasetsBody = zDatasetCreatePayload export const zPostDatasetsResponse = zDatasetDetailResponse export const zPostDatasetsPipelineFileUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) /** @@ -2670,7 +2674,7 @@ export const zPatchDatasetsByDatasetIdResponse = zDatasetDetailWithPartialMember export const zPostDatasetsByDatasetIdDocumentCreateByFileBody = z.object({ data: z.string().optional(), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostDatasetsByDatasetIdDocumentCreateByFilePath = z.object({ @@ -2695,7 +2699,7 @@ export const zPostDatasetsByDatasetIdDocumentCreateByTextResponse = zDocumentAnd export const zPostDatasetsByDatasetIdDocumentCreateByFile2Body = z.object({ data: z.string().optional(), - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), }) export const zPostDatasetsByDatasetIdDocumentCreateByFile2Path = z.object({ @@ -2745,7 +2749,9 @@ export const zPostDatasetsByDatasetIdDocumentsDownloadZipPath = z.object({ /** * ZIP archive containing the requested documents. */ -export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom() +export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zPostDatasetsByDatasetIdDocumentsMetadataBody = zMetadataOperationData @@ -2807,7 +2813,7 @@ export const zGetDatasetsByDatasetIdDocumentsByDocumentIdResponse = zDocumentDet export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdBody = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdPath = z.object({ @@ -2967,7 +2973,7 @@ export const zPatchDatasetsByDatasetIdDocumentsByDocumentIdSegmentsBySegmentIdCh export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFileBody = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFilePath = z.object({ @@ -2996,7 +3002,7 @@ export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByTextResponse = export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Body = z.object({ data: z.string().optional(), - file: z.custom().optional(), + file: z.custom((value) => value instanceof Blob || value instanceof File).optional(), }) export const zPostDatasetsByDatasetIdDocumentsByDocumentIdUpdateByFile2Path = z.object({ @@ -3167,7 +3173,7 @@ export const zGetEndUsersByEndUserIdPath = z.object({ export const zGetEndUsersByEndUserIdResponse = zEndUserDetail export const zPostFilesUploadBody = z.object({ - file: z.custom(), + file: z.custom((value) => value instanceof Blob || value instanceof File), user: z.string().optional(), }) @@ -3188,7 +3194,9 @@ export const zGetFilesByFileIdPreviewQuery = z.object({ /** * Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If `as_attachment` is `true`, the file is returned as a download with `Content-Disposition: attachment`. */ -export const zGetFilesByFileIdPreviewResponse = z.custom() +export const zGetFilesByFileIdPreviewResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zGetFormHumanInputByFormTokenPath = z.object({ form_token: z.string(), @@ -3271,7 +3279,9 @@ export const zPostTextToAudioBody = zTextToAudioPayloadWithUser /** * Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; otherwise the provider output is returned directly. */ -export const zPostTextToAudioResponse = z.custom() +export const zPostTextToAudioResponse = z.custom( + (value) => value instanceof Blob || value instanceof File, +) export const zGetWorkflowByTaskIdEventsPath = z.object({ task_id: z.string(), diff --git a/packages/contracts/openapi-ts.api.config.ts b/packages/contracts/openapi-ts.api.config.ts index 7d362f856d3..d1251df3657 100644 --- a/packages/contracts/openapi-ts.api.config.ts +++ b/packages/contracts/openapi-ts.api.config.ts @@ -3,6 +3,7 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { $, defineConfig } from '@hey-api/openapi-ts' +import ts from 'typescript' type JsonObject = Record @@ -497,7 +498,15 @@ const createApiConfig = (job: ApiJob): UserConfig => ({ if (ctx.schema.format === 'binary') return $(ctx.symbols.z) .attr('custom') - .call() + .call( + $.func((predicate) => { + const value = $.id('value') + const isBlob = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('Blob')) + const isFile = $.binary(value, ts.SyntaxKind.InstanceOfKeyword, $.id('File')) + predicate.param('value') + predicate.do($.return($.binary(isBlob, '||', isFile))) + }), + ) .generic($.type.or($.type('Blob'), $.type('File'))) if (ctx.schema.pattern === pydanticDecimalStringPattern) { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 79f6c4e23a9..4f900c881e5 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -20,7 +20,7 @@ "scripts": { "gen-api-contract": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi && uv run --project ../../api ../../api/dev/generate_fastopenapi_specs.py --output-dir openapi && node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api", "gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts", - "test": "vp test openapi-yaml.test.ts", + "test": "vp test", "type-check": "tsc" }, "dependencies": { diff --git a/packages/contracts/sandbox-contract.smoke.test.ts b/packages/contracts/sandbox-contract.smoke.test.ts index 84e721f14a7..c1e20b62905 100644 --- a/packages/contracts/sandbox-contract.smoke.test.ts +++ b/packages/contracts/sandbox-contract.smoke.test.ts @@ -1,26 +1,14 @@ -import assert from 'node:assert/strict' -import { registerHooks } from 'node:module' -import { dirname, resolve } from 'node:path' -import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' +import { sandbox as agentSandbox } from './generated/api/console/agent/orpc.gen' +import { sandbox as appSandbox } from './generated/api/console/apps/orpc.gen' -const thisDir = dirname(fileURLToPath(import.meta.url)) -const sourcePath = resolve(thisDir, './generated/api/console/apps/orpc.gen.ts') - -registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === './zod.gen' || specifier.endsWith('/zod.gen')) - return nextResolve(`${specifier}.ts`, context) - - return nextResolve(specifier, context) - }, +describe('generated sandbox contracts', () => { + it.each([ + ['Agent sandbox', agentSandbox], + ['App sandbox', appSandbox], + ])('exposes the %s file operations', (_, sandbox) => { + expect(sandbox.files.get).toBeDefined() + expect(sandbox.files.read.get).toBeDefined() + expect(sandbox.files.upload.post).toBeDefined() + }) }) - -const { agentSandbox, sandbox } = await import(pathToFileURL(sourcePath).href) - -assert.ok(agentSandbox.files.get) -assert.ok(agentSandbox.files.read.get) -assert.ok(agentSandbox.files.upload.post) - -assert.ok(sandbox.files.get) -assert.ok(sandbox.files.read.get) -assert.ok(sandbox.files.upload.post) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6fb00fd6ac..543627cf50d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -846,6 +846,15 @@ importers: '@dify/tsconfig': specifier: workspace:* version: link:../packages/tsconfig + '@orpc/client': + specifier: 'catalog:' + version: 1.14.8 + '@orpc/contract': + specifier: 'catalog:' + version: 1.14.8 + '@orpc/openapi-client': + specifier: 'catalog:' + version: 1.14.8 '@playwright/test': specifier: 'catalog:' version: 1.61.1 @@ -6038,9 +6047,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.1.0: - resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} - es-module-lexer@2.3.1: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} @@ -9280,7 +9286,7 @@ snapshots: '@amplitude/rrweb-snapshot@2.1.0': dependencies: - postcss: 8.5.17 + postcss: 8.5.19 '@amplitude/rrweb-types@2.0.0-alpha.40': {} @@ -13582,8 +13588,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.1.0: {} - es-module-lexer@2.3.1: {} es-toolkit@1.49.0: {} @@ -14103,6 +14107,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + fflate@0.4.8: {} fflate@0.7.4: {} @@ -14234,7 +14242,7 @@ snapshots: buffer-image-size: 0.6.4 entities: 7.0.1 whatwg-mimetype: 3.0.0 - ws: 8.21.0 + ws: 8.21.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -16825,8 +16833,8 @@ snapshots: tinyglobby@0.2.17: dependencies: - fdir: 6.5.0(picomatch@4.0.4) - picomatch: 4.0.4 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 tinypool@2.1.0: {} @@ -17373,7 +17381,7 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 @@ -17404,7 +17412,7 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 @@ -17435,7 +17443,7 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.1.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 obug: 2.1.3 diff --git a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx index 0ca184ebe46..094518c0726 100644 --- a/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx +++ b/web/app/components/workflow/block-selector/__tests__/tool-browser.spec.tsx @@ -1,4 +1,5 @@ import type { ReactElement } from 'react' +import type { Plugin } from '@/app/components/plugins/types' import { screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useMarketplacePlugins } from '@/app/components/plugins/marketplace/query' @@ -23,6 +24,16 @@ vi.mock('@/app/components/plugins/marketplace/query', () => ({ useMarketplacePlugins: vi.fn(), })) +vi.mock('@/app/components/workflow/block-selector/marketplace-plugin/list', () => ({ + default: ({ list }: { list: Plugin[] }) => ( +
+ {list.map((plugin) => ( +
{plugin.label.en_US}
+ ))} +
+ ), +})) + vi.mock('@/app/components/workflow/nodes/_base/components/mcp-tool-availability', () => ({ useMCPToolAvailability: () => ({ allowed: true, @@ -49,8 +60,20 @@ const mockUseTheme = vi.mocked(useTheme) const render = (ui: ReactElement, enableMarketplace = false) => renderWithConsoleQuery(ui, { systemFeatures: { enable_marketplace: enableMarketplace } }) -const createMarketplacePluginsMock = () => - ({ data: undefined }) as ReturnType +const createMarketplacePluginsMock = ( + overrides: Partial> = {}, +) => + ({ data: undefined, isFetching: false, ...overrides }) as ReturnType + +const createMarketplaceData = (plugins: Plugin[]) => ({ + pages: [{ plugins, total: plugins.length, page: 1, page_size: 40 }], + pageParams: [1], +}) + +const marketplaceTool = { + plugin_id: 'marketplace-tool', + label: { en_US: 'Marketplace Tool' }, +} as Plugin describe('ToolBrowser', () => { beforeEach(() => { @@ -223,7 +246,13 @@ describe('ToolBrowser', () => { expect(screen.queryByText('Other Toolkit')).not.toBeInTheDocument() }) - it('shows the empty state when no tool matches the current filter', async () => { + it('shows the empty state and request action when local and marketplace tools do not match', async () => { + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ + data: params ? createMarketplaceData([]) : undefined, + }), + ) + render( { workflowTools={[]} mcpTools={[]} />, + true, ) await waitFor(() => { expect(screen.getByText('workflow.tabs.noPluginsFound')).toBeInTheDocument() }) + expect(screen.getByRole('link', { name: 'workflow.tabs.requestToCommunity' })).toHaveAttribute( + 'href', + 'https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml', + ) + }) + + it('keeps matching local tools visible while marketplace results are loading', async () => { + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ isFetching: params !== undefined }), + ) + + render( + , + true, + ) + + await waitFor(() => { + expect(mockUseMarketplacePlugins).toHaveBeenLastCalledWith({ + query: 'local', + tags: [], + category: PluginCategoryEnum.tool, + }) + }) + expect(screen.getByText('Local Toolkit')).toBeInTheDocument() + expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument() + }) + + it('renders a marketplace result instead of the empty state', async () => { + mockUseMarketplacePlugins.mockImplementation((params) => + createMarketplacePluginsMock({ + data: params ? createMarketplaceData([marketplaceTool]) : undefined, + }), + ) + + render( + , + true, + ) + + expect(await screen.findByText('Marketplace Tool')).toBeInTheDocument() + expect(screen.queryByText('workflow.tabs.noPluginsFound')).not.toBeInTheDocument() }) it('debounces marketplace requests across search and tag changes', async () => {