diff --git a/e2e/features/apps/create-agent-app.feature b/e2e/features/apps/create-agent-app.feature new file mode 100644 index 0000000000..75d8dc3a77 --- /dev/null +++ b/e2e/features/apps/create-agent-app.feature @@ -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 diff --git a/e2e/features/apps/create-chatflow-app.feature b/e2e/features/apps/create-chatflow-app.feature new file mode 100644 index 0000000000..364cccc494 --- /dev/null +++ b/e2e/features/apps/create-chatflow-app.feature @@ -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 diff --git a/e2e/features/apps/create-text-generator-app.feature b/e2e/features/apps/create-text-generator-app.feature new file mode 100644 index 0000000000..aec0436644 --- /dev/null +++ b/e2e/features/apps/create-text-generator-app.feature @@ -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 diff --git a/e2e/features/apps/delete-app.feature b/e2e/features/apps/delete-app.feature new file mode 100644 index 0000000000..49326ba098 --- /dev/null +++ b/e2e/features/apps/delete-app.feature @@ -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 diff --git a/e2e/features/apps/duplicate-app.feature b/e2e/features/apps/duplicate-app.feature new file mode 100644 index 0000000000..3645a7d172 --- /dev/null +++ b/e2e/features/apps/duplicate-app.feature @@ -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 diff --git a/e2e/features/apps/export-app.feature b/e2e/features/apps/export-app.feature new file mode 100644 index 0000000000..d6d040fb00 --- /dev/null +++ b/e2e/features/apps/export-app.feature @@ -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 diff --git a/e2e/features/apps/switch-app-mode.feature b/e2e/features/apps/switch-app-mode.feature new file mode 100644 index 0000000000..5cdc6341fb --- /dev/null +++ b/e2e/features/apps/switch-app-mode.feature @@ -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 diff --git a/e2e/features/step-definitions/apps/create-app.steps.ts b/e2e/features/step-definitions/apps/create-app.steps.ts index e444b97dc8..931d4662a2 100644 --- a/e2e/features/step-definitions/apps/create-app.steps.ts +++ b/e2e/features/step-definitions/apps/create-app.steps.ts @@ -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 + //

Chatflow

alongside the card's
Chatflow
. + // locator('div').getByText(...) would still match the

because getByText + // searches inside each div for any descendant. Use :text-is() instead, which + // targets only
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) { diff --git a/e2e/features/step-definitions/apps/delete-app.steps.ts b/e2e/features/step-definitions/apps/delete-app.steps.ts new file mode 100644 index 0000000000..e5da626645 --- /dev/null +++ b/e2e/features/step-definitions/apps/delete-app.steps.ts @@ -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, + }) +}) diff --git a/e2e/features/step-definitions/apps/duplicate-app.steps.ts b/e2e/features/step-definitions/apps/duplicate-app.steps.ts new file mode 100644 index 0000000000..e5e3694e4d --- /dev/null +++ b/e2e/features/step-definitions/apps/duplicate-app.steps.ts @@ -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() +}) diff --git a/e2e/features/step-definitions/apps/export-app.steps.ts b/e2e/features/step-definitions/apps/export-app.steps.ts new file mode 100644 index 0000000000..4ebeecb507 --- /dev/null +++ b/e2e/features/step-definitions/apps/export-app.steps.ts @@ -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`) +}) diff --git a/e2e/features/step-definitions/apps/switch-app-mode.steps.ts b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts new file mode 100644 index 0000000000..55ad1ab02c --- /dev/null +++ b/e2e/features/step-definitions/apps/switch-app-mode.steps.ts @@ -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]) +}) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 33b337fb93..c1a535ee2c 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -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() }) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 0e9c4b9c84..986f79c8f9 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -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) { diff --git a/e2e/support/api.ts b/e2e/support/api.ts new file mode 100644 index 0000000000..c6d6c98bde --- /dev/null +++ b/e2e/support/api.ts @@ -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 { + 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 { + const ctx = await createApiContext() + try { + await ctx.delete(`/console/api/apps/${id}`) + } + finally { + await ctx.dispose() + } +} diff --git a/e2e/vite.config.ts b/e2e/vite.config.ts index 2329b534b4..f3dd7bbb0b 100644 --- a/e2e/vite.config.ts +++ b/e2e/vite.config.ts @@ -1,5 +1,3 @@ import { defineConfig } from 'vite-plus' -export default defineConfig({ - -}) +export default defineConfig({})