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:
Jingyi
2026-04-16 23:31:54 -07:00
committed by GitHub
parent de15e5b449
commit f5e9b02565
16 changed files with 268 additions and 13 deletions

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

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

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

View 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`)
})

View 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])
})

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import { defineConfig } from 'vite-plus' import { defineConfig } from 'vite-plus'
export default defineConfig({ export default defineConfig({})
})