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) {
|
When('I enter a unique E2E app name', async function (this: DifyWorld) {
|
||||||
const appName = `E2E App ${Date.now()}`
|
const appName = `E2E App ${Date.now()}`
|
||||||
|
this.lastCreatedAppName = appName
|
||||||
await this.getPage().getByPlaceholder('Give your app a name').fill(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) {
|
When('I select the {string} app type', async function (this: DifyWorld, appType: string) {
|
||||||
const dialog = this.getPage().getByRole('dialog')
|
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 expect(appTypeCard).toBeVisible()
|
||||||
await appTypeTitle.click()
|
await appTypeCard.click()
|
||||||
})
|
})
|
||||||
|
|
||||||
When('I expand the beginner app types', async function (this: DifyWorld) {
|
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 { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber'
|
||||||
import { chromium } from '@playwright/test'
|
import { chromium } from '@playwright/test'
|
||||||
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
||||||
|
import { deleteTestApp } from '../../support/api'
|
||||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||||
|
|
||||||
const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
|
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}` : ''}`,
|
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for (const id of this.createdAppIds) await deleteTestApp(id).catch(() => {})
|
||||||
|
|
||||||
await this.closeSession()
|
await this.closeSession()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import type { IWorldOptions } from '@cucumber/cucumber'
|
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 type { AuthSessionMetadata } from '../../fixtures/auth'
|
||||||
import { setWorldConstructor, World } from '@cucumber/cucumber'
|
import { setWorldConstructor, World } from '@cucumber/cucumber'
|
||||||
import {
|
import { authStatePath, readAuthSessionMetadata } from '../../fixtures/auth'
|
||||||
|
|
||||||
authStatePath,
|
|
||||||
readAuthSessionMetadata,
|
|
||||||
} from '../../fixtures/auth'
|
|
||||||
import { baseURL, defaultLocale } from '../../test-env'
|
import { baseURL, defaultLocale } from '../../test-env'
|
||||||
|
|
||||||
export class DifyWorld extends World {
|
export class DifyWorld extends World {
|
||||||
@@ -16,6 +12,9 @@ export class DifyWorld extends World {
|
|||||||
pageErrors: string[] = []
|
pageErrors: string[] = []
|
||||||
scenarioStartedAt: number | undefined
|
scenarioStartedAt: number | undefined
|
||||||
session: AuthSessionMetadata | undefined
|
session: AuthSessionMetadata | undefined
|
||||||
|
lastCreatedAppName: string | undefined
|
||||||
|
createdAppIds: string[] = []
|
||||||
|
capturedDownloads: Download[] = []
|
||||||
|
|
||||||
constructor(options: IWorldOptions) {
|
constructor(options: IWorldOptions) {
|
||||||
super(options)
|
super(options)
|
||||||
@@ -25,6 +24,9 @@ export class DifyWorld extends World {
|
|||||||
resetScenarioState() {
|
resetScenarioState() {
|
||||||
this.consoleErrors = []
|
this.consoleErrors = []
|
||||||
this.pageErrors = []
|
this.pageErrors = []
|
||||||
|
this.lastCreatedAppName = undefined
|
||||||
|
this.createdAppIds = []
|
||||||
|
this.capturedDownloads = []
|
||||||
}
|
}
|
||||||
|
|
||||||
async startSession(browser: Browser, authenticated: boolean) {
|
async startSession(browser: Browser, authenticated: boolean) {
|
||||||
@@ -45,6 +47,9 @@ export class DifyWorld extends World {
|
|||||||
this.page.on('pageerror', (error) => {
|
this.page.on('pageerror', (error) => {
|
||||||
this.pageErrors.push(error.message)
|
this.pageErrors.push(error.message)
|
||||||
})
|
})
|
||||||
|
this.page.on('download', (dl) => {
|
||||||
|
this.capturedDownloads.push(dl)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async startAuthenticatedSession(browser: Browser) {
|
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'
|
import { defineConfig } from 'vite-plus'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({})
|
||||||
|
|
||||||
})
|
|
||||||
|
|||||||
Reference in New Issue
Block a user