From 04f90267246cfa7d2f84bf3b75a77b02e7b1b80e Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:32:40 +0800 Subject: [PATCH] fix(web): show unavailable state for disabled Web Apps (#39392) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- e2e/AGENTS.md | 18 ++++ e2e/features/agent-v2/support/access-point.ts | 5 +- e2e/features/apps/share-app.feature | 14 +-- e2e/features/apps/web-app-service.feature | 19 ++++ .../step-definitions/apps/share-app.steps.ts | 25 ----- .../apps/web-app-service.steps.ts | 101 ++++++++++++++++++ .../step-definitions/common/app.steps.ts | 8 +- e2e/features/support/hooks.ts | 1 + e2e/features/support/world.ts | 2 + e2e/support/api.ts | 43 +++++--- oxlint-suppressions.json | 8 -- .../components/__tests__/splash.spec.tsx | 36 ++++++- web/app/(shareLayout)/components/splash.tsx | 24 ++++- 13 files changed, 236 insertions(+), 68 deletions(-) create mode 100644 e2e/features/apps/web-app-service.feature create mode 100644 e2e/features/step-definitions/apps/web-app-service.steps.ts diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md index 8aae18c6e75..8771bd27d68 100644 --- a/e2e/AGENTS.md +++ b/e2e/AGENTS.md @@ -195,6 +195,24 @@ Open the HTML report locally with: open cucumber-report/report.html ``` +## Scenario admission and behavior ownership + +Add an E2E scenario only when it protects a critical user journey and a cross-boundary result that +cheaper owner-level tests do not already prove. A control changing its own label is not sufficient +E2E evidence when component or integration tests can own that contract. + +Start from product truth, including real defaults and actor roles. API fixtures may establish +preconditions, but they must not manufacture an opposite state merely to make the intended action +look meaningful. When a product default is part of the journey, make it explicit and observable. + +For cross-actor journeys, isolate each actor's browser state, keep their pages in typed `DifyWorld` +state, and include them in failure diagnostics and cleanup. Assert the downstream user-observable +effect, not only the initiating control's local state. + +When a run exposes behavior that conflicts with the intended product contract, identify the first +layer that misclassifies the business state. Fix that owner or report the mismatch explicitly; do +not make the E2E pass by encoding an accidental redirect, stale label, or misleading error state. + ## Writing new scenarios ### Workflow diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index 96316028b3b..fa0b53e5b25 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -47,8 +47,9 @@ export async function setAgentSiteAccessAndGetURL( if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) const appDetail = await setAppSiteEnabled(appId, enabled) - const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site.access_token - const baseURL = agent.site?.app_base_url ?? appDetail.site.app_base_url + const token = agent.site?.access_token ?? agent.site?.code ?? appDetail.site?.access_token + const baseURL = agent.site?.app_base_url ?? appDetail.site?.app_base_url + if (!token || !baseURL) throw new Error(`Agent v2 ${agentId} does not expose a Web App URL.`) return `${baseURL.replace(/\/$/, '')}/agent/${token}` } diff --git a/e2e/features/apps/share-app.feature b/e2e/features/apps/share-app.feature index 265599ecd16..36399c4123b 100644 --- a/e2e/features/apps/share-app.feature +++ b/e2e/features/apps/share-app.feature @@ -1,17 +1,5 @@ @apps @core -Feature: Share app publicly - - @authenticated - 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 +Feature: Use a shared workflow app @unauthenticated Scenario: Access a shared workflow app without authentication diff --git a/e2e/features/apps/web-app-service.feature b/e2e/features/apps/web-app-service.feature new file mode 100644 index 00000000000..f027ed3e9da --- /dev/null +++ b/e2e/features/apps/web-app-service.feature @@ -0,0 +1,19 @@ +@apps @authenticated @core +Feature: Manage Web App service + + Scenario: Disable and restore a published workflow Web App + Given I am signed in as the default E2E admin + And a new runnable workflow app has been published + When I navigate to the app overview page + And I open the app information panel + Then the Web App should be in service + When an anonymous visitor opens the Web App + Then the published workflow Web App should be accessible + When I disable the Web App + Then the Web App should be disabled + When the anonymous visitor reloads the Web App + Then the published workflow Web App should be unavailable + When I enable the Web App + Then the Web App should be in service + When the anonymous visitor reloads the Web App + Then the published workflow Web App should be accessible diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts index b216b518696..d34e227ace0 100644 --- a/e2e/features/step-definitions/apps/share-app.steps.ts +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -9,31 +9,6 @@ import { } from '../../../support/api' import { createE2EResourceName } from '../../../support/naming' -const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - -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.getByRole('button', { name: new RegExp(escapeRegExp(appName)) }).click() - const webAppCard = page.getByRole('region', { name: 'Web App' }) - const webAppSwitch = webAppCard.getByRole('switch', { name: 'Web App' }) - await expect(webAppSwitch).toBeEnabled({ timeout: 15_000 }) - await webAppSwitch.click() -}) - -Then('the Web App should be in service', async function (this: DifyWorld) { - const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) - await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ - timeout: 10_000, - }) -}) - Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { const app = await createTestApp(createE2EResourceName('App', 'Share'), 'workflow') this.createdAppIds.push(app.id) diff --git a/e2e/features/step-definitions/apps/web-app-service.steps.ts b/e2e/features/step-definitions/apps/web-app-service.steps.ts new file mode 100644 index 00000000000..aee5962b98c --- /dev/null +++ b/e2e/features/step-definitions/apps/web-app-service.steps.ts @@ -0,0 +1,101 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { + createTestApp, + getAppSiteDetail, + getAppSiteURL, + publishWorkflowApp, + syncRunnableWorkflowDraft, +} from '../../../support/api' +import { createE2EResourceName } from '../../../support/naming' +import { baseURL, defaultLocale } from '../../../test-env' + +Given('a new runnable workflow app has been published', async function (this: DifyWorld) { + const app = await createTestApp(createE2EResourceName('App', 'WebApp'), 'workflow') + this.createdAppIds.push(app.id) + this.lastCreatedAppName = app.name + await syncRunnableWorkflowDraft(app.id) + await publishWorkflowApp(app.id) + + const appDetail = await getAppSiteDetail(app.id) + expect(appDetail.enable_site).toBe(true) + this.shareURL = getAppSiteURL(appDetail) +}) + +When('I open the app information panel', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) { + throw new Error('No app name available. Create an app before opening its information panel.') + } + + await this.getPage().getByRole('button', { name: appName }).click() +}) + +const getWebAppSwitch = (world: DifyWorld) => { + const webAppCard = world.getPage().getByRole('region', { name: 'Web App' }) + return webAppCard.getByRole('switch', { name: 'Web App' }) +} + +When('an anonymous visitor opens the Web App', async function (this: DifyWorld) { + if (!this.shareURL) throw new Error('No Web App URL is available.') + if (!this.context) throw new Error('Playwright browser context has not been initialized.') + + const browser = this.context.browser() + if (!browser) throw new Error('Playwright browser has not been initialized.') + + const anonymousContext = await browser.newContext({ baseURL, locale: defaultLocale }) + this.registerCleanup(() => anonymousContext.close()) + this.sharedAppPage = await anonymousContext.newPage() + await this.sharedAppPage.goto(this.shareURL, { timeout: 20_000 }) +}) + +When('the anonymous visitor reloads the Web App', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await this.sharedAppPage.reload({ timeout: 20_000 }) +}) + +When('I disable the Web App', async function (this: DifyWorld) { + const webAppSwitch = getWebAppSwitch(this) + + await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 }) + await expect(webAppSwitch).toHaveAttribute('aria-checked', 'true') + await webAppSwitch.click() +}) + +When('I enable the Web App', async function (this: DifyWorld) { + const webAppSwitch = getWebAppSwitch(this) + + await expect(webAppSwitch).not.toHaveAttribute('aria-disabled', 'true', { timeout: 15_000 }) + await expect(webAppSwitch).toHaveAttribute('aria-checked', 'false') + await webAppSwitch.click() +}) + +Then('the Web App should be in service', async function (this: DifyWorld) { + const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) + await expect(webAppCard.getByText('In Service', { exact: true })).toBeVisible({ + timeout: 10_000, + }) +}) + +Then('the Web App should be disabled', async function (this: DifyWorld) { + const webAppCard = this.getPage().getByRole('region', { name: 'Web App' }) + await expect(webAppCard.getByText('Disabled', { exact: true })).toBeVisible({ + timeout: 10_000, + }) +}) + +Then('the published workflow Web App should be accessible', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await expect(this.sharedAppPage.getByRole('button', { name: 'Execute' })).toBeVisible({ + timeout: 15_000, + }) +}) + +Then('the published workflow Web App should be unavailable', async function (this: DifyWorld) { + if (!this.sharedAppPage) throw new Error('The anonymous visitor has not opened the Web App.') + await expect(this.sharedAppPage.getByRole('heading', { name: '404' })).toBeVisible({ + timeout: 15_000, + }) + await expect(this.sharedAppPage.getByText('App is unavailable', { exact: true })).toBeVisible() +}) diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index 1fa50cbf0d1..a3399130f4e 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -12,15 +12,19 @@ Given('a {string} app has been created via API', async function (this: DifyWorld }) Given('a minimal workflow draft has been synced', async function (this: DifyWorld) { - const appId = this.createdAppIds.at(-1)! + const appId = this.createdAppIds.at(-1) + if (!appId) throw new Error('No app is available for workflow draft setup.') await syncMinimalWorkflowDraft(appId) }) When('I open the app from the app list', async function (this: DifyWorld) { + const appName = this.lastCreatedAppName + if (!appName) throw new Error('No app is available to open from the app list.') + const page = this.getPage() await page.goto('/apps') await waitForAppsConsole(page) - const appLink = page.getByRole('link', { name: this.lastCreatedAppName!, exact: true }) + const appLink = page.getByRole('link', { name: appName, exact: true }) await expect(appLink).toBeVisible() await appLink.click() }) diff --git a/e2e/features/support/hooks.ts b/e2e/features/support/hooks.ts index 0325d04a7a7..40c794c6b14 100644 --- a/e2e/features/support/hooks.ts +++ b/e2e/features/support/hooks.ts @@ -220,6 +220,7 @@ After( const artifactErrors: string[] = [] const diagnosticPages = uniqueDiagnosticPages([ { label: 'main-page', page: this.page }, + { label: 'shared-app', page: this.sharedAppPage }, { label: 'agent-v2-web-app', page: this.agentBuilder.accessPoint.webAppPage }, { label: 'agent-v2-api-reference', page: this.agentBuilder.accessPoint.apiReferencePage }, { diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index d8b7f3ea472..53a13f40163 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -96,6 +96,7 @@ export class DifyWorld extends World { scenarioCleanups: ScenarioCleanup[] = [] capturedDownloads: Download[] = [] shareURL: string | undefined + sharedAppPage: Page | undefined constructor(options: IWorldOptions) { super(options) @@ -120,6 +121,7 @@ export class DifyWorld extends World { this.scenarioCleanups = [] this.capturedDownloads = [] this.shareURL = undefined + this.sharedAppPage = undefined } async startSession(browser: Browser, authenticated: boolean) { diff --git a/e2e/support/api.ts b/e2e/support/api.ts index a6dfde8ef17..3cc4175debc 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -1,5 +1,7 @@ +import type { AppDetailWithSite } from '@dify/contracts/api/console/apps/types.gen' import type { APIResponse } from '@playwright/test' import { readFile } from 'node:fs/promises' +import { zAppDetailWithSite } from '@dify/contracts/api/console/apps/zod.gen' import { request } from '@playwright/test' import { authStatePath } from '../fixtures/auth' import { apiURL } from '../test-env' @@ -82,7 +84,7 @@ export async function getWorkflowDraft(appId: string): Promise { export async function syncMinimalWorkflowDraft(appId: string): Promise { const ctx = await createApiContext() try { - await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data: { graph: { nodes: [ @@ -101,6 +103,7 @@ export async function syncMinimalWorkflowDraft(appId: string): Promise { conversation_variables: [], }, }) + await expectApiResponseOK(response, `Sync minimal workflow draft for ${appId}`) } finally { await ctx.dispose() } @@ -164,7 +167,7 @@ export async function deleteTestApp(id: string): Promise { export async function syncRunnableWorkflowDraft(appId: string): Promise { const ctx = await createApiContext() try { - await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + const response = await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { data: { graph: { nodes: [ @@ -203,6 +206,7 @@ export async function syncRunnableWorkflowDraft(appId: string): Promise { conversation_variables: [], }, }) + await expectApiResponseOK(response, `Sync runnable workflow draft for ${appId}`) } finally { await ctx.dispose() } @@ -211,22 +215,37 @@ export async function syncRunnableWorkflowDraft(appId: string): Promise { export async function publishWorkflowApp(appId: string): Promise { const ctx = await createApiContext() try { - await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { + const response = await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { data: { marked_name: '', marked_comment: '' }, }) + await expectApiResponseOK(response, `Publish workflow app ${appId}`) } finally { await ctx.dispose() } } -export type AppDetailWithSite = { - mode?: string - site: { access_token: string; app_base_url: string; enable_site: boolean } +export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { + if (!site?.app_base_url || !site.access_token) + throw new Error('App detail does not include a Web App URL.') + + const webAppMode = (() => { + if (mode === 'completion' || mode === 'workflow') return mode + if (mode === 'advanced-chat' || mode === 'agent-chat' || mode === 'chat') return 'chat' + throw new Error(`Unsupported Web App mode: ${mode}`) + })() + + return `${site.app_base_url}/${webAppMode}/${site.access_token}` } -export function getAppSiteURL({ mode, site }: AppDetailWithSite): string { - const webAppMode = mode === 'completion' || mode === 'workflow' ? mode : 'chat' - return `${site.app_base_url}/${webAppMode}/${site.access_token}` +export async function getAppSiteDetail(appId: string): Promise { + const ctx = await createApiContext() + try { + const response = await ctx.get(`/console/api/apps/${appId}`) + await expectApiResponseOK(response, `Get app site detail for ${appId}`) + return zAppDetailWithSite.parse(await response.json()) + } finally { + await ctx.dispose() + } } export async function enableAppSiteAndGetURL(appId: string): Promise { @@ -243,11 +262,9 @@ export async function setAppSiteEnabled( data: { enable_site: enabled }, }) await expectApiResponseOK(enableResponse, `${enabled ? 'Enable' : 'Disable'} app site ${appId}`) - - const detailResponse = await ctx.get(`/console/api/apps/${appId}`) - await expectApiResponseOK(detailResponse, `Get app site detail for ${appId}`) - return (await detailResponse.json()) as AppDetailWithSite } finally { await ctx.dispose() } + + return getAppSiteDetail(appId) } diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index c9e6e2ff30e..e8696cf7d38 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -157,14 +157,6 @@ "count": 1 } }, - "web/app/(shareLayout)/components/splash.tsx": { - "jsx_a11y/click-events-have-key-events": { - "count": 1 - }, - "jsx_a11y/no-static-element-interactions": { - "count": 1 - } - }, "web/app/(shareLayout)/webapp-reset-password/check-code/page.tsx": { "jsx_a11y/click-events-have-key-events": { "count": 1 diff --git a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx index 48d87b4438b..cf2f43f2417 100644 --- a/web/app/(shareLayout)/components/__tests__/splash.spec.tsx +++ b/web/app/(shareLayout)/components/__tests__/splash.spec.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react' +import { render, screen, waitFor } from '@testing-library/react' import Splash from '../splash' const navigationMocks = vi.hoisted(() => ({ @@ -42,7 +42,7 @@ vi.mock('@/service/share', () => ({ vi.mock('@/service/webapp-auth', () => webAppAuthMocks) -describe('Splash redirect security', () => { +describe('Splash', () => { beforeEach(() => { vi.clearAllMocks() webAppState.shareCode = 'share-app' @@ -121,4 +121,36 @@ describe('Splash redirect security', () => { expect(webAppAuthMocks.webAppLoginStatus).not.toHaveBeenCalled() expect(fetchAccessTokenMock).not.toHaveBeenCalled() }) + + it('should show the app unavailable state when a public Web App passport is not found', async () => { + navigationMocks.searchParams = new URLSearchParams() + webAppAuthMocks.webAppLoginStatus.mockResolvedValue({ + userLoggedIn: true, + appLoggedIn: false, + }) + fetchAccessTokenMock.mockRejectedValue(new Response(null, { status: 404 })) + + render( + +
share application
+
, + ) + + expect(await screen.findByText('share.common.appUnavailable')).toBeInTheDocument() + }) + + it('should expose the unavailable-state action as a button', () => { + navigationMocks.searchParams = new URLSearchParams({ + code: '404', + message: 'The Web App is unavailable.', + }) + + render( + +
share application
+
, + ) + + expect(screen.getByRole('button', { name: 'share.login.backToHome' })).toBeInTheDocument() + }) }) diff --git a/web/app/(shareLayout)/components/splash.tsx b/web/app/(shareLayout)/components/splash.tsx index 9f1967cb39f..e813f789507 100644 --- a/web/app/(shareLayout)/components/splash.tsx +++ b/web/app/(shareLayout)/components/splash.tsx @@ -57,6 +57,7 @@ function Splash({ children }: PropsWithChildren) { }, [getSigninUrl, pathname, redirectUrl, router, shareCode]) const [isLoading, setIsLoading] = useState(true) + const [unavailableShareCode, setUnavailableShareCode] = useState() useEffect(() => { const loginRedirect = resolveWebAppLoginRedirect(redirectUrl, window.location.origin) const isSigninRoute = isWebAppSigninPath(pathname) @@ -101,7 +102,12 @@ function Splash({ children }: PropsWithChildren) { }) setWebAppPassport(effectiveShareCode, access_token) redirectOrFinish() - } catch { + } catch (error) { + if (error instanceof Response && error.status === 404) { + setUnavailableShareCode(effectiveShareCode) + await webAppLogout(effectiveShareCode) + return + } await webAppLogout(effectiveShareCode) proceedToAuth() } @@ -126,11 +132,23 @@ function Splash({ children }: PropsWithChildren) { code={code || t(($) => $['common.appUnavailable'], { ns: 'share' })} unknownReason={message} /> - + + + ) + } + + if (unavailableShareCode === shareCode) { + return ( +
+
) }