mirror of
https://github.com/langgenius/dify.git
synced 2026-03-29 20:06:48 +00:00
Compare commits
1 Commits
codex/i18n
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
548cadacff |
70
.github/workflows/main-ci.yml
vendored
70
.github/workflows/main-ci.yml
vendored
@@ -42,6 +42,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
api-changed: ${{ steps.changes.outputs.api }}
|
||||
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
||||
web-changed: ${{ steps.changes.outputs.web }}
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
@@ -59,6 +60,16 @@ jobs:
|
||||
- 'web/**'
|
||||
- '.github/workflows/web-tests.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
e2e:
|
||||
- 'api/**'
|
||||
- 'api/pyproject.toml'
|
||||
- 'api/uv.lock'
|
||||
- 'e2e/**'
|
||||
- 'web/**'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/middleware.env.example'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
vdb:
|
||||
- 'api/core/rag/datasource/**'
|
||||
- 'docker/**'
|
||||
@@ -190,6 +201,65 @@ jobs:
|
||||
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
web-e2e-run:
|
||||
name: Run Web Full-Stack E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
|
||||
uses: ./.github/workflows/web-e2e.yml
|
||||
|
||||
web-e2e-skip:
|
||||
name: Skip Web Full-Stack E2E
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Report skipped web full-stack e2e
|
||||
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
|
||||
|
||||
web-e2e:
|
||||
name: Web Full-Stack E2E
|
||||
if: ${{ always() }}
|
||||
needs:
|
||||
- pre_job
|
||||
- check-changes
|
||||
- web-e2e-run
|
||||
- web-e2e-skip
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Finalize Web Full-Stack E2E status
|
||||
env:
|
||||
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
|
||||
RUN_RESULT: ${{ needs.web-e2e-run.result }}
|
||||
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
|
||||
run: |
|
||||
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||
echo "Web full-stack E2E ran successfully."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web full-stack E2E was required but finished with result: $RUN_RESULT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||
echo "Web full-stack E2E was skipped because no E2E-related files changed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||
exit 1
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
needs: pre_job
|
||||
|
||||
72
.github/workflows/web-e2e.yml
vendored
Normal file
72
.github/workflows/web-e2e.yml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Web Full-Stack E2E
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: web-e2e-${{ github.head_ref || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web dependencies
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Install E2E package dependencies
|
||||
working-directory: ./e2e
|
||||
run: vp install --frozen-lockfile
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
cache-dependency-glob: api/uv.lock
|
||||
|
||||
- name: Install API dependencies
|
||||
run: uv sync --project api --dev
|
||||
|
||||
- name: Install Playwright browser
|
||||
working-directory: ./e2e
|
||||
run: vp run e2e:install
|
||||
|
||||
- name: Run isolated source-api and built-web Cucumber E2E tests
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
run: vp run e2e:full
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: cucumber-report
|
||||
path: e2e/cucumber-report
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E logs
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: e2e/.logs
|
||||
retention-days: 7
|
||||
@@ -127,6 +127,8 @@ services:
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text}
|
||||
|
||||
6
e2e/.gitignore
vendored
Normal file
6
e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.auth/
|
||||
playwright-report/
|
||||
test-results/
|
||||
cucumber-report/
|
||||
.logs/
|
||||
164
e2e/AGENTS.md
Normal file
164
e2e/AGENTS.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# E2E
|
||||
|
||||
This package contains the repository-level end-to-end tests for Dify.
|
||||
|
||||
This file is the canonical package guide for `e2e/`. Keep detailed workflow, architecture, debugging, and reporting documentation here. Keep `README.md` as a minimal pointer to this file so the two documents do not drift.
|
||||
|
||||
The suite uses Cucumber for scenario definitions and Playwright as the browser execution layer.
|
||||
|
||||
It tests:
|
||||
|
||||
- backend API started from source
|
||||
- frontend served from the production artifact
|
||||
- middleware services started from Docker
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js `^22.22.1`
|
||||
- `pnpm`
|
||||
- `uv`
|
||||
- Docker
|
||||
|
||||
Install Playwright browsers once:
|
||||
|
||||
```bash
|
||||
cd e2e
|
||||
pnpm install
|
||||
pnpm e2e:install
|
||||
pnpm check
|
||||
```
|
||||
|
||||
Use `pnpm check` as the default local verification step after editing E2E TypeScript, Cucumber support code, or feature glue. It runs formatting, linting, and type checks for this package.
|
||||
|
||||
Common commands:
|
||||
|
||||
```bash
|
||||
# authenticated-only regression (default excludes @fresh)
|
||||
# expects backend API, frontend artifact, and middleware stack to already be running
|
||||
pnpm e2e
|
||||
|
||||
# full reset + fresh install + authenticated scenarios
|
||||
# starts required middleware/dependencies for you
|
||||
pnpm e2e:full
|
||||
|
||||
# run a tagged subset
|
||||
pnpm e2e -- --tags @smoke
|
||||
|
||||
# headed browser
|
||||
pnpm e2e:headed -- --tags @smoke
|
||||
|
||||
# slow down browser actions for local debugging
|
||||
E2E_SLOW_MO=500 pnpm e2e:headed -- --tags @smoke
|
||||
```
|
||||
|
||||
Frontend artifact behavior:
|
||||
|
||||
- if `web/.next/BUILD_ID` exists, E2E reuses the existing build by default
|
||||
- if you set `E2E_FORCE_WEB_BUILD=1`, E2E rebuilds the frontend before starting it
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["Start E2E run"] --> B["run-cucumber.ts orchestrates setup/API/frontend"]
|
||||
B --> C["support/web-server.ts starts or reuses frontend directly"]
|
||||
C --> D["Cucumber loads config, steps, and support modules"]
|
||||
D --> E["BeforeAll bootstraps shared auth state via /install"]
|
||||
E --> F{"Which command is running?"}
|
||||
F -->|`pnpm e2e`| G["Run config default tags: not @fresh and not @skip"]
|
||||
F -->|`pnpm e2e:full*`| H["Override tags to not @skip"]
|
||||
G --> I["Per-scenario BrowserContext from shared browser"]
|
||||
H --> I
|
||||
I --> J["Failure artifacts written to cucumber-report/artifacts"]
|
||||
```
|
||||
|
||||
Ownership is split like this:
|
||||
|
||||
- `scripts/setup.ts` is the single environment entrypoint for reset, middleware, backend, and frontend startup
|
||||
- `run-cucumber.ts` orchestrates the E2E run and Cucumber invocation
|
||||
- `support/web-server.ts` manages frontend reuse, startup, readiness, and shutdown
|
||||
- `features/support/hooks.ts` manages auth bootstrap, scenario lifecycle, and diagnostics
|
||||
- `features/support/world.ts` owns per-scenario typed context
|
||||
- `features/step-definitions/` holds domain-oriented glue so the official VS Code Cucumber plugin works with default conventions when `e2e/` is opened as the workspace root
|
||||
|
||||
Package layout:
|
||||
|
||||
- `features/`: Gherkin scenarios grouped by capability
|
||||
- `features/step-definitions/`: domain-oriented step definitions
|
||||
- `features/support/hooks.ts`: suite lifecycle, auth-state bootstrap, diagnostics
|
||||
- `features/support/world.ts`: shared scenario context
|
||||
- `support/web-server.ts`: typed frontend startup/reuse logic
|
||||
- `scripts/setup.ts`: reset and service lifecycle commands
|
||||
- `scripts/run-cucumber.ts`: Cucumber orchestration entrypoint
|
||||
|
||||
Behavior depends on instance state:
|
||||
|
||||
- uninitialized instance: completes install and stores authenticated state
|
||||
- initialized instance: signs in and reuses authenticated state
|
||||
|
||||
Because of that, the `@fresh` install scenario only runs in the `pnpm e2e:full*` flows. The default `pnpm e2e*` flows exclude `@fresh` via Cucumber config tags so they can be re-run against an already initialized instance.
|
||||
|
||||
Reset all persisted E2E state:
|
||||
|
||||
```bash
|
||||
pnpm e2e:reset
|
||||
```
|
||||
|
||||
This removes:
|
||||
|
||||
- `docker/volumes/db/data`
|
||||
- `docker/volumes/redis/data`
|
||||
- `docker/volumes/weaviate`
|
||||
- `docker/volumes/plugin_daemon`
|
||||
- `e2e/.auth`
|
||||
- `e2e/.logs`
|
||||
- `e2e/cucumber-report`
|
||||
|
||||
Start the full middleware stack:
|
||||
|
||||
```bash
|
||||
pnpm e2e:middleware:up
|
||||
```
|
||||
|
||||
Stop the full middleware stack:
|
||||
|
||||
```bash
|
||||
pnpm e2e:middleware:down
|
||||
```
|
||||
|
||||
The middleware stack includes:
|
||||
|
||||
- PostgreSQL
|
||||
- Redis
|
||||
- Weaviate
|
||||
- Sandbox
|
||||
- SSRF proxy
|
||||
- Plugin daemon
|
||||
|
||||
Fresh install verification:
|
||||
|
||||
```bash
|
||||
pnpm e2e:full
|
||||
```
|
||||
|
||||
Run the Cucumber suite against an already running middleware stack:
|
||||
|
||||
```bash
|
||||
pnpm e2e:middleware:up
|
||||
pnpm e2e
|
||||
pnpm e2e:middleware:down
|
||||
```
|
||||
|
||||
Artifacts and diagnostics:
|
||||
|
||||
- `cucumber-report/report.html`: HTML report
|
||||
- `cucumber-report/report.json`: JSON report
|
||||
- `cucumber-report/artifacts/`: failure screenshots and HTML captures
|
||||
- `.logs/cucumber-api.log`: backend startup log
|
||||
- `.logs/cucumber-web.log`: frontend startup log
|
||||
|
||||
Open the HTML report locally with:
|
||||
|
||||
```bash
|
||||
open cucumber-report/report.html
|
||||
```
|
||||
3
e2e/README.md
Normal file
3
e2e/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# E2E
|
||||
|
||||
Canonical documentation for this package lives in [AGENTS.md](./AGENTS.md).
|
||||
19
e2e/cucumber.config.ts
Normal file
19
e2e/cucumber.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IConfiguration } from '@cucumber/cucumber'
|
||||
|
||||
const config = {
|
||||
format: [
|
||||
'progress-bar',
|
||||
'summary',
|
||||
'html:./cucumber-report/report.html',
|
||||
'json:./cucumber-report/report.json',
|
||||
],
|
||||
import: ['features/**/*.ts'],
|
||||
parallel: 1,
|
||||
paths: ['features/**/*.feature'],
|
||||
tags: process.env.E2E_CUCUMBER_TAGS || 'not @fresh and not @skip',
|
||||
timeout: 60_000,
|
||||
} satisfies Partial<IConfiguration> & {
|
||||
timeout: number
|
||||
}
|
||||
|
||||
export default config
|
||||
10
e2e/features/apps/create-app.feature
Normal file
10
e2e/features/apps/create-app.feature
Normal file
@@ -0,0 +1,10 @@
|
||||
@apps @authenticated
|
||||
Feature: Create app
|
||||
Scenario: Create a new blank app and redirect to the 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 enter a unique E2E app name
|
||||
And I confirm app creation
|
||||
Then I should land on the app editor
|
||||
And I should see the "Orchestrate" text
|
||||
8
e2e/features/smoke/authenticated-entry.feature
Normal file
8
e2e/features/smoke/authenticated-entry.feature
Normal file
@@ -0,0 +1,8 @@
|
||||
@smoke @authenticated
|
||||
Feature: Authenticated app console
|
||||
Scenario: Open the apps console with the shared authenticated state
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
Then I should stay on the apps console
|
||||
And I should see the "Create from Blank" button
|
||||
And I should not see the "Sign in" button
|
||||
7
e2e/features/smoke/install.feature
Normal file
7
e2e/features/smoke/install.feature
Normal file
@@ -0,0 +1,7 @@
|
||||
@smoke @fresh
|
||||
Feature: Fresh installation bootstrap
|
||||
Scenario: Complete the initial installation bootstrap on a fresh instance
|
||||
Given the last authentication bootstrap came from a fresh install
|
||||
When I open the apps console
|
||||
Then I should stay on the apps console
|
||||
And I should see the "Create from Blank" button
|
||||
29
e2e/features/step-definitions/apps/create-app.steps.ts
Normal file
29
e2e/features/step-definitions/apps/create-app.steps.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
When('I start creating a blank app', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Create from Blank' }).click()
|
||||
})
|
||||
|
||||
When('I enter a unique E2E app name', async function (this: DifyWorld) {
|
||||
const appName = `E2E App ${Date.now()}`
|
||||
|
||||
await this.getPage().getByPlaceholder('Give your app a name').fill(appName)
|
||||
})
|
||||
|
||||
When('I confirm app creation', async function (this: DifyWorld) {
|
||||
const createButton = this.getPage()
|
||||
.getByRole('button', { name: /^Create(?:\s|$)/ })
|
||||
.last()
|
||||
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click()
|
||||
})
|
||||
|
||||
Then('I should land on the app editor', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/app\/[^/]+\/(workflow|configuration)(?:\?.*)?$/)
|
||||
})
|
||||
11
e2e/features/step-definitions/common/auth.steps.ts
Normal file
11
e2e/features/step-definitions/common/auth.steps.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Given } from '@cucumber/cucumber'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
Given('I am signed in as the default E2E admin', async function (this: DifyWorld) {
|
||||
const session = await this.getAuthSession()
|
||||
|
||||
this.attach(
|
||||
`Authenticated as ${session.adminEmail} using ${session.mode} flow at ${session.baseURL}.`,
|
||||
'text/plain',
|
||||
)
|
||||
})
|
||||
23
e2e/features/step-definitions/common/navigation.steps.ts
Normal file
23
e2e/features/step-definitions/common/navigation.steps.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
When('I open the apps console', async function (this: DifyWorld) {
|
||||
await this.getPage().goto('/apps')
|
||||
})
|
||||
|
||||
Then('I should stay on the apps console', async function (this: DifyWorld) {
|
||||
await expect(this.getPage()).toHaveURL(/\/apps(?:\?.*)?$/)
|
||||
})
|
||||
|
||||
Then('I should see the {string} button', async function (this: DifyWorld, label: string) {
|
||||
await expect(this.getPage().getByRole('button', { name: label })).toBeVisible()
|
||||
})
|
||||
|
||||
Then('I should not see the {string} button', async function (this: DifyWorld, label: string) {
|
||||
await expect(this.getPage().getByRole('button', { name: label })).not.toBeVisible()
|
||||
})
|
||||
|
||||
Then('I should see the {string} text', async function (this: DifyWorld, text: string) {
|
||||
await expect(this.getPage().getByText(text)).toBeVisible({ timeout: 30_000 })
|
||||
})
|
||||
12
e2e/features/step-definitions/smoke/install.steps.ts
Normal file
12
e2e/features/step-definitions/smoke/install.steps.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Given } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
|
||||
Given(
|
||||
'the last authentication bootstrap came from a fresh install',
|
||||
async function (this: DifyWorld) {
|
||||
const session = await this.getAuthSession()
|
||||
|
||||
expect(session.mode).toBe('install')
|
||||
},
|
||||
)
|
||||
90
e2e/features/support/hooks.ts
Normal file
90
e2e/features/support/hooks.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { After, AfterAll, Before, BeforeAll, Status, setDefaultTimeout } from '@cucumber/cucumber'
|
||||
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 { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
import type { DifyWorld } from './world'
|
||||
|
||||
const e2eRoot = fileURLToPath(new URL('../..', import.meta.url))
|
||||
const artifactsDir = path.join(e2eRoot, 'cucumber-report', 'artifacts')
|
||||
|
||||
let browser: Browser | undefined
|
||||
|
||||
setDefaultTimeout(60_000)
|
||||
|
||||
const sanitizeForPath = (value: string) =>
|
||||
value.replaceAll(/[^a-zA-Z0-9_-]+/g, '-').replaceAll(/^-+|-+$/g, '')
|
||||
|
||||
const writeArtifact = async (
|
||||
scenarioName: string,
|
||||
extension: 'html' | 'png',
|
||||
contents: Buffer | string,
|
||||
) => {
|
||||
const artifactPath = path.join(
|
||||
artifactsDir,
|
||||
`${Date.now()}-${sanitizeForPath(scenarioName || 'scenario')}.${extension}`,
|
||||
)
|
||||
await writeFile(artifactPath, contents)
|
||||
|
||||
return artifactPath
|
||||
}
|
||||
|
||||
BeforeAll(async () => {
|
||||
await mkdir(artifactsDir, { recursive: true })
|
||||
|
||||
browser = await chromium.launch({
|
||||
headless: cucumberHeadless,
|
||||
slowMo: cucumberSlowMo,
|
||||
})
|
||||
|
||||
console.log(`[e2e] session cache bootstrap against ${baseURL}`)
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
})
|
||||
|
||||
Before(async function (this: DifyWorld, { pickle }) {
|
||||
if (!browser) throw new Error('Shared Playwright browser is not available.')
|
||||
|
||||
await this.startAuthenticatedSession(browser)
|
||||
this.scenarioStartedAt = Date.now()
|
||||
|
||||
const tags = pickle.tags.map((tag) => tag.name).join(' ')
|
||||
console.log(`[e2e] start ${pickle.name}${tags ? ` ${tags}` : ''}`)
|
||||
})
|
||||
|
||||
After(async function (this: DifyWorld, { pickle, result }) {
|
||||
const elapsedMs = this.scenarioStartedAt ? Date.now() - this.scenarioStartedAt : undefined
|
||||
|
||||
if (result?.status !== Status.PASSED && this.page) {
|
||||
const screenshot = await this.page.screenshot({
|
||||
fullPage: true,
|
||||
})
|
||||
const screenshotPath = await writeArtifact(pickle.name, 'png', screenshot)
|
||||
this.attach(screenshot, 'image/png')
|
||||
|
||||
const html = await this.page.content()
|
||||
const htmlPath = await writeArtifact(pickle.name, 'html', html)
|
||||
this.attach(html, 'text/html')
|
||||
|
||||
if (this.consoleErrors.length > 0)
|
||||
this.attach(`Console Errors:\n${this.consoleErrors.join('\n')}`, 'text/plain')
|
||||
|
||||
if (this.pageErrors.length > 0)
|
||||
this.attach(`Page Errors:\n${this.pageErrors.join('\n')}`, 'text/plain')
|
||||
|
||||
this.attach(`Artifacts:\n${[screenshotPath, htmlPath].join('\n')}`, 'text/plain')
|
||||
}
|
||||
|
||||
const status = result?.status || 'UNKNOWN'
|
||||
console.log(
|
||||
`[e2e] end ${pickle.name} status=${status}${elapsedMs ? ` durationMs=${elapsedMs}` : ''}`,
|
||||
)
|
||||
|
||||
await this.closeSession()
|
||||
})
|
||||
|
||||
AfterAll(async () => {
|
||||
await browser?.close()
|
||||
browser = undefined
|
||||
})
|
||||
68
e2e/features/support/world.ts
Normal file
68
e2e/features/support/world.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { type IWorldOptions, World, setWorldConstructor } from '@cucumber/cucumber'
|
||||
import type { Browser, BrowserContext, ConsoleMessage, Page } from '@playwright/test'
|
||||
import {
|
||||
authStatePath,
|
||||
readAuthSessionMetadata,
|
||||
type AuthSessionMetadata,
|
||||
} from '../../fixtures/auth'
|
||||
import { baseURL, defaultLocale } from '../../test-env'
|
||||
|
||||
export class DifyWorld extends World {
|
||||
context: BrowserContext | undefined
|
||||
page: Page | undefined
|
||||
consoleErrors: string[] = []
|
||||
pageErrors: string[] = []
|
||||
scenarioStartedAt: number | undefined
|
||||
session: AuthSessionMetadata | undefined
|
||||
|
||||
constructor(options: IWorldOptions) {
|
||||
super(options)
|
||||
this.resetScenarioState()
|
||||
}
|
||||
|
||||
resetScenarioState() {
|
||||
this.consoleErrors = []
|
||||
this.pageErrors = []
|
||||
}
|
||||
|
||||
async startAuthenticatedSession(browser: Browser) {
|
||||
this.resetScenarioState()
|
||||
this.context = await browser.newContext({
|
||||
baseURL,
|
||||
locale: defaultLocale,
|
||||
storageState: authStatePath,
|
||||
})
|
||||
this.context.setDefaultTimeout(30_000)
|
||||
this.page = await this.context.newPage()
|
||||
this.page.setDefaultTimeout(30_000)
|
||||
|
||||
this.page.on('console', (message: ConsoleMessage) => {
|
||||
if (message.type() === 'error') this.consoleErrors.push(message.text())
|
||||
})
|
||||
this.page.on('pageerror', (error) => {
|
||||
this.pageErrors.push(error.message)
|
||||
})
|
||||
}
|
||||
|
||||
getPage() {
|
||||
if (!this.page) throw new Error('Playwright page has not been initialized for this scenario.')
|
||||
|
||||
return this.page
|
||||
}
|
||||
|
||||
async getAuthSession() {
|
||||
this.session ??= await readAuthSessionMetadata()
|
||||
return this.session
|
||||
}
|
||||
|
||||
async closeSession() {
|
||||
await this.context?.close()
|
||||
this.context = undefined
|
||||
this.page = undefined
|
||||
this.session = undefined
|
||||
this.scenarioStartedAt = undefined
|
||||
this.resetScenarioState()
|
||||
}
|
||||
}
|
||||
|
||||
setWorldConstructor(DifyWorld)
|
||||
148
e2e/fixtures/auth.ts
Normal file
148
e2e/fixtures/auth.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Browser, Page } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { defaultBaseURL, defaultLocale } from '../test-env'
|
||||
|
||||
export type AuthSessionMetadata = {
|
||||
adminEmail: string
|
||||
baseURL: string
|
||||
mode: 'install' | 'login'
|
||||
usedInitPassword: boolean
|
||||
}
|
||||
|
||||
const WAIT_TIMEOUT_MS = 120_000
|
||||
const e2eRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
export const authDir = path.join(e2eRoot, '.auth')
|
||||
export const authStatePath = path.join(authDir, 'admin.json')
|
||||
export const authMetadataPath = path.join(authDir, 'session.json')
|
||||
|
||||
export const adminCredentials = {
|
||||
email: process.env.E2E_ADMIN_EMAIL || 'e2e-admin@example.com',
|
||||
name: process.env.E2E_ADMIN_NAME || 'E2E Admin',
|
||||
password: process.env.E2E_ADMIN_PASSWORD || 'E2eAdmin12345',
|
||||
}
|
||||
|
||||
const initPassword = process.env.E2E_INIT_PASSWORD || 'E2eInit12345'
|
||||
|
||||
export const resolveBaseURL = (configuredBaseURL?: string) =>
|
||||
configuredBaseURL || process.env.E2E_BASE_URL || defaultBaseURL
|
||||
|
||||
export const readAuthSessionMetadata = async () => {
|
||||
const content = await readFile(authMetadataPath, 'utf8')
|
||||
return JSON.parse(content) as AuthSessionMetadata
|
||||
}
|
||||
|
||||
const escapeRegex = (value: string) => value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
const appURL = (baseURL: string, pathname: string) => new URL(pathname, baseURL).toString()
|
||||
|
||||
const waitForPageState = async (page: Page) => {
|
||||
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)
|
||||
}
|
||||
|
||||
throw new Error(`Unable to determine auth page state for ${page.url()}`)
|
||||
}
|
||||
|
||||
const completeInitPasswordIfNeeded = async (page: Page) => {
|
||||
const initPasswordField = page.getByLabel('Admin initialization password')
|
||||
if (!(await initPasswordField.isVisible({ timeout: 3_000 }).catch(() => false))) 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,
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const completeInstall = async (page: Page, baseURL: string) => {
|
||||
await expect(page.getByRole('heading', { name: 'Setting up an admin account' })).toBeVisible({
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
await page.getByLabel('Email address').fill(adminCredentials.email)
|
||||
await page.getByLabel('Username').fill(adminCredentials.name)
|
||||
await page.getByLabel('Password').fill(adminCredentials.password)
|
||||
await page.getByRole('button', { name: 'Set up' }).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
const completeLogin = async (page: Page, baseURL: string) => {
|
||||
await expect(page.getByRole('button', { name: 'Sign in' })).toBeVisible({
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
await page.getByLabel('Email address').fill(adminCredentials.email)
|
||||
await page.getByLabel('Password').fill(adminCredentials.password)
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`^${escapeRegex(baseURL)}/apps(?:\\?.*)?$`), {
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
}
|
||||
|
||||
export const ensureAuthenticatedState = async (browser: Browser, configuredBaseURL?: string) => {
|
||||
const baseURL = resolveBaseURL(configuredBaseURL)
|
||||
|
||||
await mkdir(authDir, { recursive: true })
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL,
|
||||
locale: defaultLocale,
|
||||
})
|
||||
const page = await context.newPage()
|
||||
|
||||
try {
|
||||
await page.goto(appURL(baseURL, '/install'), { waitUntil: 'networkidle' })
|
||||
|
||||
let usedInitPassword = await completeInitPasswordIfNeeded(page)
|
||||
let pageState = await waitForPageState(page)
|
||||
|
||||
while (pageState === 'init') {
|
||||
const completedInitPassword = await completeInitPasswordIfNeeded(page)
|
||||
if (!completedInitPassword)
|
||||
throw new Error(`Unable to validate initialization password for ${page.url()}`)
|
||||
|
||||
usedInitPassword = true
|
||||
pageState = await waitForPageState(page)
|
||||
}
|
||||
|
||||
if (pageState === 'install') await completeInstall(page, baseURL)
|
||||
else await completeLogin(page, baseURL)
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Create from Blank' })).toBeVisible({
|
||||
timeout: WAIT_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
await context.storageState({ path: authStatePath })
|
||||
|
||||
const metadata: AuthSessionMetadata = {
|
||||
adminEmail: adminCredentials.email,
|
||||
baseURL,
|
||||
mode: pageState,
|
||||
usedInitPassword,
|
||||
}
|
||||
|
||||
await writeFile(authMetadataPath, `${JSON.stringify(metadata, null, 2)}\n`, 'utf8')
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
}
|
||||
34
e2e/package.json
Normal file
34
e2e/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "dify-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"check": "vp check --fix",
|
||||
"e2e": "tsx ./scripts/run-cucumber.ts",
|
||||
"e2e:full": "tsx ./scripts/run-cucumber.ts --full",
|
||||
"e2e:full:headed": "tsx ./scripts/run-cucumber.ts --full --headed",
|
||||
"e2e:headed": "tsx ./scripts/run-cucumber.ts --headed",
|
||||
"e2e:install": "playwright install --with-deps chromium",
|
||||
"e2e:middleware:down": "tsx ./scripts/setup.ts middleware-down",
|
||||
"e2e:middleware:up": "tsx ./scripts/setup.ts middleware-up",
|
||||
"e2e:reset": "tsx ./scripts/setup.ts reset"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cucumber/cucumber": "12.7.0",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@types/node": "25.5.0",
|
||||
"tsx": "4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite-plus": "latest"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@latest",
|
||||
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
||||
}
|
||||
}
|
||||
}
|
||||
2632
e2e/pnpm-lock.yaml
generated
Normal file
2632
e2e/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
242
e2e/scripts/common.ts
Normal file
242
e2e/scripts/common.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { access, copyFile, readFile, writeFile } from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { sleep } from '../support/process'
|
||||
|
||||
type RunCommandOptions = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
stdio?: 'inherit' | 'pipe'
|
||||
}
|
||||
|
||||
type RunCommandResult = {
|
||||
exitCode: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
type ForegroundProcessOptions = {
|
||||
command: string
|
||||
args: string[]
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export const rootDir = fileURLToPath(new URL('../..', import.meta.url))
|
||||
export const e2eDir = path.join(rootDir, 'e2e')
|
||||
export const apiDir = path.join(rootDir, 'api')
|
||||
export const dockerDir = path.join(rootDir, 'docker')
|
||||
export const webDir = path.join(rootDir, 'web')
|
||||
|
||||
export const middlewareComposeFile = path.join(dockerDir, 'docker-compose.middleware.yaml')
|
||||
export const middlewareEnvFile = path.join(dockerDir, 'middleware.env')
|
||||
export const middlewareEnvExampleFile = path.join(dockerDir, 'middleware.env.example')
|
||||
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')
|
||||
|
||||
const formatCommand = (command: string, args: string[]) => [command, ...args].join(' ')
|
||||
|
||||
export const isMainModule = (metaUrl: string) => {
|
||||
const entrypoint = process.argv[1]
|
||||
if (!entrypoint) return false
|
||||
|
||||
return pathToFileURL(entrypoint).href === metaUrl
|
||||
}
|
||||
|
||||
export const runCommand = async ({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
stdio = 'inherit',
|
||||
}: RunCommandOptions): Promise<RunCommandResult> => {
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: stdio === 'inherit' ? 'inherit' : 'pipe',
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
if (stdio === 'pipe') {
|
||||
childProcess.stdout?.on('data', (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
childProcess.stderr?.on('data', (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString()
|
||||
})
|
||||
}
|
||||
|
||||
return await new Promise<RunCommandResult>((resolve, reject) => {
|
||||
childProcess.once('error', reject)
|
||||
childProcess.once('exit', (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 1,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const runCommandOrThrow = async (options: RunCommandOptions) => {
|
||||
const result = await runCommand(options)
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(
|
||||
`Command failed (${result.exitCode}): ${formatCommand(options.command, options.args)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
const forwardSignalsToChild = (childProcess: ChildProcess) => {
|
||||
const handleSignal = (signal: NodeJS.Signals) => {
|
||||
if (childProcess.exitCode === null) childProcess.kill(signal)
|
||||
}
|
||||
|
||||
const onSigint = () => handleSignal('SIGINT')
|
||||
const onSigterm = () => handleSignal('SIGTERM')
|
||||
|
||||
process.on('SIGINT', onSigint)
|
||||
process.on('SIGTERM', onSigterm)
|
||||
|
||||
return () => {
|
||||
process.off('SIGINT', onSigint)
|
||||
process.off('SIGTERM', onSigterm)
|
||||
}
|
||||
}
|
||||
|
||||
export const runForegroundProcess = async ({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
env,
|
||||
}: ForegroundProcessOptions) => {
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
const cleanupSignals = forwardSignalsToChild(childProcess)
|
||||
const exitCode = await new Promise<number>((resolve, reject) => {
|
||||
childProcess.once('error', reject)
|
||||
childProcess.once('exit', (code) => {
|
||||
resolve(code ?? 1)
|
||||
})
|
||||
})
|
||||
|
||||
cleanupSignals()
|
||||
process.exit(exitCode)
|
||||
}
|
||||
|
||||
export const ensureFileExists = async (filePath: string, exampleFilePath: string) => {
|
||||
try {
|
||||
await access(filePath)
|
||||
} catch {
|
||||
await copyFile(exampleFilePath, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureLineInFile = async (filePath: string, line: string) => {
|
||||
const fileContent = await readFile(filePath, 'utf8')
|
||||
const lines = fileContent.split(/\r?\n/)
|
||||
const assignmentPrefix = line.includes('=') ? `${line.slice(0, line.indexOf('='))}=` : null
|
||||
|
||||
if (lines.includes(line)) return
|
||||
|
||||
if (assignmentPrefix && lines.some((existingLine) => existingLine.startsWith(assignmentPrefix)))
|
||||
return
|
||||
|
||||
const normalizedContent = fileContent.endsWith('\n') ? fileContent : `${fileContent}\n`
|
||||
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 readSimpleDotenv = async (filePath: string) => {
|
||||
const fileContent = await readFile(filePath, 'utf8')
|
||||
const entries = fileContent
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith('#'))
|
||||
.map<[string, string]>((line) => {
|
||||
const separatorIndex = line.indexOf('=')
|
||||
const key = separatorIndex === -1 ? line : line.slice(0, separatorIndex).trim()
|
||||
const rawValue = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1).trim()
|
||||
|
||||
if (
|
||||
(rawValue.startsWith('"') && rawValue.endsWith('"')) ||
|
||||
(rawValue.startsWith("'") && rawValue.endsWith("'"))
|
||||
) {
|
||||
return [key, rawValue.slice(1, -1)]
|
||||
}
|
||||
|
||||
return [key, rawValue]
|
||||
})
|
||||
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
export const waitForCondition = async ({
|
||||
check,
|
||||
description,
|
||||
intervalMs,
|
||||
timeoutMs,
|
||||
}: {
|
||||
check: () => Promise<boolean> | boolean
|
||||
description: string
|
||||
intervalMs: number
|
||||
timeoutMs: number
|
||||
}) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (await check()) return
|
||||
|
||||
await sleep(intervalMs)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${description} after ${timeoutMs}ms.`)
|
||||
}
|
||||
|
||||
export const isTcpPortReachable = async (host: string, port: number, timeoutMs = 1_000) => {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({
|
||||
host,
|
||||
port,
|
||||
})
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
socket.removeAllListeners()
|
||||
socket.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
socket.setTimeout(timeoutMs)
|
||||
socket.once('connect', () => finish(true))
|
||||
socket.once('timeout', () => finish(false))
|
||||
socket.once('error', () => finish(false))
|
||||
})
|
||||
}
|
||||
154
e2e/scripts/run-cucumber.ts
Normal file
154
e2e/scripts/run-cucumber.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { mkdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { startWebServer, stopWebServer } from '../support/web-server'
|
||||
import { waitForUrl, startLoggedProcess, stopManagedProcess } from '../support/process'
|
||||
import { apiURL, baseURL, reuseExistingWebServer } from '../test-env'
|
||||
import { e2eDir, isMainModule, runCommand } from './common'
|
||||
import { resetState, startMiddleware, stopMiddleware } from './setup'
|
||||
|
||||
type RunOptions = {
|
||||
forwardArgs: string[]
|
||||
full: boolean
|
||||
headed: boolean
|
||||
}
|
||||
|
||||
const parseArgs = (argv: string[]): RunOptions => {
|
||||
let full = false
|
||||
let headed = false
|
||||
const forwardArgs: string[] = []
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index]
|
||||
|
||||
if (arg === '--') {
|
||||
forwardArgs.push(...argv.slice(index + 1))
|
||||
break
|
||||
}
|
||||
|
||||
if (arg === '--full') {
|
||||
full = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--headed') {
|
||||
headed = true
|
||||
continue
|
||||
}
|
||||
|
||||
forwardArgs.push(arg)
|
||||
}
|
||||
|
||||
return {
|
||||
forwardArgs,
|
||||
full,
|
||||
headed,
|
||||
}
|
||||
}
|
||||
|
||||
const hasCustomTags = (forwardArgs: string[]) =>
|
||||
forwardArgs.some((arg) => arg === '--tags' || arg.startsWith('--tags='))
|
||||
|
||||
const main = async () => {
|
||||
const { forwardArgs, full, headed } = parseArgs(process.argv.slice(2))
|
||||
const startMiddlewareForRun = full
|
||||
const resetStateForRun = full
|
||||
|
||||
if (resetStateForRun) await resetState()
|
||||
|
||||
if (startMiddlewareForRun) await startMiddleware()
|
||||
|
||||
const cucumberReportDir = path.join(e2eDir, 'cucumber-report')
|
||||
const logDir = path.join(e2eDir, '.logs')
|
||||
|
||||
await rm(cucumberReportDir, { force: true, recursive: true })
|
||||
await mkdir(logDir, { recursive: true })
|
||||
|
||||
const apiProcess = await startLoggedProcess({
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'api'],
|
||||
cwd: e2eDir,
|
||||
label: 'api server',
|
||||
logFilePath: path.join(logDir, 'cucumber-api.log'),
|
||||
})
|
||||
|
||||
let cleanupPromise: Promise<void> | undefined
|
||||
const cleanup = async () => {
|
||||
if (!cleanupPromise) {
|
||||
cleanupPromise = (async () => {
|
||||
await stopWebServer()
|
||||
await stopManagedProcess(apiProcess)
|
||||
|
||||
if (startMiddlewareForRun) {
|
||||
try {
|
||||
await stopMiddleware()
|
||||
} catch {
|
||||
// Cleanup should continue even if middleware shutdown fails.
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
await cleanupPromise
|
||||
}
|
||||
|
||||
const onTerminate = () => {
|
||||
void cleanup().finally(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
process.once('SIGINT', onTerminate)
|
||||
process.once('SIGTERM', onTerminate)
|
||||
|
||||
try {
|
||||
try {
|
||||
await waitForUrl(`${apiURL}/health`, 180_000, 1_000)
|
||||
} catch {
|
||||
throw new Error(`API did not become ready at ${apiURL}/health.`)
|
||||
}
|
||||
|
||||
await startWebServer({
|
||||
baseURL,
|
||||
command: 'npx',
|
||||
args: ['tsx', './scripts/setup.ts', 'web'],
|
||||
cwd: e2eDir,
|
||||
logFilePath: path.join(logDir, 'cucumber-web.log'),
|
||||
reuseExistingServer: reuseExistingWebServer,
|
||||
timeoutMs: 300_000,
|
||||
})
|
||||
|
||||
const cucumberEnv: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
CUCUMBER_HEADLESS: headed ? '0' : '1',
|
||||
}
|
||||
|
||||
if (startMiddlewareForRun && !hasCustomTags(forwardArgs))
|
||||
cucumberEnv.E2E_CUCUMBER_TAGS = 'not @skip'
|
||||
|
||||
const result = await runCommand({
|
||||
command: 'npx',
|
||||
args: [
|
||||
'tsx',
|
||||
'./node_modules/@cucumber/cucumber/bin/cucumber.js',
|
||||
'--config',
|
||||
'./cucumber.config.ts',
|
||||
...forwardArgs,
|
||||
],
|
||||
cwd: e2eDir,
|
||||
env: cucumberEnv,
|
||||
})
|
||||
|
||||
process.exitCode = result.exitCode
|
||||
} finally {
|
||||
process.off('SIGINT', onTerminate)
|
||||
process.off('SIGTERM', onTerminate)
|
||||
await cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
306
e2e/scripts/setup.ts
Normal file
306
e2e/scripts/setup.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { access, mkdir, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { waitForUrl } from '../support/process'
|
||||
import {
|
||||
apiDir,
|
||||
apiEnvExampleFile,
|
||||
dockerDir,
|
||||
e2eDir,
|
||||
ensureFileExists,
|
||||
ensureLineInFile,
|
||||
ensureWebEnvLocal,
|
||||
isMainModule,
|
||||
isTcpPortReachable,
|
||||
middlewareComposeFile,
|
||||
middlewareEnvExampleFile,
|
||||
middlewareEnvFile,
|
||||
readSimpleDotenv,
|
||||
runCommand,
|
||||
runCommandOrThrow,
|
||||
runForegroundProcess,
|
||||
waitForCondition,
|
||||
webDir,
|
||||
} from './common'
|
||||
|
||||
const buildIdPath = path.join(webDir, '.next', 'BUILD_ID')
|
||||
|
||||
const middlewareDataPaths = [
|
||||
path.join(dockerDir, 'volumes', 'db', 'data'),
|
||||
path.join(dockerDir, 'volumes', 'plugin_daemon'),
|
||||
path.join(dockerDir, 'volumes', 'redis', 'data'),
|
||||
path.join(dockerDir, 'volumes', 'weaviate'),
|
||||
]
|
||||
|
||||
const e2eStatePaths = [
|
||||
path.join(e2eDir, '.auth'),
|
||||
path.join(e2eDir, 'cucumber-report'),
|
||||
path.join(e2eDir, '.logs'),
|
||||
path.join(e2eDir, 'playwright-report'),
|
||||
path.join(e2eDir, 'test-results'),
|
||||
]
|
||||
|
||||
const composeArgs = [
|
||||
'compose',
|
||||
'-f',
|
||||
middlewareComposeFile,
|
||||
'--profile',
|
||||
'postgresql',
|
||||
'--profile',
|
||||
'weaviate',
|
||||
]
|
||||
|
||||
const getApiEnvironment = async () => {
|
||||
const envFromExample = await readSimpleDotenv(apiEnvExampleFile)
|
||||
|
||||
return {
|
||||
...envFromExample,
|
||||
FLASK_APP: 'app.py',
|
||||
}
|
||||
}
|
||||
|
||||
const getServiceContainerId = async (service: string) => {
|
||||
const result = await runCommandOrThrow({
|
||||
command: 'docker',
|
||||
args: ['compose', '-f', middlewareComposeFile, 'ps', '-q', service],
|
||||
cwd: dockerDir,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
const getContainerHealth = async (containerId: string) => {
|
||||
const result = await runCommand({
|
||||
command: 'docker',
|
||||
args: ['inspect', '-f', '{{.State.Health.Status}}', containerId],
|
||||
cwd: dockerDir,
|
||||
stdio: 'pipe',
|
||||
})
|
||||
|
||||
if (result.exitCode !== 0) return ''
|
||||
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
const printComposeLogs = async (services: string[]) => {
|
||||
await runCommand({
|
||||
command: 'docker',
|
||||
args: ['compose', '-f', middlewareComposeFile, 'logs', ...services],
|
||||
cwd: dockerDir,
|
||||
})
|
||||
}
|
||||
|
||||
const waitForDependency = async ({
|
||||
description,
|
||||
services,
|
||||
wait,
|
||||
}: {
|
||||
description: string
|
||||
services: string[]
|
||||
wait: () => Promise<void>
|
||||
}) => {
|
||||
console.log(`Waiting for ${description}...`)
|
||||
|
||||
try {
|
||||
await wait()
|
||||
} catch (error) {
|
||||
await printComposeLogs(services)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export const ensureWebBuild = async () => {
|
||||
await ensureWebEnvLocal()
|
||||
|
||||
if (process.env.E2E_FORCE_WEB_BUILD === '1') {
|
||||
await runCommandOrThrow({
|
||||
command: 'pnpm',
|
||||
args: ['run', 'build'],
|
||||
cwd: webDir,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await access(buildIdPath)
|
||||
console.log('Reusing existing web build artifact.')
|
||||
} catch {
|
||||
await runCommandOrThrow({
|
||||
command: 'pnpm',
|
||||
args: ['run', 'build'],
|
||||
cwd: webDir,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const startWeb = async () => {
|
||||
await ensureWebBuild()
|
||||
|
||||
await runForegroundProcess({
|
||||
command: 'pnpm',
|
||||
args: ['run', 'start'],
|
||||
cwd: webDir,
|
||||
env: {
|
||||
HOSTNAME: '127.0.0.1',
|
||||
PORT: '3000',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const startApi = async () => {
|
||||
const env = await getApiEnvironment()
|
||||
|
||||
await runCommandOrThrow({
|
||||
command: 'uv',
|
||||
args: ['run', '--project', '.', 'flask', 'upgrade-db'],
|
||||
cwd: apiDir,
|
||||
env,
|
||||
})
|
||||
|
||||
await runForegroundProcess({
|
||||
command: 'uv',
|
||||
args: ['run', '--project', '.', 'flask', 'run', '--host', '127.0.0.1', '--port', '5001'],
|
||||
cwd: apiDir,
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
export const stopMiddleware = async () => {
|
||||
await runCommandOrThrow({
|
||||
command: 'docker',
|
||||
args: [...composeArgs, 'down', '--remove-orphans'],
|
||||
cwd: dockerDir,
|
||||
})
|
||||
}
|
||||
|
||||
export const resetState = async () => {
|
||||
console.log('Stopping middleware services...')
|
||||
try {
|
||||
await stopMiddleware()
|
||||
} catch {
|
||||
// Reset should continue even if middleware is already stopped.
|
||||
}
|
||||
|
||||
console.log('Removing persisted middleware data...')
|
||||
await Promise.all(
|
||||
middlewareDataPaths.map(async (targetPath) => {
|
||||
await rm(targetPath, { force: true, recursive: true })
|
||||
await mkdir(targetPath, { recursive: true })
|
||||
}),
|
||||
)
|
||||
|
||||
console.log('Removing E2E local state...')
|
||||
await Promise.all(
|
||||
e2eStatePaths.map((targetPath) => rm(targetPath, { force: true, recursive: true })),
|
||||
)
|
||||
|
||||
console.log('E2E state reset complete.')
|
||||
}
|
||||
|
||||
export const startMiddleware = async () => {
|
||||
await ensureFileExists(middlewareEnvFile, middlewareEnvExampleFile)
|
||||
await ensureLineInFile(middlewareEnvFile, 'COMPOSE_PROFILES=postgresql,weaviate')
|
||||
|
||||
console.log('Starting middleware services...')
|
||||
await runCommandOrThrow({
|
||||
command: 'docker',
|
||||
args: [
|
||||
...composeArgs,
|
||||
'up',
|
||||
'-d',
|
||||
'db_postgres',
|
||||
'redis',
|
||||
'weaviate',
|
||||
'sandbox',
|
||||
'ssrf_proxy',
|
||||
'plugin_daemon',
|
||||
],
|
||||
cwd: dockerDir,
|
||||
})
|
||||
|
||||
const [postgresContainerId, redisContainerId] = await Promise.all([
|
||||
getServiceContainerId('db_postgres'),
|
||||
getServiceContainerId('redis'),
|
||||
])
|
||||
|
||||
await waitForDependency({
|
||||
description: 'PostgreSQL and Redis health checks',
|
||||
services: ['db_postgres', 'redis'],
|
||||
wait: () =>
|
||||
waitForCondition({
|
||||
check: async () => {
|
||||
const [postgresStatus, redisStatus] = await Promise.all([
|
||||
getContainerHealth(postgresContainerId),
|
||||
getContainerHealth(redisContainerId),
|
||||
])
|
||||
|
||||
return postgresStatus === 'healthy' && redisStatus === 'healthy'
|
||||
},
|
||||
description: 'PostgreSQL and Redis health checks',
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: 240_000,
|
||||
}),
|
||||
})
|
||||
|
||||
await waitForDependency({
|
||||
description: 'Weaviate readiness',
|
||||
services: ['weaviate'],
|
||||
wait: () => waitForUrl('http://127.0.0.1:8080/v1/.well-known/ready', 120_000, 2_000),
|
||||
})
|
||||
|
||||
await waitForDependency({
|
||||
description: 'sandbox health',
|
||||
services: ['sandbox', 'ssrf_proxy'],
|
||||
wait: () => waitForUrl('http://127.0.0.1:8194/health', 120_000, 2_000),
|
||||
})
|
||||
|
||||
await waitForDependency({
|
||||
description: 'plugin daemon port',
|
||||
services: ['plugin_daemon'],
|
||||
wait: () =>
|
||||
waitForCondition({
|
||||
check: async () => isTcpPortReachable('127.0.0.1', 5002),
|
||||
description: 'plugin daemon port',
|
||||
intervalMs: 2_000,
|
||||
timeoutMs: 120_000,
|
||||
}),
|
||||
})
|
||||
|
||||
console.log('Full middleware stack is ready.')
|
||||
}
|
||||
|
||||
const printUsage = () => {
|
||||
console.log('Usage: tsx ./scripts/setup.ts <reset|middleware-up|middleware-down|api|web>')
|
||||
}
|
||||
|
||||
const main = async () => {
|
||||
const command = process.argv[2]
|
||||
|
||||
switch (command) {
|
||||
case 'api':
|
||||
await startApi()
|
||||
return
|
||||
case 'middleware-down':
|
||||
await stopMiddleware()
|
||||
return
|
||||
case 'middleware-up':
|
||||
await startMiddleware()
|
||||
return
|
||||
case 'reset':
|
||||
await resetState()
|
||||
return
|
||||
case 'web':
|
||||
await startWeb()
|
||||
return
|
||||
default:
|
||||
printUsage()
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
void main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
178
e2e/support/process.ts
Normal file
178
e2e/support/process.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import type { ChildProcess } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { createWriteStream, type WriteStream } from 'node:fs'
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import net from 'node:net'
|
||||
import { dirname } from 'node:path'
|
||||
|
||||
type ManagedProcessOptions = {
|
||||
command: string
|
||||
args?: string[]
|
||||
cwd: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
label: string
|
||||
logFilePath: string
|
||||
}
|
||||
|
||||
export type ManagedProcess = {
|
||||
childProcess: ChildProcess
|
||||
label: string
|
||||
logFilePath: string
|
||||
logStream: WriteStream
|
||||
}
|
||||
|
||||
export const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
export const isPortReachable = async (host: string, port: number, timeoutMs = 1_000) => {
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const socket = net.createConnection({
|
||||
host,
|
||||
port,
|
||||
})
|
||||
|
||||
const finish = (result: boolean) => {
|
||||
socket.removeAllListeners()
|
||||
socket.destroy()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
socket.setTimeout(timeoutMs)
|
||||
socket.once('connect', () => finish(true))
|
||||
socket.once('timeout', () => finish(false))
|
||||
socket.once('error', () => finish(false))
|
||||
})
|
||||
}
|
||||
|
||||
export const waitForUrl = async (
|
||||
url: string,
|
||||
timeoutMs: number,
|
||||
intervalMs = 1_000,
|
||||
requestTimeoutMs = Math.max(intervalMs, 1_000),
|
||||
) => {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), requestTimeoutMs)
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (response.ok) return
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
|
||||
await sleep(intervalMs)
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${url} after ${timeoutMs}ms.`)
|
||||
}
|
||||
|
||||
export const startLoggedProcess = async ({
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
env,
|
||||
label,
|
||||
logFilePath,
|
||||
}: ManagedProcessOptions): Promise<ManagedProcess> => {
|
||||
await mkdir(dirname(logFilePath), { recursive: true })
|
||||
|
||||
const logStream = createWriteStream(logFilePath, { flags: 'a' })
|
||||
const childProcess = spawn(command, args, {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
},
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
|
||||
const formattedCommand = [command, ...args].join(' ')
|
||||
logStream.write(`[${new Date().toISOString()}] Starting ${label}: ${formattedCommand}\n`)
|
||||
childProcess.stdout?.pipe(logStream, { end: false })
|
||||
childProcess.stderr?.pipe(logStream, { end: false })
|
||||
|
||||
return {
|
||||
childProcess,
|
||||
label,
|
||||
logFilePath,
|
||||
logStream,
|
||||
}
|
||||
}
|
||||
|
||||
const waitForProcessExit = (childProcess: ChildProcess, timeoutMs: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
if (childProcess.exitCode !== null) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}, timeoutMs)
|
||||
|
||||
const onExit = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
childProcess.off('exit', onExit)
|
||||
}
|
||||
|
||||
childProcess.once('exit', onExit)
|
||||
})
|
||||
|
||||
const signalManagedProcess = (childProcess: ChildProcess, signal: NodeJS.Signals) => {
|
||||
const { pid } = childProcess
|
||||
if (!pid) return
|
||||
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
process.kill(-pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
childProcess.kill(signal)
|
||||
} catch {
|
||||
// Best-effort shutdown. Cleanup continues even when the process is already gone.
|
||||
}
|
||||
}
|
||||
|
||||
export const stopManagedProcess = async (managedProcess?: ManagedProcess) => {
|
||||
if (!managedProcess) return
|
||||
|
||||
const { childProcess, logStream } = managedProcess
|
||||
|
||||
if (childProcess.exitCode === null) {
|
||||
signalManagedProcess(childProcess, 'SIGTERM')
|
||||
await waitForProcessExit(childProcess, 5_000)
|
||||
}
|
||||
|
||||
if (childProcess.exitCode === null) {
|
||||
signalManagedProcess(childProcess, 'SIGKILL')
|
||||
await waitForProcessExit(childProcess, 5_000)
|
||||
}
|
||||
|
||||
childProcess.stdout?.unpipe(logStream)
|
||||
childProcess.stderr?.unpipe(logStream)
|
||||
childProcess.stdout?.destroy()
|
||||
childProcess.stderr?.destroy()
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
logStream.end(() => resolve())
|
||||
})
|
||||
}
|
||||
83
e2e/support/web-server.ts
Normal file
83
e2e/support/web-server.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type { ManagedProcess } from './process'
|
||||
import { isPortReachable, startLoggedProcess, stopManagedProcess, waitForUrl } from './process'
|
||||
|
||||
type WebServerStartOptions = {
|
||||
baseURL: string
|
||||
command: string
|
||||
args?: string[]
|
||||
cwd: string
|
||||
logFilePath: string
|
||||
reuseExistingServer: boolean
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
let activeProcess: ManagedProcess | undefined
|
||||
|
||||
const getUrlHostAndPort = (url: string) => {
|
||||
const parsedUrl = new URL(url)
|
||||
const isHttps = parsedUrl.protocol === 'https:'
|
||||
|
||||
return {
|
||||
host: parsedUrl.hostname,
|
||||
port: parsedUrl.port ? Number(parsedUrl.port) : isHttps ? 443 : 80,
|
||||
}
|
||||
}
|
||||
|
||||
export const startWebServer = async ({
|
||||
baseURL,
|
||||
command,
|
||||
args = [],
|
||||
cwd,
|
||||
logFilePath,
|
||||
reuseExistingServer,
|
||||
timeoutMs,
|
||||
}: WebServerStartOptions) => {
|
||||
const { host, port } = getUrlHostAndPort(baseURL)
|
||||
|
||||
if (reuseExistingServer && (await isPortReachable(host, port))) return
|
||||
|
||||
activeProcess = await startLoggedProcess({
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
label: 'web server',
|
||||
logFilePath,
|
||||
})
|
||||
|
||||
let startupError: Error | undefined
|
||||
activeProcess.childProcess.once('error', (error) => {
|
||||
startupError = error
|
||||
})
|
||||
activeProcess.childProcess.once('exit', (code, signal) => {
|
||||
if (startupError) return
|
||||
|
||||
startupError = new Error(
|
||||
`Web server exited before readiness (code: ${code ?? 'unknown'}, signal: ${signal ?? 'none'}).`,
|
||||
)
|
||||
})
|
||||
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (startupError) {
|
||||
await stopManagedProcess(activeProcess)
|
||||
activeProcess = undefined
|
||||
throw startupError
|
||||
}
|
||||
|
||||
try {
|
||||
await waitForUrl(baseURL, 1_000, 250, 1_000)
|
||||
return
|
||||
} catch {
|
||||
// Continue polling until timeout or child exit.
|
||||
}
|
||||
}
|
||||
|
||||
await stopManagedProcess(activeProcess)
|
||||
activeProcess = undefined
|
||||
throw new Error(`Timed out waiting for web server readiness at ${baseURL} after ${timeoutMs}ms.`)
|
||||
}
|
||||
|
||||
export const stopWebServer = async () => {
|
||||
await stopManagedProcess(activeProcess)
|
||||
activeProcess = undefined
|
||||
}
|
||||
12
e2e/test-env.ts
Normal file
12
e2e/test-env.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const defaultBaseURL = 'http://127.0.0.1:3000'
|
||||
export const defaultApiURL = 'http://127.0.0.1:5001'
|
||||
export const defaultLocale = 'en-US'
|
||||
|
||||
export const baseURL = process.env.E2E_BASE_URL || defaultBaseURL
|
||||
export const apiURL = process.env.E2E_API_URL || defaultApiURL
|
||||
|
||||
export const cucumberHeadless = process.env.CUCUMBER_HEADLESS !== '0'
|
||||
export const cucumberSlowMo = Number(process.env.E2E_SLOW_MO || 0)
|
||||
export const reuseExistingWebServer = process.env.E2E_REUSE_WEB_SERVER
|
||||
? process.env.E2E_REUSE_WEB_SERVER !== '0'
|
||||
: !process.env.CI
|
||||
25
e2e/tsconfig.json
Normal file
25
e2e/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowJs": false,
|
||||
"resolveJsonModule": true,
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["node", "@playwright/test", "@cucumber/cucumber"],
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": [
|
||||
"./node_modules",
|
||||
"./playwright-report",
|
||||
"./test-results",
|
||||
"./.auth",
|
||||
"./cucumber-report",
|
||||
"./.logs"
|
||||
]
|
||||
}
|
||||
15
e2e/vite.config.ts
Normal file
15
e2e/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'vite-plus'
|
||||
|
||||
export default defineConfig({
|
||||
lint: {
|
||||
options: {
|
||||
typeAware: true,
|
||||
typeCheck: true,
|
||||
denyWarnings: true,
|
||||
},
|
||||
},
|
||||
fmt: {
|
||||
singleQuote: true,
|
||||
semi: false,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user