mirror of
https://github.com/langgenius/dify.git
synced 2026-07-15 09:27:36 +08:00
test(e2e): cover agent speech-to-text
This commit is contained in:
@ -209,10 +209,13 @@ Feature: Create dataset
|
||||
- `@fresh` — only runs in `e2e:full` mode (requires uninitialized instance)
|
||||
- `@external-model` — scenario execution can call a real model provider. Use this only for runtime requests, not for scenarios that only require an active model fixture.
|
||||
- `@external-tool` — scenario execution can call a real third-party tool provider. Use this only for runtime tool execution, not for plugin installation, discovery, or local deterministic tools.
|
||||
- `@microphone` — runs the scenario in an isolated Chromium instance backed by the checked-in fake audio fixture and grants microphone permission only to that scenario context.
|
||||
- `@skip` — excluded from all runs
|
||||
|
||||
External runtime commands are opt-in. `pnpm -C e2e e2e:external:prepare` reads `E2E_EXTERNAL_RUNTIME_SEED_SPECS`, defaulting to `agent-v2:external-runtime`, and runs the matching seed packs before the external suite. `pnpm -C e2e e2e:external` reads `E2E_EXTERNAL_RUNTIME_TAGS`, defaulting to `(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview`.
|
||||
|
||||
The Agent v2 external runtime seed also prepares the workspace default Speech-to-Text model. `E2E_SPEECH_TO_TEXT_MODEL_PROVIDER` and `E2E_SPEECH_TO_TEXT_MODEL_NAME` select an existing model or the model configured through `E2E_MODEL_PROVIDER_CREDENTIALS_JSON`; they default to `openai` and `gpt-4o-mini-transcribe`.
|
||||
|
||||
Some external runtime scenarios need feature-owned services in addition to a real model or tool provider. Do not overload `@external-model` or `@external-tool` to mean those services are available. For Agent v2, scenarios that require the standalone `dify-agent` run server use the feature tag `@agent-backend-runtime` plus the explicit step `the Agent v2 runtime backend is available`. Run them with `E2E_START_AGENT_BACKEND=1` to let E2E start `dify-agent` and the shellctl local sandbox required by its `dify.config`/`dify.shell` runtime layers, or set `E2E_AGENT_BACKEND_URL`/`AGENT_BACKEND_BASE_URL` when an existing server should be reused.
|
||||
|
||||
Keep scenarios short and declarative. Each step should describe **what** the user does, not **how** the UI works.
|
||||
|
||||
@ -36,6 +36,8 @@ Use tags in three layers:
|
||||
- `@publish` — publish and publish-bar state.
|
||||
- `@access-point` — Web app, Backend service API, and Workflow access surfaces.
|
||||
- `@stable-model` — active model fixture dependency. Apply this to every scenario that includes `the Agent Builder stable chat model is available` or otherwise requires an active model configured in the workspace.
|
||||
- `@speech-to-text-model` — active workspace default Speech-to-Text model dependency. Apply this to scenarios that include `the workspace default speech-to-text model is active`.
|
||||
- `@microphone` — deterministic Chromium fake microphone dependency. The scenario hook launches a fake-audio browser and grants microphone permission only to tagged scenarios.
|
||||
- `@agent-decision-model` — stronger active model fixture dependency for scenarios that specifically validate Agent autonomous planning or resource-selection behavior, such as generated-query Knowledge Retrieval. Do not use it for scenarios that only need a model to exist or answer a deterministic prompt.
|
||||
- `@tool-fixture` — preseeded Tool dependency such as `JSON Process / JSON Replace` or `Tavily / Tavily Search`.
|
||||
- `@skill-fixture` — checked-in or preseeded Skill dependency such as `e2e-summary-skill`.
|
||||
@ -75,6 +77,7 @@ Keep Agent v2 step definitions grouped by user capability, not by DOM component
|
||||
- `access-point-service-api.steps.ts` — Backend service API entrypoints, keys, API reference, and service requests.
|
||||
- `access-point-workflow.steps.ts` — Workflow access references.
|
||||
- `preflight.steps.ts` — explicit `Given` entrypoints for Agent Builder preflight resources.
|
||||
- `speech-to-text.steps.ts` — Agent Build voice input, multipart request, and transcribed input behavior.
|
||||
|
||||
Cucumber step definitions are globally registered. Do not duplicate the same step text across files, even if one is written as `Given` and another as `Then`.
|
||||
|
||||
@ -87,6 +90,7 @@ Agent v2 business state belongs under `world.agentBuilder`; do not keep adding A
|
||||
Use the existing namespace shape:
|
||||
|
||||
- `world.agentBuilder.preflight.stableModel`
|
||||
- `world.agentBuilder.preflight.speechToTextModel`
|
||||
- `world.agentBuilder.preflight.brokenModel`
|
||||
- `world.agentBuilder.preflight.preseededResources`
|
||||
- `world.agentBuilder.accessPoint.serviceApiBaseURL`
|
||||
@ -98,6 +102,7 @@ Use the existing namespace shape:
|
||||
- `world.agentBuilder.accessPoint.workflowReferencePage`
|
||||
- `world.agentBuilder.accessPoint.composerDraftSnapshot`
|
||||
- `world.agentBuilder.configure.concurrentPage`
|
||||
- `world.agentBuilder.speechToText.request`
|
||||
- `world.agentBuilder.workflow.agentConsolePage`
|
||||
- `world.agentBuilder.workflow.outputVariables`
|
||||
|
||||
@ -163,6 +168,8 @@ Use `the Agent v2 runtime backend is available` before scenarios tagged `@agent-
|
||||
|
||||
Use `the Agent Builder stable chat model is available` before scenarios that need a real Agent Soul model configuration. This includes true runtime scenarios, model-backed build-mode assertions, and Workflow Agent v2 node setup because the backend rejects Agent nodes without model config. Do not add the model preflight to pure navigation or identity checks unless the setup API itself requires model config. `E2E_STABLE_MODEL_PROVIDER`, `E2E_STABLE_MODEL_NAME`, and optional `E2E_STABLE_MODEL_TYPE` are selectors for a model already configured in the workspace; they are not provider credentials. The step defaults to `openai` / `gpt-5-nano` / `llm`, verifies the selected model is present and `active` through `/console/api/workspaces/current/models/model-types/{type}`, then stores it on `DifyWorld.agentBuilder.preflight.stableModel`.
|
||||
|
||||
Use `the workspace default speech-to-text model is active` before real speech-to-text scenarios. The preflight reads `/console/api/workspaces/current/default-model?model_type=speech2text`, verifies that same provider/model is active in the Speech-to-Text model list, and stores it on `DifyWorld.agentBuilder.preflight.speechToTextModel`. It must not configure a model or provider credential. External runtime preparation selects `E2E_SPEECH_TO_TEXT_MODEL_PROVIDER` / `E2E_SPEECH_TO_TEXT_MODEL_NAME`, defaulting to `openai` / `gpt-4o-mini-transcribe`, reuses `E2E_MODEL_PROVIDER_CREDENTIALS_JSON` when provider setup is required, and selects the active model as the workspace default.
|
||||
|
||||
Use `the Agent Builder agent-decision chat model is available` before scenarios that need a stronger model to exercise Agent autonomous planning, generated query selection, or tool/resource choice. `E2E_AGENT_DECISION_MODEL_PROVIDER`, `E2E_AGENT_DECISION_MODEL_NAME`, and optional `E2E_AGENT_DECISION_MODEL_TYPE` are selectors for a second active model fixture, defaulting to `openai` / `gpt-5.5` / `llm`. The step stores the model on `DifyWorld.agentBuilder.preflight.agentDecisionModel`. Do not use this fixture as a broad replacement for `@stable-model`; it is intentionally narrower and costlier.
|
||||
|
||||
Keep `@stable-model` on Build draft apply scenarios that click `Apply`. The current product path calls `/build-chat/finalize` before applying the draft, and the backend returns `model is required` when the Agent Soul has no model config. Discard-only and pending-draft isolation scenarios can stay model-free when they do not finalize the Build draft.
|
||||
|
||||
@ -14,6 +14,11 @@ Feature: Agent Builder preseeded environment
|
||||
Given I am signed in as the default E2E admin
|
||||
And the Agent Builder stable chat model is available
|
||||
|
||||
@speech-to-text-model
|
||||
Scenario: Default speech-to-text model is available
|
||||
Given I am signed in as the default E2E admin
|
||||
And the workspace default speech-to-text model is active
|
||||
|
||||
@agent-decision-model
|
||||
Scenario: Agent-decision chat model is available
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
12
e2e/features/agent-v2/speech-to-text.feature
Normal file
12
e2e/features/agent-v2/speech-to-text.feature
Normal file
@ -0,0 +1,12 @@
|
||||
@agent-v2 @authenticated
|
||||
Feature: Agent v2 speech-to-text
|
||||
@build @speech-to-text @microphone @external-model @speech-to-text-model
|
||||
Scenario: Recorded speech is transcribed into the current Agent input
|
||||
Given I am signed in as the default E2E admin
|
||||
And the workspace default speech-to-text model is active
|
||||
And an Agent v2 test agent with speech-to-text enabled has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
And I start Agent v2 voice input
|
||||
And I stop Agent v2 voice input after the fixture speech has played
|
||||
Then the Agent v2 speech-to-text request should succeed
|
||||
And the transcribed fixture phrase "Purple elephant seven" should appear in the Agent v2 input
|
||||
@ -1,5 +1,6 @@
|
||||
export const agentBuilderPreseededResources = {
|
||||
stableChatModel: 'E2E Stable Chat Model',
|
||||
speechToTextModel: 'Workspace default Speech-to-Text model',
|
||||
summarySkill: 'e2e-summary-skill',
|
||||
jsonReplaceTool: 'JSON Process / JSON Replace',
|
||||
tavilySearchTool: 'Tavily / Tavily Search',
|
||||
|
||||
@ -86,6 +86,20 @@ export function createAgentSoulConfigWithModel(
|
||||
}
|
||||
}
|
||||
|
||||
export function createAgentSoulConfigWithSpeechToText(
|
||||
agentSoul: AgentSoulConfig,
|
||||
): AgentSoulConfig {
|
||||
return {
|
||||
...agentSoul,
|
||||
app_features: {
|
||||
...agentSoul.app_features,
|
||||
speech_to_text: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createPublishableAgentSoulConfig(agentSoul: AgentSoulConfig): AgentSoulConfig {
|
||||
if (agentSoul.model)
|
||||
return agentSoul
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { ProviderWithModelsResponse } from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type {
|
||||
DefaultModelDataResponse,
|
||||
ProviderWithModelsResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { DifyWorld } from '../../../support/world'
|
||||
import { createApiContext, expectApiResponseOK } from '../../../../support/api'
|
||||
import { agentBuilderPreseededResources } from '../agent-builder-resources'
|
||||
@ -152,6 +155,45 @@ export async function skipMissingAgentBuilderStableChatModel(
|
||||
})
|
||||
}
|
||||
|
||||
export async function skipMissingAgentBuilderSpeechToTextModel(
|
||||
world: DifyWorld,
|
||||
): Promise<'skipped' | NonNullable<DifyWorld['agentBuilder']['preflight']['speechToTextModel']>> {
|
||||
const ctx = await createApiContext()
|
||||
let defaultModel: NonNullable<DefaultModelDataResponse['data']>
|
||||
|
||||
try {
|
||||
const response = await ctx.get(
|
||||
'/console/api/workspaces/current/default-model?model_type=speech2text',
|
||||
)
|
||||
await expectApiResponseOK(response, `Check ${agentBuilderPreseededResources.speechToTextModel}`)
|
||||
const body = (await response.json()) as DefaultModelDataResponse
|
||||
if (!body.data) {
|
||||
return skipBlockedPrecondition(
|
||||
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()
|
||||
}
|
||||
|
||||
return skipMissingAgentBuilderModel(world, {
|
||||
ok: true,
|
||||
provider: defaultModel.provider.provider,
|
||||
resourceName: agentBuilderPreseededResources.speechToTextModel,
|
||||
type: 'speech2text',
|
||||
value: defaultModel.model,
|
||||
}, {
|
||||
requireActive: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function skipMissingAgentBuilderAgentDecisionChatModel(
|
||||
world: DifyWorld,
|
||||
): Promise<'skipped' | NonNullable<DifyWorld['agentBuilder']['preflight']['stableModel']>> {
|
||||
|
||||
@ -8,6 +8,7 @@ import type {
|
||||
} from '@dify/contracts/api/console/datasets/types.gen'
|
||||
import type {
|
||||
AvailableModelListResponse,
|
||||
DefaultModelDataResponse,
|
||||
ModelProviderListResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { SeedContext, SeedResource, SeedTask } from '../../../support/seed'
|
||||
@ -79,6 +80,8 @@ type ToolResource = SeedResource & {
|
||||
}
|
||||
|
||||
const modelCredentialEnv = 'E2E_MODEL_PROVIDER_CREDENTIALS_JSON'
|
||||
const speechToTextModelProviderEnv = 'E2E_SPEECH_TO_TEXT_MODEL_PROVIDER'
|
||||
const speechToTextModelNameEnv = 'E2E_SPEECH_TO_TEXT_MODEL_NAME'
|
||||
const marketplacePluginIdsEnv = 'E2E_MARKETPLACE_PLUGIN_IDS'
|
||||
const marketplacePluginUniqueIdentifiersEnv = 'E2E_MARKETPLACE_PLUGIN_UNIQUE_IDENTIFIERS'
|
||||
const oauthToolCredentialIdEnv = 'E2E_OAUTH_TOOL_CREDENTIAL_ID'
|
||||
@ -114,6 +117,12 @@ const agentDecisionModelConfig = (): StableModel => ({
|
||||
type: process.env.E2E_AGENT_DECISION_MODEL_TYPE?.trim() || 'llm',
|
||||
})
|
||||
|
||||
const speechToTextModelConfig = (): StableModel => ({
|
||||
name: process.env[speechToTextModelNameEnv]?.trim() || 'gpt-4o-mini-transcribe',
|
||||
provider: process.env[speechToTextModelProviderEnv]?.trim() || 'openai',
|
||||
type: 'speech2text',
|
||||
})
|
||||
|
||||
const parseJsonEnv = (envName: string) => {
|
||||
const raw = process.env[envName]?.trim()
|
||||
if (!raw)
|
||||
@ -134,7 +143,7 @@ const parseJsonEnv = (envName: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const findChatModel = async (config: StableModel, title: string) => {
|
||||
const findModel = async (config: StableModel, title: string) => {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.get(`/console/api/workspaces/current/models/model-types/${config.type}`)
|
||||
@ -248,14 +257,14 @@ const upsertStableProviderCredential = async (
|
||||
}
|
||||
}
|
||||
|
||||
const seedChatModel = async (context: SeedContext, {
|
||||
const seedModel = async (context: SeedContext, {
|
||||
config,
|
||||
title,
|
||||
}: {
|
||||
config: StableModel
|
||||
title: string
|
||||
}) => {
|
||||
const existing = await findChatModel(config, title)
|
||||
const existing = await findModel(config, title)
|
||||
const resource = {
|
||||
id: `${existing?.provider ?? config.provider}/${existing?.name ?? config.name}`,
|
||||
kind: 'model',
|
||||
@ -313,7 +322,7 @@ const seedChatModel = async (context: SeedContext, {
|
||||
}
|
||||
}
|
||||
|
||||
const seeded = await findChatModel(config, title)
|
||||
const seeded = await findModel(config, title)
|
||||
if (seeded?.status !== activeModelStatus) {
|
||||
return blocked(
|
||||
title,
|
||||
@ -328,16 +337,94 @@ const seedChatModel = async (context: SeedContext, {
|
||||
})
|
||||
}
|
||||
|
||||
const seedStableModel = async (context: SeedContext) => seedChatModel(context, {
|
||||
const seedStableModel = async (context: SeedContext) => seedModel(context, {
|
||||
config: stableModelConfig(),
|
||||
title: agentBuilderPreseededResources.stableChatModel,
|
||||
})
|
||||
|
||||
const seedAgentDecisionModel = async (context: SeedContext) => seedChatModel(context, {
|
||||
const seedAgentDecisionModel = async (context: SeedContext) => seedModel(context, {
|
||||
config: agentDecisionModelConfig(),
|
||||
title: agentBuilderPreseededResources.agentDecisionChatModel,
|
||||
})
|
||||
|
||||
const getDefaultModel = async (modelType: string) => {
|
||||
const ctx = await createApiContext()
|
||||
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 createApiContext()
|
||||
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)
|
||||
if (!model || model.status !== activeModelStatus)
|
||||
return blocked(title, `${config.provider}/${config.name} is not active after model setup.`)
|
||||
|
||||
const resource = {
|
||||
id: `${model.provider}/${model.name}`,
|
||||
kind: 'model',
|
||||
name: title,
|
||||
}
|
||||
const defaultModel = await getDefaultModel(config.type)
|
||||
const isExpectedDefault = defaultModel?.model === model.name
|
||||
&& matchesProvider(defaultModel.provider.provider, model.provider)
|
||||
|
||||
if (isExpectedDefault)
|
||||
return modelResult.status === 'updated' ? modelResult : verified(title, resource)
|
||||
|
||||
if (context.dryRun)
|
||||
return skipped(title, `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,
|
||||
})
|
||||
|
||||
const updatedDefaultModel = await getDefaultModel(config.type)
|
||||
if (updatedDefaultModel?.model !== model.name
|
||||
|| !matchesProvider(updatedDefaultModel.provider.provider, model.provider)) {
|
||||
return blocked(
|
||||
title,
|
||||
`${model.provider}/${model.name} was not selected as the workspace default Speech-to-Text model.`,
|
||||
)
|
||||
}
|
||||
|
||||
return updated(title, resource)
|
||||
}
|
||||
|
||||
type BuiltinToolProvider = {
|
||||
label?: { en_US?: string, zh_Hans?: string }
|
||||
name: string
|
||||
@ -1011,12 +1098,21 @@ const agentV2FullSeedTasks = (): SeedTask[] => [
|
||||
},
|
||||
]
|
||||
|
||||
const agentV2ExternalRuntimeSeedTasks = (): SeedTask[] => [
|
||||
...agentV2BaseSeedTasks(),
|
||||
{
|
||||
id: 'speech-to-text-model',
|
||||
title: agentBuilderPreseededResources.speechToTextModel,
|
||||
run: seedSpeechToTextModel,
|
||||
},
|
||||
]
|
||||
|
||||
export const createAgentV2SeedTasks = (profile: string = 'full'): SeedTask[] => {
|
||||
if (profile === 'full')
|
||||
return agentV2FullSeedTasks()
|
||||
|
||||
if (profile === 'external-runtime')
|
||||
return agentV2BaseSeedTasks()
|
||||
return agentV2ExternalRuntimeSeedTasks()
|
||||
|
||||
throw new Error(`Unknown Agent V2 seed profile "${profile}".`)
|
||||
}
|
||||
|
||||
@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
skipMissingAgentBuilderAgentDecisionChatModel,
|
||||
skipMissingAgentBuilderBrokenChatModel,
|
||||
skipMissingAgentBuilderSpeechToTextModel,
|
||||
skipMissingAgentBuilderStableChatModel,
|
||||
} from '../../agent-v2/support/preflight/models'
|
||||
import { skipMissingPreseededTool } from '../../agent-v2/support/preflight/tools'
|
||||
@ -35,6 +36,14 @@ Given('the Agent Builder stable chat model is available', async function (this:
|
||||
this.agentBuilder.preflight.stableModel = stableModel
|
||||
})
|
||||
|
||||
Given('the workspace default speech-to-text model is active', async function (this: DifyWorld) {
|
||||
const speechToTextModel = await skipMissingAgentBuilderSpeechToTextModel(this)
|
||||
if (speechToTextModel === 'skipped')
|
||||
return speechToTextModel
|
||||
|
||||
this.agentBuilder.preflight.speechToTextModel = speechToTextModel
|
||||
})
|
||||
|
||||
Given('the Agent Builder agent-decision chat model is available', async function (this: DifyWorld) {
|
||||
const agentDecisionModel = await skipMissingAgentBuilderAgentDecisionChatModel(this)
|
||||
if (agentDecisionModel === 'skipped')
|
||||
|
||||
@ -0,0 +1,92 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { voiceInputTestMaterial } from '../../../support/test-materials'
|
||||
import { createConfiguredTestAgent } from '../../agent-v2/support/agent'
|
||||
import {
|
||||
createAgentSoulConfigWithSpeechToText,
|
||||
normalAgentSoulConfig,
|
||||
} from '../../agent-v2/support/agent-soul'
|
||||
import { getCurrentAgentId } from './configure-helpers'
|
||||
|
||||
const getAgentInput = (world: DifyWorld) =>
|
||||
world.getPage().getByPlaceholder('Describe what your agent should do')
|
||||
|
||||
const escapeRegExp = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
Given(
|
||||
'an Agent v2 test agent with speech-to-text enabled has been created via API',
|
||||
async function (this: DifyWorld) {
|
||||
if (!this.agentBuilder.preflight.speechToTextModel) {
|
||||
throw new Error(
|
||||
'Create a speech-to-text Agent v2 test agent after the default Speech-to-Text model preflight.',
|
||||
)
|
||||
}
|
||||
|
||||
const agent = await createConfiguredTestAgent({
|
||||
agentSoul: createAgentSoulConfigWithSpeechToText(normalAgentSoulConfig),
|
||||
})
|
||||
this.createdAgentIds.push(agent.id)
|
||||
this.lastCreatedAgentName = agent.name
|
||||
this.lastCreatedAgentRole = agent.role ?? undefined
|
||||
},
|
||||
)
|
||||
|
||||
When('I start Agent v2 voice input', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await expect(getAgentInput(this)).toBeVisible({ timeout: 30_000 })
|
||||
await page.getByRole('button', { name: 'Voice input' }).click()
|
||||
await expect(page.getByText('Speak now...')).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
|
||||
When(
|
||||
'I stop Agent v2 voice input after the fixture speech has played',
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const agentId = getCurrentAgentId(this)
|
||||
|
||||
await expect(page.getByTestId('voice-input-timer')).toHaveText(
|
||||
voiceInputTestMaterial.recordingDuration,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
const responsePromise = page.waitForResponse(response => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname === `/console/api/agent/${agentId}/audio-to-text`
|
||||
))
|
||||
await page.getByTestId('voice-input-stop').click()
|
||||
const response = await responsePromise
|
||||
const request = response.request()
|
||||
|
||||
this.agentBuilder.speechToText.request = {
|
||||
contentType: request.headers()['content-type'] ?? '',
|
||||
path: new URL(response.url()).pathname,
|
||||
status: response.status(),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
Then('the Agent v2 speech-to-text request should succeed', async function (this: DifyWorld) {
|
||||
const request = this.agentBuilder.speechToText.request
|
||||
if (!request)
|
||||
throw new Error('No Agent v2 speech-to-text request was captured.')
|
||||
|
||||
expect(request).toEqual(expect.objectContaining({
|
||||
contentType: expect.stringContaining('multipart/form-data'),
|
||||
path: `/console/api/agent/${getCurrentAgentId(this)}/audio-to-text`,
|
||||
status: 200,
|
||||
}))
|
||||
})
|
||||
|
||||
Then(
|
||||
'the transcribed fixture phrase {string} should appear in the Agent v2 input',
|
||||
async function (this: DifyWorld, expectedPhrase: string) {
|
||||
const phrasePattern = new RegExp(
|
||||
expectedPhrase.trim().split(/\s+/).map(escapeRegExp).join('\\s+'),
|
||||
'i',
|
||||
)
|
||||
|
||||
await expect(getAgentInput(this)).toHaveValue(phrasePattern, { timeout: 60_000 })
|
||||
},
|
||||
)
|
||||
@ -9,6 +9,7 @@ import { chromium } from '@playwright/test'
|
||||
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
||||
import { deleteTestApp } from '../../support/api'
|
||||
import { deleteTestDataset } from '../../support/datasets'
|
||||
import { getVoiceInputTestMaterialPath } from '../../support/test-materials'
|
||||
import { deleteBuiltinToolCredential } from '../../support/tools'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
import { deleteTestAgent } from '../agent-v2/support/agent'
|
||||
@ -18,6 +19,7 @@ const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts')
|
||||
|
||||
let browser: Browser | undefined
|
||||
let microphoneBrowserPromise: Promise<Browser> | undefined
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
||||
@ -103,19 +105,45 @@ BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => {
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
})
|
||||
|
||||
const getMicrophoneBrowser = () => {
|
||||
microphoneBrowserPromise ??= chromium.launch({
|
||||
args: [
|
||||
'--use-fake-device-for-media-stream',
|
||||
'--use-fake-ui-for-media-stream',
|
||||
`--use-file-for-fake-audio-capture=${getVoiceInputTestMaterialPath()}%noloop`,
|
||||
],
|
||||
headless: cucumberHeadless,
|
||||
slowMo: cucumberSlowMo,
|
||||
})
|
||||
|
||||
return microphoneBrowserPromise
|
||||
}
|
||||
|
||||
Before(async function (this: DifyWorld, { pickle }) {
|
||||
if (!browser)
|
||||
throw new Error('Shared Playwright browser is not available.')
|
||||
|
||||
const isUnauthenticatedScenario = pickle.tags.some(tag => tag.name === '@unauthenticated')
|
||||
const scenarioTags = pickle.tags.map(tag => tag.name)
|
||||
const isMicrophoneScenario = scenarioTags.includes('@microphone')
|
||||
const isUnauthenticatedScenario = scenarioTags.includes('@unauthenticated')
|
||||
const scenarioBrowser = isMicrophoneScenario ? await getMicrophoneBrowser() : browser
|
||||
|
||||
if (isUnauthenticatedScenario)
|
||||
await this.startUnauthenticatedSession(browser)
|
||||
else await this.startAuthenticatedSession(browser)
|
||||
await this.startUnauthenticatedSession(scenarioBrowser)
|
||||
else await this.startAuthenticatedSession(scenarioBrowser)
|
||||
|
||||
if (isMicrophoneScenario) {
|
||||
if (!this.context)
|
||||
throw new Error('Playwright context has not been initialized for the microphone scenario.')
|
||||
|
||||
await this.context.grantPermissions(['microphone'], {
|
||||
origin: new URL(baseURL).origin,
|
||||
})
|
||||
}
|
||||
|
||||
this.scenarioStartedAt = Date.now()
|
||||
|
||||
const tags = pickle.tags.map(tag => tag.name).join(' ')
|
||||
const tags = scenarioTags.join(' ')
|
||||
console.warn(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
|
||||
})
|
||||
|
||||
@ -196,6 +224,9 @@ After(async function (this: DifyWorld, { pickle, result }) {
|
||||
})
|
||||
|
||||
AfterAll(async () => {
|
||||
const microphoneBrowser = await microphoneBrowserPromise?.catch(() => undefined)
|
||||
await microphoneBrowser?.close()
|
||||
await browser?.close()
|
||||
microphoneBrowserPromise = undefined
|
||||
browser = undefined
|
||||
})
|
||||
|
||||
@ -36,12 +36,18 @@ export type AgentV2WorkflowOutputVariable = {
|
||||
name: string
|
||||
type: string
|
||||
}
|
||||
export type AgentBuilderSpeechToTextRequest = {
|
||||
contentType: string
|
||||
path: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export const createAgentBuilderWorldState = () => ({
|
||||
preflight: {
|
||||
agentDecisionModel: undefined as AgentBuilderChatModel | undefined,
|
||||
brokenModel: undefined as AgentBuilderChatModel | undefined,
|
||||
preseededResources: {} as Record<string, AgentBuilderPreseededResource>,
|
||||
speechToTextModel: undefined as AgentBuilderChatModel | undefined,
|
||||
stableModel: undefined as AgentBuilderChatModel | undefined,
|
||||
},
|
||||
accessPoint: {
|
||||
@ -57,6 +63,9 @@ export const createAgentBuilderWorldState = () => ({
|
||||
configure: {
|
||||
concurrentPage: undefined as Page | undefined,
|
||||
},
|
||||
speechToText: {
|
||||
request: undefined as AgentBuilderSpeechToTextRequest | undefined,
|
||||
},
|
||||
workflow: {
|
||||
agentConsolePage: undefined as Page | undefined,
|
||||
outputVariables: [] as AgentV2WorkflowOutputVariable[],
|
||||
|
||||
BIN
e2e/fixtures/test-materials/voice-input.wav
Normal file
BIN
e2e/fixtures/test-materials/voice-input.wav
Normal file
Binary file not shown.
@ -10,7 +10,13 @@ export const generatedTestMaterialsDir = fileURLToPath(
|
||||
new URL('../.generated-test-materials', import.meta.url),
|
||||
)
|
||||
|
||||
export const voiceInputTestMaterial = {
|
||||
fileName: 'voice-input.wav',
|
||||
recordingDuration: '00:07',
|
||||
} as const
|
||||
|
||||
export const getTestMaterialPath = (fileName: string) => path.join(testMaterialsDir, fileName)
|
||||
export const getVoiceInputTestMaterialPath = () => getTestMaterialPath(voiceInputTestMaterial.fileName)
|
||||
|
||||
export async function getGeneratedTextMaterialPath({
|
||||
fileName,
|
||||
|
||||
Reference in New Issue
Block a user