diff --git a/e2e/features/apps/share-app.feature b/e2e/features/apps/share-app.feature new file mode 100644 index 0000000000..22f89f7ebb --- /dev/null +++ b/e2e/features/apps/share-app.feature @@ -0,0 +1,19 @@ +@apps @authenticated @core +Feature: Share app publicly + + Scenario: Enable public share for a published workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + And a minimal runnable workflow draft has been synced + When I open the app from the app list + And I open the publish panel + And I publish the app + And I navigate to the app overview page + And I enable the Web App share + Then the Web App should be in service + + @unauthenticated + Scenario: Access a shared workflow app without authentication + Given a workflow app has been published and shared via API + When I open the shared app URL + Then the shared app page should be accessible diff --git a/e2e/features/apps/workflow-run-publish.feature b/e2e/features/apps/workflow-run-publish.feature new file mode 100644 index 0000000000..8640a7490b --- /dev/null +++ b/e2e/features/apps/workflow-run-publish.feature @@ -0,0 +1,13 @@ +@apps @authenticated @core @mode-matrix +Feature: Workflow run and publish + + Scenario: Run and publish a minimal workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + And a minimal runnable workflow draft has been synced + When I open the app from the app list + And I run the workflow + Then the workflow run should succeed + When I open the publish panel + And I publish the app + Then the app should be marked as published diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts new file mode 100644 index 0000000000..24da05baab --- /dev/null +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -0,0 +1,39 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api' + +When('I enable the Web App share', async function (this: DifyWorld) { + const page = this.getPage() + const appName = this.lastCreatedAppName + if (!appName) + throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.') + + await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click() + await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 }) + await page.getByRole('switch').first().click() +}) + +Then('the Web App should be in service', async function (this: DifyWorld) { + await expect(this.getPage().getByText('In Service').first()).toBeVisible({ timeout: 10_000 }) +}) + +Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { + const app = await createTestApp(`E2E Share ${Date.now()}`, 'workflow') + this.createdAppIds.push(app.id) + this.lastCreatedAppName = app.name + await syncRunnableWorkflowDraft(app.id) + await publishWorkflowApp(app.id) + this.shareURL = await enableAppSiteAndGetURL(app.id) +}) + +When('I open the shared app URL', async function (this: DifyWorld) { + if (!this.shareURL) + throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.') + await this.getPage().goto(this.shareURL, { timeout: 20_000 }) +}) + +Then('the shared app page should be accessible', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 }) + await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_000 }) +}) diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts new file mode 100644 index 0000000000..584a33e774 --- /dev/null +++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts @@ -0,0 +1,23 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { syncRunnableWorkflowDraft } from '../../../support/api' + +Given('a minimal runnable workflow draft has been synced', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1) + if (!appId) + throw new Error('No app ID found. Run "a \\"workflow\\" app has been created via API" first.') + await syncRunnableWorkflowDraft(appId) +}) + +When('I run the workflow', async function (this: DifyWorld) { + const page = this.getPage() + await page.getByText('Test Run').click() + await expect(page.getByText('Running').first()).toBeVisible({ timeout: 15_000 }) +}) + +Then('the workflow run should succeed', async function (this: DifyWorld) { + const page = this.getPage() + await page.getByText('DETAIL').click() + await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 }) +}) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 986f79c8f9..b53087171f 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -15,6 +15,7 @@ export class DifyWorld extends World { lastCreatedAppName: string | undefined createdAppIds: string[] = [] capturedDownloads: Download[] = [] + shareURL: string | undefined constructor(options: IWorldOptions) { super(options) @@ -27,6 +28,7 @@ export class DifyWorld extends World { this.lastCreatedAppName = undefined this.createdAppIds = [] this.capturedDownloads = [] + this.shareURL = undefined } async startSession(browser: Browser, authenticated: boolean) { diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index d7778e65e2..3c8e895e90 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.ts @@ -67,11 +67,20 @@ const main = async () => { logFilePath: path.join(logDir, 'cucumber-api.log'), }) + const celeryProcess = await startLoggedProcess({ + command: 'npx', + args: ['tsx', './scripts/setup.ts', 'celery'], + cwd: e2eDir, + label: 'celery worker', + logFilePath: path.join(logDir, 'cucumber-celery.log'), + }) + let cleanupPromise: Promise | undefined const cleanup = async () => { if (!cleanupPromise) { cleanupPromise = (async () => { await stopWebServer() + await stopManagedProcess(celeryProcess) await stopManagedProcess(apiProcess) if (startMiddlewareForRun) { diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index ba4c011b04..3f77a3f72a 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -202,6 +202,32 @@ export const startApi = async () => { }) } +export const startCelery = async () => { + const env = await getApiEnvironment() + + await runForegroundProcess({ + command: 'uv', + args: [ + 'run', + '--project', + '.', + '--no-sync', + 'celery', + '-A', + 'app.celery', + 'worker', + '--pool', + 'solo', + '--loglevel', + 'INFO', + '-Q', + 'workflow_based_app_execution', + ], + cwd: apiDir, + env, + }) +} + export const stopMiddleware = async () => { await runCommandOrThrow({ command: 'docker', @@ -308,7 +334,7 @@ export const startMiddleware = async () => { } const printUsage = () => { - console.log('Usage: tsx ./scripts/setup.ts ') + console.log('Usage: tsx ./scripts/setup.ts ') } const main = async () => { @@ -318,6 +344,9 @@ const main = async () => { case 'api': await startApi() return + case 'celery': + await startCelery() + return case 'middleware-down': await stopMiddleware() return diff --git a/e2e/support/api.ts b/e2e/support/api.ts index 7d9fd0264f..74c42d3e73 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -80,3 +80,83 @@ export async function deleteTestApp(id: string): Promise { await ctx.dispose() } } + +export async function syncRunnableWorkflowDraft(appId: string): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + data: { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'end', + type: 'custom', + position: { x: 480, y: 282 }, + data: { + id: 'end', + type: 'end', + title: 'End', + outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], + }, + }, + ], + edges: [ + { + id: 'start-end', + type: 'custom', + source: 'start', + target: 'end', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + }, + }) + } + finally { + await ctx.dispose() + } +} + +export async function publishWorkflowApp(appId: string): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { + data: { marked_name: '', marked_comment: '' }, + }) + } + finally { + await ctx.dispose() + } +} + +type AppDetailWithSite = { + site: { access_token: string, app_base_url: string, enable_site: boolean } +} + +export async function enableAppSiteAndGetURL(appId: string): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/site-enable`, { + data: { enable_site: true }, + }) + const res = await ctx.get(`/console/api/apps/${appId}`) + const body = (await res.json()) as AppDetailWithSite + const { app_base_url, access_token } = body.site + return `${app_base_url}/workflow/${access_token}` + } + finally { + await ctx.dispose() + } +}