test(e2e): validate generated console contracts (#39400)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-07-22 15:48:37 +08:00 committed by GitHub
parent c5aadfe557
commit acb5ee29e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 2122 additions and 2043 deletions

View File

@ -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.

View File

@ -729,6 +729,7 @@ class AgentBuildDraftCheckoutApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/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

View File

@ -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

View File

@ -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}},
},

View File

@ -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)<br> |
| 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)<br> |
### [POST] /workspaces/current/plugin/upload/pkg
#### Request Body
| Required | Schema |
| -------- | ------ |
| Yes | **multipart/form-data**: { **"pkg"**: binary }<br> |
#### 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, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No |
| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**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, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" }, **"children"**: [ object ], **"description"**: , **"file"**: object, **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No |
| children | [ { **"array_item"**: , **"children"**: [ object ], **"description"**: , **"file"**: , **"name"**: string, **"required"**: boolean, **"type"**: string, <br>**Available values:** "array", "boolean", "file", "number", "object", "string" } ] | | No |
| description | string | | No |
| failure_strategy | [DeclaredOutputFailureStrategy](#declaredoutputfailurestrategy) | | No |
| file | [DeclaredOutputFileConfig](#declaredoutputfileconfig) | | No |

View File

@ -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

View File

@ -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.

View File

@ -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.

View File

@ -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<void> {
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<string> {
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<AgentApiAccessResponse> {
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<ApiKeyItem> {
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,
}

View File

@ -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<AgentBuildDraftResponse> {
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<AgentBuildDraftResponse> {
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<boolean> {
const ctx = await createConsoleApiContext()
export async function agentBuildDraftExists(
client: ConsoleClient,
agentId: string,
): Promise<boolean> {
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<AgentBuildDraftResponse> {
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<void> {
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
}
}

View File

@ -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<AgentSkillUploadResponse> {
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<AgentSkillUploadResponse> {
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<AgentConfigFileRefConfig> {
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<AgentConfigSkillRefConfig> {
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<AgentConfigFileRefConfig> {
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<AgentConfigSkillRefConfig> {
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<AgentDriveSkillItemResponse[]> {
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<void> {
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<void> {
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<void> {
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<AgentDriveSkillItemResponse[]> {
const body: AgentDriveSkillListResponse = await client.agent.byAgentId.drive.skills.get({
params: { agent_id: agentId },
})
return body.items ?? []
}

View File

@ -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<AgentAppDetailWithSite> {
export async function createTestAgent(
client: ConsoleClient,
{
description = 'Created by Dify E2E.',
name = createE2EResourceName('Agent'),
role = 'E2E test assistant',
}: CreateTestAgentOptions = {},
): Promise<AgentAppDetailWithSite> {
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<AgentAppDetailWithSite> {
const agent = await createTestAgent(seed)
await saveAgentComposerDraft(agent.id, agentSoul)
export async function createConfiguredTestAgent(
client: ConsoleClient,
{
agentSoul = normalAgentSoulConfig,
seed,
}: {
agentSoul?: AgentSoulConfig
seed?: CreateTestAgentOptions
} = {},
): Promise<AgentAppDetailWithSite> {
const agent = await createTestAgent(client, seed)
await saveAgentComposerDraft(client, agent.id, agentSoul)
return agent
}
export async function getTestAgent(agentId: string): Promise<AgentAppDetailWithSite> {
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<void> {
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<AgentAppComposerResponse> {
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<AgentReferencingWorkflowResponse[]> {
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<AgentAppComposerResponse> {
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<void> {
const composer = await getAgentComposerDraft(agentId)
async function ensureAgentComposerDraftIsPublishable(
client: ConsoleClient,
agentId: string,
): Promise<void> {
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<void> {
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<void> {
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 },
})
}

View File

@ -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<PreseededResource> {
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,
}
}

View File

@ -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<PreseededResource> {
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<PreseededResource> {
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<PreseededResource> {
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<PreseededResource> {
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<PreseededResource> {
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<PreseededResource> {
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
}

View File

@ -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<T extends NamedResource = NamedResource> = {
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 <T extends NamedResource = NamedResource>({
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<T>
return body.data.find((item) => item.name === resourceName)
} finally {
await ctx.dispose()
}
}
export const buildQuery = (params: Record<string, string>) => 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 = <T extends NamedResource>(resources: T[], resourceName: string) =>
resources.find((item) => item.name === resourceName)
export const isRecord = (value: unknown): value is Record<string, unknown> =>
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)

View File

@ -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<DatasetListItemResponse>({
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<PreseededResource> {
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,
)

View File

@ -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<NonNullable<DifyWorld['agentBuilder']['fixtures']['stableModel']>> {
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<NonNullable<DifyWorld['agentBuilder']['fixtures']['stableModel']>> {
return requireAgentBuilderModel(world, readAgentBuilderStableChatModelConfig(), {
return requireAgentBuilderModel(world, client, readAgentBuilderStableChatModelConfig(), {
requireActive: true,
})
}
export async function requireAgentBuilderSpeechToTextModel(
world: DifyWorld,
client: ConsoleClient,
): Promise<NonNullable<DifyWorld['agentBuilder']['fixtures']['speechToTextModel']>> {
const ctx = await createConsoleApiContext()
let defaultModel: NonNullable<DefaultModelDataResponse['data']>
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<NonNullable<DifyWorld['agentBuilder']['fixtures']['stableModel']>> {
return requireAgentBuilderModel(world, readAgentBuilderAgentDecisionChatModelConfig(), {
return requireAgentBuilderModel(world, client, readAgentBuilderAgentDecisionChatModelConfig(), {
requireActive: true,
})
}

View File

@ -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<PreseededResource> {
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,
}
}

View File

@ -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<string, unknown>,
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<UploadedConsoleFile> => {
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<DatasetListItemResponse>({
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

View File

@ -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<void> {
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<void> {
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 } })
}

View File

@ -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

View File

@ -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

View File

@ -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)
},

View File

@ -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,
)

View File

@ -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
},
)

View File

@ -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)
},

View File

@ -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

View File

@ -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<ReturnType<typeof getAgentBuildDraft>>) =>
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,

View File

@ -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<DifyWorl
export const uploadSummaryConfigSkillForBuildDraft = async (world: DifyWorld) => {
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 })
}

View File

@ -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<string, unknown>)
@ -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<string, unknown>)

View File

@ -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(

View File

@ -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
},

View File

@ -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) => {

View File

@ -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<DeclaredOutputConfig[]> => {
const data = await getAgentV2WorkflowNodeData(appId)
const getDeclaredOutputsFromDraft = async (
world: DifyWorld,
appId: string,
): Promise<DeclaredOutputConfig[]> => {
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)

View File

@ -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(

View File

@ -27,7 +27,7 @@ Given(
)
}
const agent = await createConfiguredTestAgent({
const agent = await createConfiguredTestAgent(this.getConsoleClient(), {
agentSoul: createAgentSoulConfigWithSpeechToText(normalAgentSoulConfig),
})
this.createdAgentIds.push(agent.id)

View File

@ -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()
},
)

View File

@ -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)
},
)

View File

@ -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

View File

@ -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)

View File

@ -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) {

View File

@ -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)
},

View File

@ -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)
})

View File

@ -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) {

View File

@ -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) {

View File

@ -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 },
},
)
},
})),
]

View File

@ -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> | void
export type CreatedAgentDriveFile = {
@ -76,6 +79,8 @@ export type AgentBuilderWorldState = ReturnType<typeof createAgentBuilderWorldSt
export class DifyWorld extends World {
context: BrowserContext | undefined
consoleRequestContext: APIRequestContext | undefined
consoleClient: ConsoleClient | undefined
page: Page | undefined
consoleErrors: string[] = []
pageErrors: string[] = []
@ -132,6 +137,11 @@ export class DifyWorld extends World {
...(authenticated ? { storageState: authStatePath } : {}),
})
this.context.setDefaultTimeout(30_000)
this.consoleRequestContext = await request.newContext({
baseURL: apiURL,
storageState: authStatePath,
})
this.consoleClient = createConsoleClient({ requestContext: this.consoleRequestContext })
this.context.on('console', (message) => {
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

View File

@ -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 <T>(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<T>
}
const postConsoleAPI = async (
context: BrowserContext,
pathname: string,
const validateInitPasswordIfNeeded = async (
client: ReturnType<typeof createConsoleClient>,
deadline: number,
data: Record<string, unknown>,
) => {
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<InitStatusResponse>(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<typeof createConsoleClient>,
deadline: number,
): Promise<AuthBootstrapResult> => {
const setupStatus = await getConsoleAPI<SetupStatusResponse>(
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<typeof createConsoleClient>, 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 })

View File

@ -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:",

View File

@ -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<ReturnType<typeof createStandaloneConsoleSession>> | 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)

View File

@ -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<PostAppsResponse> {
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<void> {
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 })
}

View File

@ -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<APIRequestContext, 'fetch' | 'storageState'>
export type ConsoleClient = JsonifiedClient<
ContractRouterClient<typeof consoleRouterContract, ConsoleClientContext>
>
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<ConsoleClientContext>(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<ConsoleClientContext>(consoleRouterContract),
new ResponseValidationPlugin<ConsoleClientContext>(consoleRouterContract),
],
url: new URL('/console/api/', apiBaseURL).toString(),
})
return createORPCClient<ConsoleClient>(link)
}

View File

@ -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<void> {
if (response.ok()) return
const body = await response.text().catch(() => '')
throw new Error(`${action} failed with ${response.status()} ${response.statusText()}: ${body}`)
}

View File

@ -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(),
}
}

View File

@ -1,11 +0,0 @@
import { createConsoleApiContext, expectApiResponseOK } from './console-context'
export async function deleteTestDataset(datasetId: string): Promise<void> {
const ctx = await createConsoleApiContext()
try {
const response = await ctx.delete(`/console/api/datasets/${datasetId}`)
await expectApiResponseOK(response, `Delete dataset ${datasetId}`)
} finally {
await ctx.dispose()
}
}

View File

@ -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<PostWorkspacesCurrentPluginListLatestVersionsResponse> {
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<PostWorkspacesCurrentPluginListInstallationsIdsResponse> {
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<GetWorkspacesCurrentPluginTasksByTaskIdResponse> {
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<PostWorkspacesCurrentPluginInstallMarketplaceResponse> {
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<PostWorkspacesCurrentPluginUploadPkgResponse> {
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<PostWorkspacesCurrentPluginInstallPkgResponse> {
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()
}
}

View File

@ -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<APIRequestContext, 'fetch'>
type OpenAPIFetch = NonNullable<OpenAPILinkOptions<ConsoleClientContext>['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()
}
}
}

View File

@ -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<void> {
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()
}
}

View File

@ -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<GetAppsByAppIdResponse> {
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<string> {
await setAppSiteEnabled(appId, true)
return getAppSiteURL(await getAppSiteDetail(appId))
}
export async function setAppSiteEnabled(appId: string, enabled: boolean): Promise<void> {
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()
}
}

View File

@ -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<GetAppsByAppIdWorkflowsDraftResponse> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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 } })
}

View File

@ -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<string, unknown> =>
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<SeedResult> => {
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)

View File

@ -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<string, SeedResource>
}

View File

@ -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<string, string>
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<APIRequestContext['fetch']>(async () => response)
const context = {
fetch,
storageState: vi.fn<APIRequestContext['storageState']>(async () => ({
cookies: [
{
domain: 'api.test',
expires: -1,
httpOnly: false,
name: 'csrf_token',
path: '/',
sameSite: 'Lax',
secure: false,
value: csrfToken,
},
],
origins: [],
})),
} satisfies Pick<APIRequestContext, 'fetch' | 'storageState'>
return { context, fetch }
}
const callPlaywrightFetch = (requestContext: Pick<APIRequestContext, 'fetch'>, 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()
})
})

View File

@ -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()
})
})

View File

@ -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)
})
})

View File

@ -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
}

View File

@ -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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
})
export const zPostAgentByAgentIdSkillsUploadPath = z.object({

View File

@ -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'

View File

@ -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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
})
export const zPostAppsByAppIdAudioToTextPath = z.object({

View File

@ -64,7 +64,7 @@ export const zGetFilesSupportTypeResponse = zAllowedExtensionsResponse
export const zGetFilesUploadResponse = zUploadConfig
export const zPostFilesUploadBody = z.object({
file: z.custom<Blob | File>(),
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
source: z.enum(['datasets']).optional(),
})

View File

@ -144,7 +144,9 @@ export const zTextToAudioPayload = z.object({
/**
* AudioBinaryResponse
*/
export const zAudioBinaryResponse = z.custom<Blob | File>()
export const zAudioBinaryResponse = z.custom<Blob | File>(
(value) => value instanceof Blob || value instanceof File,
)
/**
* WorkflowRunPayload

View File

@ -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'

View File

@ -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']),

View File

@ -115,7 +115,9 @@ export const zTextToSpeechRequest = z.object({
/**
* AudioBinaryResponse
*/
export const zAudioBinaryResponse = z.custom<Blob | File>()
export const zAudioBinaryResponse = z.custom<Blob | File>(
(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<Blob | File>(),
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
source: z.enum(['datasets']).optional(),
})

View File

@ -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 = {

View File

@ -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'

View File

@ -245,7 +245,9 @@ export const zWorkspacePermissionResponse = z.object({
/**
* BinaryFileResponse
*/
export const zBinaryFileResponse = z.custom<Blob | File>()
export const zBinaryFileResponse = z.custom<Blob | File>(
(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<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
})
/**

View File

@ -112,7 +112,9 @@ export const zAppMetaResponse = z.object({
/**
* AudioBinaryResponse
*/
export const zAudioBinaryResponse = z.custom<Blob | File>()
export const zAudioBinaryResponse = z.custom<Blob | File>(
(value) => value instanceof Blob || value instanceof File,
)
/**
* AudioTranscriptResponse
@ -124,7 +126,9 @@ export const zAudioTranscriptResponse = z.object({
/**
* BinaryFileResponse
*/
export const zBinaryFileResponse = z.custom<Blob | File>()
export const zBinaryFileResponse = z.custom<Blob | File>(
(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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>()
export const zPostDatasetsByDatasetIdDocumentsDownloadZipResponse = z.custom<Blob | File>(
(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<Blob | File>().optional(),
file: z.custom<Blob | File>((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<Blob | File>().optional(),
file: z.custom<Blob | File>((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<Blob | File>().optional(),
file: z.custom<Blob | File>((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<Blob | File>(),
file: z.custom<Blob | File>((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<Blob | File>()
export const zGetFilesByFileIdPreviewResponse = z.custom<Blob | File>(
(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<Blob | File>()
export const zPostTextToAudioResponse = z.custom<Blob | File>(
(value) => value instanceof Blob || value instanceof File,
)
export const zGetWorkflowByTaskIdEventsPath = z.object({
task_id: z.string(),

View File

@ -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<string, unknown>
@ -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) {

View File

@ -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": {

View File

@ -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)

32
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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[] }) => (
<div>
{list.map((plugin) => (
<div key={plugin.plugin_id}>{plugin.label.en_US}</div>
))}
</div>
),
}))
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<typeof useMarketplacePlugins>
const createMarketplacePluginsMock = (
overrides: Partial<ReturnType<typeof useMarketplacePlugins>> = {},
) =>
({ data: undefined, isFetching: false, ...overrides }) as ReturnType<typeof useMarketplacePlugins>
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(
<ToolBrowser
searchText="missing"
@ -234,11 +263,74 @@ describe('ToolBrowser', () => {
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(
<ToolBrowser
searchText="local"
tags={[]}
onSelect={vi.fn()}
buildInTools={[
createToolProvider({
id: 'provider-local',
label: { en_US: 'Local Toolkit', zh_Hans: 'Local Toolkit' },
}),
]}
customTools={[]}
workflowTools={[]}
mcpTools={[]}
/>,
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(
<ToolBrowser
searchText="marketplace"
tags={[]}
onSelect={vi.fn()}
buildInTools={[]}
customTools={[]}
workflowTools={[]}
mcpTools={[]}
/>,
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 () => {