mirror of
https://mirror.skon.top/github.com/langgenius/dify.git
synced 2026-04-20 15:20:15 +08:00
test: add API seeding infrastructure and app creation E2E scenarios (#35276)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
11
e2e/features/apps/create-agent-app.feature
Normal file
11
e2e/features/apps/create-agent-app.feature
Normal file
@@ -0,0 +1,11 @@
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Agent app
|
||||
Scenario: Create a new Agent app and redirect to the configuration page
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I start creating a blank app
|
||||
And I expand the beginner app types
|
||||
And I select the "Agent" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app configuration page
|
||||
10
e2e/features/apps/create-chatflow-app.feature
Normal file
10
e2e/features/apps/create-chatflow-app.feature
Normal file
@@ -0,0 +1,10 @@
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Chatflow app
|
||||
Scenario: Create a new Chatflow app and redirect to the workflow editor
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I start creating a blank app
|
||||
And I select the "Chatflow" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the workflow editor
|
||||
11
e2e/features/apps/create-text-generator-app.feature
Normal file
11
e2e/features/apps/create-text-generator-app.feature
Normal file
@@ -0,0 +1,11 @@
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Create Text Generator app
|
||||
Scenario: Create a new Text Generator app and redirect to the configuration page
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I start creating a blank app
|
||||
And I expand the beginner app types
|
||||
And I select the "Text Generator" app type
|
||||
And I enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app configuration page
|
||||
11
e2e/features/apps/delete-app.feature
Normal file
11
e2e/features/apps/delete-app.feature
Normal file
@@ -0,0 +1,11 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Delete app
|
||||
Scenario: Delete an existing app from the apps console
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Delete" in the app options menu
|
||||
And I type the app name in the deletion confirmation
|
||||
And I confirm the deletion
|
||||
Then the app should no longer appear in the apps console
|
||||
10
e2e/features/apps/duplicate-app.feature
Normal file
10
e2e/features/apps/duplicate-app.feature
Normal file
@@ -0,0 +1,10 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Duplicate app
|
||||
Scenario: Duplicate an existing app and open the copy in the editor
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Duplicate" in the app options menu
|
||||
And I confirm the app duplication
|
||||
Then I should land on the app editor
|
||||
9
e2e/features/apps/export-app.feature
Normal file
9
e2e/features/apps/export-app.feature
Normal file
@@ -0,0 +1,9 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Export app DSL
|
||||
Scenario: Export the DSL file for an existing app
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E completion app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Export DSL" in the app options menu
|
||||
Then a YAML file named after the app should be downloaded
|
||||
10
e2e/features/apps/switch-app-mode.feature
Normal file
10
e2e/features/apps/switch-app-mode.feature
Normal file
@@ -0,0 +1,10 @@
|
||||
@apps @authenticated @core
|
||||
Feature: Switch app mode
|
||||
Scenario: Switch a Completion app to Workflow Orchestrate
|
||||
Given I am signed in as the default E2E admin
|
||||
And there is an existing E2E completion app available for testing
|
||||
When I open the apps console
|
||||
And I open the options menu for the last created E2E app
|
||||
And I click "Switch to Workflow Orchestrate" in the app options menu
|
||||
And I confirm the app switch
|
||||
Then I should land on the switched app
|
||||
@@ -11,7 +11,7 @@ When('I start creating a blank app', async function (this: DifyWorld) {
|
||||
|
||||
When('I enter a unique E2E app name', async function (this: DifyWorld) {
|
||||
const appName = `E2E App ${Date.now()}`
|
||||
|
||||
this.lastCreatedAppName = appName
|
||||
await this.getPage().getByPlaceholder('Give your app a name').fill(appName)
|
||||
})
|
||||
|
||||
@@ -26,10 +26,15 @@ When('I confirm app creation', async function (this: DifyWorld) {
|
||||
|
||||
When('I select the {string} app type', async function (this: DifyWorld, appType: string) {
|
||||
const dialog = this.getPage().getByRole('dialog')
|
||||
const appTypeTitle = dialog.getByText(appType, { exact: true })
|
||||
// The modal defaults to ADVANCED_CHAT, so the preview panel immediately renders
|
||||
// <h4>Chatflow</h4> alongside the card's <div>Chatflow</div>.
|
||||
// locator('div').getByText(...) would still match the <h4> because getByText
|
||||
// searches inside each div for any descendant. Use :text-is() instead, which
|
||||
// targets only <div> elements whose own normalised text equals appType exactly.
|
||||
const appTypeCard = dialog.locator(`div:text-is("${appType}")`)
|
||||
|
||||
await expect(appTypeTitle).toBeVisible()
|
||||
await appTypeTitle.click()
|
||||
await expect(appTypeCard).toBeVisible()
|
||||
await appTypeCard.click()
|
||||
})
|
||||
|
||||
When('I expand the beginner app types', async function (this: DifyWorld) {
|
||||
|
||||
35
e2e/features/step-definitions/apps/delete-app.steps.ts
Normal file
35
e2e/features/step-definitions/apps/delete-app.steps.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
When('I type the app name in the deletion confirmation', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name stored. Run "there is an existing E2E app available for testing" first.',
|
||||
)
|
||||
}
|
||||
|
||||
const page = this.getPage()
|
||||
const dialog = page.getByRole('alertdialog')
|
||||
await expect(dialog).toBeVisible()
|
||||
await dialog.getByPlaceholder('Enter app name…').fill(appName)
|
||||
})
|
||||
|
||||
When('I confirm the deletion', async function (this: DifyWorld) {
|
||||
const dialog = this.getPage().getByRole('alertdialog')
|
||||
await dialog.getByRole('button', { name: 'Confirm' }).click()
|
||||
})
|
||||
|
||||
Then('the app should no longer appear in the apps console', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name stored. Run "there is an existing E2E app available for testing" first.',
|
||||
)
|
||||
}
|
||||
|
||||
await expect(this.getPage().getByTitle(appName)).not.toBeVisible({
|
||||
timeout: 10_000,
|
||||
})
|
||||
})
|
||||
36
e2e/features/step-definitions/apps/duplicate-app.steps.ts
Normal file
36
e2e/features/step-definitions/apps/duplicate-app.steps.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, When } from '@cucumber/cucumber'
|
||||
import { createTestApp } from '../../../support/api'
|
||||
|
||||
Given('there is an existing E2E app available for testing', async function (this: DifyWorld) {
|
||||
const name = `E2E Test App ${Date.now()}`
|
||||
const app = await createTestApp(name, 'completion')
|
||||
this.lastCreatedAppName = app.name
|
||||
this.createdAppIds.push(app.id)
|
||||
})
|
||||
|
||||
When('I open the options menu for the last created E2E app', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName)
|
||||
throw new Error('No app name stored. Run "I enter a unique E2E app name" first.')
|
||||
|
||||
const page = this.getPage()
|
||||
// Scope to the specific card: the card root is the innermost div that contains
|
||||
// both the unique app name text and a More button (they are in separate branches,
|
||||
// so no child div satisfies both). .last() picks the deepest match in DOM order.
|
||||
const appCard = page
|
||||
.locator('div')
|
||||
.filter({ has: page.getByText(appName, { exact: true }) })
|
||||
.filter({ has: page.getByRole('button', { name: 'More' }) })
|
||||
.last()
|
||||
await appCard.hover()
|
||||
await appCard.getByRole('button', { name: 'More' }).click()
|
||||
})
|
||||
|
||||
When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) {
|
||||
await this.getPage().getByRole('menuitem', { name: label }).click()
|
||||
})
|
||||
|
||||
When('I confirm the app duplication', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: 'Duplicate' }).click()
|
||||
})
|
||||
19
e2e/features/step-definitions/apps/export-app.steps.ts
Normal file
19
e2e/features/step-definitions/apps/export-app.steps.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
Then('a YAML file named after the app should be downloaded', async function (this: DifyWorld) {
|
||||
const appName = this.lastCreatedAppName
|
||||
if (!appName) {
|
||||
throw new Error(
|
||||
'No app name stored. Run "there is an existing E2E app available for testing" first.',
|
||||
)
|
||||
}
|
||||
|
||||
// The export triggers an async API call before the blob download fires.
|
||||
// Poll until the download event is captured by the page listener in DifyWorld.
|
||||
await expect.poll(() => this.capturedDownloads.length, { timeout: 10_000 }).toBeGreaterThan(0)
|
||||
|
||||
const download = this.capturedDownloads.at(-1)!
|
||||
expect(download.suggestedFilename()).toBe(`${appName}.yml`)
|
||||
})
|
||||
28
e2e/features/step-definitions/apps/switch-app-mode.steps.ts
Normal file
28
e2e/features/step-definitions/apps/switch-app-mode.steps.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { createTestApp } from '../../../support/api'
|
||||
|
||||
Given(
|
||||
'there is an existing E2E completion app available for testing',
|
||||
async function (this: DifyWorld) {
|
||||
const name = `E2E Test App ${Date.now()}`
|
||||
const app = await createTestApp(name, 'completion')
|
||||
this.lastCreatedAppName = app.name
|
||||
this.createdAppIds.push(app.id)
|
||||
},
|
||||
)
|
||||
|
||||
When('I confirm the app switch', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: 'Start switch' }).click()
|
||||
})
|
||||
|
||||
Then('I should land on the switched app', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await expect(page).toHaveURL(/\/app\/[^/]+\/workflow(?:\?.*)?$/, { timeout: 15_000 })
|
||||
|
||||
// Capture the new app's ID so the After hook can clean it up
|
||||
const match = page.url().match(/\/app\/([^/]+)\/workflow/)
|
||||
if (match?.[1])
|
||||
this.createdAppIds.push(match[1])
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber'
|
||||
import { chromium } from '@playwright/test'
|
||||
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
||||
import { deleteTestApp } from '../../support/api'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
|
||||
const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
@@ -88,6 +89,8 @@ After(async function (this: DifyWorld, { pickle, result }) {
|
||||
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
|
||||
)
|
||||
|
||||
for (const id of this.createdAppIds) await deleteTestApp(id).catch(() => {})
|
||||
|
||||
await this.closeSession()
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { IWorldOptions } from '@cucumber/cucumber'
|
||||
import type { Browser, BrowserContext, ConsoleMessage, Page } from '@playwright/test'
|
||||
import type { Browser, BrowserContext, ConsoleMessage, Download, Page } from '@playwright/test'
|
||||
import type { AuthSessionMetadata } from '../../fixtures/auth'
|
||||
import { setWorldConstructor, World } from '@cucumber/cucumber'
|
||||
import {
|
||||
|
||||
authStatePath,
|
||||
readAuthSessionMetadata,
|
||||
} from '../../fixtures/auth'
|
||||
import { authStatePath, readAuthSessionMetadata } from '../../fixtures/auth'
|
||||
import { baseURL, defaultLocale } from '../../test-env'
|
||||
|
||||
export class DifyWorld extends World {
|
||||
@@ -16,6 +12,9 @@ export class DifyWorld extends World {
|
||||
pageErrors: string[] = []
|
||||
scenarioStartedAt: number | undefined
|
||||
session: AuthSessionMetadata | undefined
|
||||
lastCreatedAppName: string | undefined
|
||||
createdAppIds: string[] = []
|
||||
capturedDownloads: Download[] = []
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
@@ -25,6 +24,9 @@ export class DifyWorld extends World {
|
||||
resetScenarioState() {
|
||||
this.consoleErrors = []
|
||||
this.pageErrors = []
|
||||
this.lastCreatedAppName = undefined
|
||||
this.createdAppIds = []
|
||||
this.capturedDownloads = []
|
||||
}
|
||||
|
||||
async startSession(browser: Browser, authenticated: boolean) {
|
||||
@@ -45,6 +47,9 @@ export class DifyWorld extends World {
|
||||
this.page.on('pageerror', (error) => {
|
||||
this.pageErrors.push(error.message)
|
||||
})
|
||||
this.page.on('download', (dl) => {
|
||||
this.capturedDownloads.push(dl)
|
||||
})
|
||||
}
|
||||
|
||||
async startAuthenticatedSession(browser: Browser) {
|
||||
|
||||
54
e2e/support/api.ts
Normal file
54
e2e/support/api.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { request } from '@playwright/test'
|
||||
import { authStatePath } from '../fixtures/auth'
|
||||
import { apiURL } from '../test-env'
|
||||
|
||||
type StorageState = {
|
||||
cookies: Array<{ name: string, value: string }>
|
||||
}
|
||||
|
||||
async function createApiContext() {
|
||||
const state = JSON.parse(await readFile(authStatePath, 'utf8')) as StorageState
|
||||
const csrfToken = state.cookies.find(c => c.name.endsWith('csrf_token'))?.value ?? ''
|
||||
|
||||
return request.newContext({
|
||||
baseURL: apiURL,
|
||||
extraHTTPHeaders: { 'X-CSRF-Token': csrfToken },
|
||||
storageState: authStatePath,
|
||||
})
|
||||
}
|
||||
|
||||
export type AppSeed = {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export async function createTestApp(name: string, mode = 'workflow'): Promise<AppSeed> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
const response = await ctx.post('/console/api/apps', {
|
||||
data: {
|
||||
name,
|
||||
mode,
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
},
|
||||
})
|
||||
const body = (await response.json()) as AppSeed
|
||||
return body
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteTestApp(id: string): Promise<void> {
|
||||
const ctx = await createApiContext()
|
||||
try {
|
||||
await ctx.delete(`/console/api/apps/${id}`)
|
||||
}
|
||||
finally {
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
})
|
||||
export default defineConfig({})
|
||||
|
||||
Reference in New Issue
Block a user