fix(e2e): avoid persisting local web env overrides

This commit is contained in:
yyh
2026-04-13 09:36:41 +08:00
parent e3a2a36dbc
commit fa2cf9ab26
5 changed files with 120 additions and 50 deletions

View File

@ -6,3 +6,13 @@ Feature: Sign out
And I open the account menu
And I sign out
Then I should be on the sign-in page
Scenario: Redirect back to sign-in when reopening the apps console after signing out
Given I am signed in as the default E2E admin
When I open the apps console
And I open the account menu
And I sign out
Then I should be on the sign-in page
When I open the apps console
Then I should be redirected to the signin page
And I should see the "Sign in" button

View File

@ -3,7 +3,7 @@ import { chromium, type Browser } from '@playwright/test'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ensureAuthenticatedState } from '../../fixtures/auth'
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
import type { DifyWorld } from './world'
@ -31,7 +31,7 @@ const writeArtifact = async (
return artifactPath
}
BeforeAll(async () => {
BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => {
await mkdir(artifactsDir, { recursive: true })
browser = await chromium.launch({

View File

@ -12,7 +12,7 @@ export type AuthSessionMetadata = {
usedInitPassword: boolean
}
const WAIT_TIMEOUT_MS = 120_000
export const AUTH_BOOTSTRAP_TIMEOUT_MS = 120_000
const e2eRoot = fileURLToPath(new URL('..', import.meta.url))
export const authDir = path.join(e2eRoot, '.auth')
@ -39,40 +39,54 @@ const escapeRegex = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '
const appURL = (baseURL: string, pathname: string) => new URL(pathname, baseURL).toString()
const waitForPageState = async (page: Page) => {
type AuthPageState = 'install' | 'login' | 'init'
const getRemainingTimeout = (deadline: number) => Math.max(deadline - Date.now(), 1)
const waitForPageState = async (page: Page, deadline: number): Promise<AuthPageState> => {
const installHeading = page.getByRole('heading', { name: 'Setting up an admin account' })
const signInButton = page.getByRole('button', { name: 'Sign in' })
const initPasswordField = page.getByLabel('Admin initialization password')
const deadline = Date.now() + WAIT_TIMEOUT_MS
while (Date.now() < deadline) {
if (await installHeading.isVisible().catch(() => false)) return 'install' as const
if (await signInButton.isVisible().catch(() => false)) return 'login' as const
if (await initPasswordField.isVisible().catch(() => false)) return 'init' as const
await page.waitForTimeout(1_000)
try {
return await Promise.any<AuthPageState>([
installHeading
.waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) })
.then(() => 'install'),
signInButton
.waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) })
.then(() => 'login'),
initPasswordField
.waitFor({ state: 'visible', timeout: getRemainingTimeout(deadline) })
.then(() => 'init'),
])
} catch {
throw new Error(`Unable to determine auth page state for ${page.url()}`)
}
throw new Error(`Unable to determine auth page state for ${page.url()}`)
}
const completeInitPasswordIfNeeded = async (page: Page) => {
const completeInitPasswordIfNeeded = async (page: Page, deadline: number) => {
const initPasswordField = page.getByLabel('Admin initialization password')
if (!(await initPasswordField.isVisible({ timeout: 3_000 }).catch(() => false))) return false
const needsInitPassword = await initPasswordField
.waitFor({ state: 'visible', timeout: Math.min(getRemainingTimeout(deadline), 3_000) })
.then(() => true)
.catch(() => false)
if (!needsInitPassword) return false
await initPasswordField.fill(initPassword)
await page.getByRole('button', { name: 'Validate' }).click()
await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
timeout: getRemainingTimeout(deadline),
})
return true
}
const completeInstall = async (page: Page, baseURL: string) => {
const completeInstall = async (page: Page, baseURL: string, deadline: number) => {
await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
timeout: getRemainingTimeout(deadline),
})
await page.getByLabel('Email address').fill(adminCredentials.email)
@ -81,13 +95,13 @@ const completeInstall = async (page: Page, baseURL: string) => {
await page.getByRole('button', { name: 'Set up' }).click()
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
timeout: WAIT_TIMEOUT_MS,
timeout: getRemainingTimeout(deadline),
})
}
const completeLogin = async (page: Page, baseURL: string) => {
const completeLogin = async (page: Page, baseURL: string, deadline: number) => {
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
timeout: getRemainingTimeout(deadline),
})
await page.getByLabel('Email address').fill(adminCredentials.email)
@ -95,12 +109,13 @@ const completeLogin = async (page: Page, baseURL: string) => {
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
timeout: WAIT_TIMEOUT_MS,
timeout: getRemainingTimeout(deadline),
})
}
export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => {
const baseURL = resolveBaseURL(configuredBaseURL)
const deadline = Date.now() + AUTH_BOOTSTRAP_TIMEOUT_MS
await mkdir(authDir, { recursive: true })
@ -111,25 +126,28 @@ export const ensureAuthenticatedState = async (browser: Browser, configuredBaseU
const page = await context.newPage()
try {
await page.goto(appURL(baseURL, '/install'), { waitUntil: 'networkidle' })
await page.goto(appURL(baseURL, '/install'), {
timeout: getRemainingTimeout(deadline),
waitUntil: 'domcontentloaded',
})
let usedInitPassword = await completeInitPasswordIfNeeded(page)
let pageState = await waitForPageState(page)
let usedInitPassword = await completeInitPasswordIfNeeded(page, deadline)
let pageState = await waitForPageState(page, deadline)
while (pageState === 'init') {
const completedInitPassword = await completeInitPasswordIfNeeded(page)
const completedInitPassword = await completeInitPasswordIfNeeded(page, deadline)
if (!completedInitPassword)
throw new Error(`Unable to validate initialization password for ${page.url()}`)
usedInitPassword = true
pageState = await waitForPageState(page)
pageState = await waitForPageState(page, deadline)
}
if (pageState === 'install') await completeInstall(page, baseURL)
else await completeLogin(page, baseURL)
if (pageState === 'install') await completeInstall(page, baseURL, deadline)
else await completeLogin(page, baseURL, deadline)
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible({
timeout: WAIT_TIMEOUT_MS,
timeout: getRemainingTimeout(deadline),
})
await context.storageState({ path: authStatePath })

View File

@ -1,4 +1,5 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { createHash } from 'node:crypto'
import { access, copyFile, readFile, writeFile } from 'node:fs/promises'
import net from 'node:net'
import path from 'node:path'
@ -38,6 +39,10 @@ export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.exa
export const webEnvLocalFile = path.join(webDir, '.env.local')
export const webEnvExampleFile = path.join(webDir, '.env.example')
export const apiEnvExampleFile = path.join(apiDir, 'tests', 'integration_tests', '.env.example')
export const e2eWebEnvOverrides = {
NEXT_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/console/api',
NEXT_PUBLIC_PUBLIC_API_PREFIX: 'http://127.0.0.1:5001/api',
} satisfies Record<string, string>
const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ')
@ -166,13 +171,16 @@ export const ensureLineInFile = async (filePath: string, line: string) => {
await writeFile(filePath, `${normalizedContent}${line}\n`, 'utf8')
}
export const ensureWebEnvLocal = async () => {
await ensureFileExists(webEnvLocalFile, webEnvExampleFile)
const fileContent = await readFile(webEnvLocalFile, 'utf8')
const nextContent = fileContent.replaceAll('http://localhost:5001', 'http://127.0.0.1:5001')
if (nextContent !== fileContent) await writeFile(webEnvLocalFile, nextContent, 'utf8')
export const getWebEnvLocalHash = async () => {
const fileContent = await readFile(webEnvLocalFile, 'utf8').catch(() => '')
return createHash('sha256')
.update(
JSON.stringify({
envLocal: fileContent,
overrides: e2eWebEnvOverrides,
}),
)
.digest('hex')
}
export const readSimpleDotenv = async (filePath: string) => {

View File

@ -1,14 +1,15 @@
import { access, mkdir, rm } from 'node:fs/promises'
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { waitForUrl } from '../support/process'
import {
apiDir,
apiEnvExampleFile,
dockerDir,
e2eWebEnvOverrides,
e2eDir,
ensureFileExists,
ensureLineInFile,
ensureWebEnvLocal,
getWebEnvLocalHash,
isMainModule,
isTcpPortReachable,
middlewareComposeFile,
@ -23,6 +24,7 @@ import {
} from './common'
const buildIdPath = path.join(webDir, '.next', 'BUILD_ID')
const webBuildEnvStampPath = path.join(webDir, '.next', 'e2e-web-env.sha256')
const middlewareDataPaths = [
path.join(dockerDir, 'volumes', 'db', 'data'),
@ -110,27 +112,47 @@ const waitForDependency = async ({
}
export const ensureWebBuild = async () => {
await ensureWebEnvLocal()
const envHash = await getWebEnvLocalHash()
const buildEnv = {
...e2eWebEnvOverrides,
}
if (process.env.E2E_FORCE_WEB_BUILD === '1') {
await runCommandOrThrow({
command: 'pnpm',
args: ['run', 'build'],
cwd: webDir,
env: buildEnv,
})
await writeFile(webBuildEnvStampPath, `${envHash}\n`, 'utf8')
return
}
try {
await access(buildIdPath)
console.log('Reusing existing web build artifact.')
const [buildExists, previousEnvHash] = await Promise.all([
access(buildIdPath)
.then(() => true)
.catch(() => false),
readFile(webBuildEnvStampPath, 'utf8')
.then((value) => value.trim())
.catch(() => ''),
])
if (buildExists && previousEnvHash === envHash) {
console.log('Reusing existing web build artifact.')
return
}
} catch {
await runCommandOrThrow({
command: 'pnpm',
args: ['run', 'build'],
cwd: webDir,
})
// Fall through to rebuild when the existing build cannot be verified.
}
await runCommandOrThrow({
command: 'pnpm',
args: ['run', 'build'],
cwd: webDir,
env: buildEnv,
})
await writeFile(webBuildEnvStampPath, `${envHash}\n`, 'utf8')
}
export const startWeb = async () => {
@ -141,6 +163,7 @@ export const startWeb = async () => {
args: ['run', 'start'],
cwd: webDir,
env: {
...e2eWebEnvOverrides,
HOSTNAME: '127.0.0.1',
PORT: '3000',
},
@ -152,14 +175,25 @@ export const startApi = async () => {
await runCommandOrThrow({
command: 'uv',
args: ['run', '--project', '.', 'flask', 'upgrade-db'],
args: ['run', '--project', '.', '--no-sync', 'flask', 'upgrade-db'],
cwd: apiDir,
env,
})
await runForegroundProcess({
command: 'uv',
args: ['run', '--project', '.', 'flask', 'run', '--host', '127.0.0.1', '--port', '5001'],
args: [
'run',
'--project',
'.',
'--no-sync',
'flask',
'run',
'--host',
'127.0.0.1',
'--port',
'5001',
],
cwd: apiDir,
env,
})