fix(web): show unavailable state for disabled Web Apps (#39392)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
yyh 2026-07-22 11:32:40 +08:00 committed by GitHub
parent 3f0f57c594
commit 04f9026724
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 236 additions and 68 deletions

View File

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

View File

@ -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}`
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 },
{

View File

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

View File

@ -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<WorkflowDraft> {
export async function syncMinimalWorkflowDraft(appId: string): Promise<void> {
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<void> {
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<void> {
export async function syncRunnableWorkflowDraft(appId: string): Promise<void> {
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<void> {
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<void> {
export async function publishWorkflowApp(appId: string): Promise<void> {
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<AppDetailWithSite> {
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<string> {
@ -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)
}

View File

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

View File

@ -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(
<Splash>
<div>share application</div>
</Splash>,
)
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(
<Splash>
<div>share application</div>
</Splash>,
)
expect(screen.getByRole('button', { name: 'share.login.backToHome' })).toBeInTheDocument()
})
})

View File

@ -57,6 +57,7 @@ function Splash({ children }: PropsWithChildren) {
}, [getSigninUrl, pathname, redirectUrl, router, shareCode])
const [isLoading, setIsLoading] = useState(true)
const [unavailableShareCode, setUnavailableShareCode] = useState<string>()
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}
/>
<span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}>
<button
type="button"
className="cursor-pointer system-sm-regular text-text-tertiary"
onClick={backToHome}
>
{code === '403'
? t(($) => $['userProfile.logout'], { ns: 'common' })
: t(($) => $['login.backToHome'], { ns: 'share' })}
</span>
</button>
</div>
)
}
if (unavailableShareCode === shareCode) {
return (
<div className="flex h-full items-center justify-center">
<AppUnavailable />
</div>
)
}