mirror of
https://github.com/langgenius/dify.git
synced 2026-07-18 07:44:36 -04:00
test(e2e): harden behavior coverage and CI gates (#39043)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -106,6 +106,7 @@ jobs:
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
vdb:
|
||||
- 'api/core/rag/datasource/**'
|
||||
|
||||
@@ -19,6 +19,7 @@ jobs:
|
||||
test:
|
||||
name: Web Full-Stack E2E
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
timeout-minutes: 120
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -58,10 +59,57 @@ jobs:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_CUCUMBER_REPORT_PROFILE: core
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
run: vp run e2e:full
|
||||
|
||||
- name: Preserve Chromium E2E report and logs
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [[ -d e2e/cucumber-report ]]; then
|
||||
mv e2e/cucumber-report e2e/cucumber-report-non-external
|
||||
fi
|
||||
if [[ -d e2e/.logs ]]; then
|
||||
mv e2e/.logs e2e/.logs-non-external
|
||||
fi
|
||||
|
||||
- name: Run WebKit keyboard and browser smoke tests
|
||||
working-directory: ./e2e
|
||||
env:
|
||||
E2E_ADMIN_EMAIL: e2e-admin@example.com
|
||||
E2E_ADMIN_NAME: E2E Admin
|
||||
E2E_ADMIN_PASSWORD: E2eAdmin12345
|
||||
E2E_BROWSER: webkit
|
||||
E2E_CUCUMBER_REPORT_PROFILE: webkit-browser-smoke
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
run: |
|
||||
teardown_webkit_smoke() {
|
||||
local run_status=$?
|
||||
trap - EXIT
|
||||
if ! vp run e2e:middleware:down; then
|
||||
echo "::error title=E2E teardown failed::WebKit smoke middleware did not shut down cleanly."
|
||||
if [[ "$run_status" -eq 0 ]]; then
|
||||
run_status=1
|
||||
fi
|
||||
fi
|
||||
exit "$run_status"
|
||||
}
|
||||
|
||||
trap teardown_webkit_smoke EXIT
|
||||
vp run e2e:middleware:up
|
||||
vp run e2e -- --tags '@browser-smoke'
|
||||
|
||||
- name: Preserve WebKit E2E report and logs
|
||||
if: ${{ !cancelled() }}
|
||||
run: |
|
||||
if [[ -d e2e/cucumber-report ]]; then
|
||||
mv e2e/cucumber-report e2e/cucumber-report-webkit
|
||||
fi
|
||||
if [[ -d e2e/.logs ]]; then
|
||||
mv e2e/.logs e2e/.logs-webkit
|
||||
fi
|
||||
|
||||
- name: Run external runtime E2E tests
|
||||
if: ${{ inputs.run-external-runtime }}
|
||||
working-directory: ./e2e
|
||||
@@ -72,8 +120,8 @@ jobs:
|
||||
E2E_AGENT_DECISION_MODEL_NAME: ${{ vars.E2E_AGENT_DECISION_MODEL_NAME || 'gpt-5.5' }}
|
||||
E2E_AGENT_DECISION_MODEL_PROVIDER: ${{ vars.E2E_AGENT_DECISION_MODEL_PROVIDER || 'openai' }}
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: ${{ vars.E2E_AGENT_DECISION_MODEL_TYPE || 'llm' }}
|
||||
E2E_CUCUMBER_REPORT_PROFILE: external
|
||||
E2E_EXTERNAL_RUNTIME_SEED_SPECS: ${{ vars.E2E_EXTERNAL_RUNTIME_SEED_SPECS }}
|
||||
E2E_EXTERNAL_RUNTIME_TAGS: ${{ vars.E2E_EXTERNAL_RUNTIME_TAGS }}
|
||||
E2E_FORCE_WEB_BUILD: "1"
|
||||
E2E_INIT_PASSWORD: E2eInit12345
|
||||
E2E_MARKETPLACE_API_URL: ${{ vars.E2E_MARKETPLACE_API_URL }}
|
||||
@@ -102,7 +150,19 @@ jobs:
|
||||
mv .logs .logs-non-external
|
||||
fi
|
||||
|
||||
trap 'vp run e2e:middleware:down' EXIT
|
||||
teardown_external_runtime() {
|
||||
local run_status=$?
|
||||
trap - EXIT
|
||||
if ! vp run e2e:middleware:down; then
|
||||
echo "::error title=E2E teardown failed::External runtime middleware did not shut down cleanly."
|
||||
if [[ "$run_status" -eq 0 ]]; then
|
||||
run_status=1
|
||||
fi
|
||||
fi
|
||||
exit "$run_status"
|
||||
}
|
||||
|
||||
trap teardown_external_runtime EXIT
|
||||
vp run e2e:middleware:up
|
||||
vp run e2e:external:prepare
|
||||
vp run e2e:external
|
||||
@@ -115,6 +175,7 @@ jobs:
|
||||
path: |
|
||||
e2e/cucumber-report
|
||||
e2e/cucumber-report-non-external
|
||||
e2e/cucumber-report-webkit
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E logs
|
||||
@@ -125,5 +186,15 @@ jobs:
|
||||
path: |
|
||||
e2e/.logs/*.log
|
||||
e2e/.logs-non-external/*.log
|
||||
e2e/.logs-webkit/*.log
|
||||
include-hidden-files: true
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload E2E seed report
|
||||
if: ${{ !cancelled() && inputs.run-external-runtime }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-seed-report
|
||||
path: e2e/seed-report
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
@@ -64,6 +64,9 @@ pnpm -C e2e e2e:headed -- --tags @smoke
|
||||
|
||||
# slow down browser actions for local debugging
|
||||
E2E_SLOW_MO=500 pnpm -C e2e e2e:headed -- --tags @smoke
|
||||
|
||||
# focused keyboard and cross-browser smoke coverage
|
||||
E2E_BROWSER=webkit pnpm -C e2e e2e -- --tags @browser-smoke
|
||||
```
|
||||
|
||||
Frontend artifact behavior:
|
||||
@@ -128,7 +131,11 @@ This removes:
|
||||
- `e2e/.auth`
|
||||
- `e2e/.logs`
|
||||
- `e2e/.logs-non-external`
|
||||
- `e2e/.logs-webkit`
|
||||
- `e2e/cucumber-report`
|
||||
- `e2e/cucumber-report-non-external`
|
||||
- `e2e/cucumber-report-webkit`
|
||||
- `e2e/seed-report`
|
||||
|
||||
Start the full middleware stack:
|
||||
|
||||
@@ -170,9 +177,20 @@ Artifacts and diagnostics:
|
||||
- `cucumber-report/report.html`: HTML report
|
||||
- `cucumber-report/report.json`: JSON report
|
||||
- `cucumber-report/artifacts/`: failure screenshots and HTML captures
|
||||
- `cucumber-report-non-external/`: Chromium core report preserved before later CI lanes
|
||||
- `cucumber-report-webkit/`: focused WebKit keyboard/browser smoke report
|
||||
- `.logs/cucumber-api.log`: backend startup log
|
||||
- `.logs/cucumber-web.log`: frontend startup log
|
||||
- `.logs-non-external/`: non-external logs preserved before an external CI run
|
||||
- `.logs-webkit/`: focused WebKit lane logs
|
||||
- `seed-report/`: JSON readiness reports emitted by external runtime seed packs
|
||||
|
||||
CI enables a JSON report gate after Cucumber exits. The gate asserts minimum selected and passed
|
||||
scenario counts, maximum skipped counts, and zero unexplained skips. A skipped scenario counts as
|
||||
an explained blocked precondition only when its skipped step attaches a `Blocked precondition:`
|
||||
reason. This keeps feature-gated and preflight readiness reporting visible without allowing a
|
||||
zero-coverage or silently skipped CI run to pass. Set `E2E_CUCUMBER_REPORT_PROFILE` to select the
|
||||
checked-in `core`, `webkit-browser-smoke`, or `external` thresholds and allowed blocked tags.
|
||||
|
||||
Open the HTML report locally with:
|
||||
|
||||
@@ -212,6 +230,7 @@ Feature: Create dataset
|
||||
- `@external-model` — scenario execution can call a real model provider. Use this only for runtime requests, not for scenarios that only require an active model fixture.
|
||||
- `@external-tool` — scenario execution can call a real third-party tool provider. Use this only for runtime tool execution, not for plugin installation, discovery, or local deterministic tools.
|
||||
- `@microphone` — runs the scenario in an isolated Chromium instance backed by the checked-in fake audio fixture and grants microphone permission only to that scenario context.
|
||||
- `@browser-smoke` — focused keyboard and navigation coverage that runs in Chromium with the core suite and again in WebKit on CI.
|
||||
- `@skip` — excluded from all runs
|
||||
|
||||
External runtime commands are opt-in. `pnpm -C e2e e2e:external:prepare` reads `E2E_EXTERNAL_RUNTIME_SEED_SPECS`, defaulting to `agent-v2:external-runtime`, and runs the matching seed packs before the external suite. `pnpm -C e2e e2e:external` reads `E2E_EXTERNAL_RUNTIME_TAGS`, defaulting to `(@external-model or @external-tool) and not @feature-gated and not @skip and not @preview`.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
@accessibility @keyboard @browser-smoke
|
||||
Feature: Keyboard navigation
|
||||
|
||||
@authenticated
|
||||
Scenario: Skip repeated navigation and move focus to the main content
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the default console entry
|
||||
And I focus and activate the skip navigation link with the keyboard
|
||||
Then the console main content should have keyboard focus
|
||||
|
||||
@unauthenticated
|
||||
Scenario: Sign in by following the form tab order
|
||||
Given I am not signed in
|
||||
When I open the sign-in page
|
||||
And I complete the sign-in form using only the keyboard
|
||||
Then I should be on the console home
|
||||
|
||||
@authenticated
|
||||
Scenario: Closing the account menu restores focus to its trigger
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I open and close the account menu using the keyboard
|
||||
Then the account menu trigger should regain keyboard focus
|
||||
|
||||
@authenticated
|
||||
Scenario: Closing the create app menu restores focus to its trigger
|
||||
Given I am signed in as the default E2E admin
|
||||
When I open the apps console
|
||||
And I open and close the create app menu using the keyboard
|
||||
Then the create app menu trigger should regain keyboard focus
|
||||
@@ -26,9 +26,8 @@ Use tags in three layers:
|
||||
- `@core` — stable non-runtime scenario expected to run in the regular Agent v2 suite when its explicit preconditions are met. Do not apply `@core` to Preview/Test Run, Web app chat runtime, or Backend service API chat runtime scenarios.
|
||||
- `@infra` — infrastructure or readiness checks.
|
||||
- `@build` — Build mode and Build draft behavior.
|
||||
- `@build-unavailable-resources` — feature-gated Build chat recovery when the user requests unavailable Skills or Tools.
|
||||
- `@files` — Files section upload, display, and fixture behavior.
|
||||
- `@files-limits` — file limit behavior. Multiple-file drop is stable core coverage; format and size rejection remain feature-gated until their product contracts are stable.
|
||||
- `@files-limits` — stable file limit behavior, currently covering multiple-file drop rejection.
|
||||
- `@knowledge` — Knowledge Retrieval configuration display, persistence, and reference cleanup.
|
||||
- `@advanced-settings` — Env Editor, Content Moderation, and related Advanced Settings behavior.
|
||||
- `@agent-create` — Agent Roster creation and initial Configure navigation.
|
||||
@@ -232,6 +231,6 @@ Order blocked steps by the real owner of the first unresolved condition. If a sc
|
||||
|
||||
Use partial coverage only when current product behavior is intentionally narrower than the written requirement and the test still asserts a real user-visible behavior. Example: Files are currently flat in Agent config files, so the flat Files list can be asserted while tree display remains blocked until product support exists.
|
||||
|
||||
Multiple-file drop is already covered as stable `@core @files-limits` behavior. File format and size rejection remain feature-gated until the product exposes stable Agent config file restrictions and user-visible error states. Do not convert those gated `@files-limits` scenarios to passing tests by relying on default environment behavior; first align the product contract or seed configuration.
|
||||
Multiple-file drop is covered as stable `@core @files-limits` behavior. Add file format or size rejection coverage only after the product exposes stable Agent config file restrictions and user-visible error states; do not encode undefined behavior as permanently skipped scenarios.
|
||||
|
||||
Do not mark a scenario as complete if it only proves setup state and does not assert the user-visible behavior or persisted product contract required by the case.
|
||||
|
||||
@@ -16,7 +16,6 @@ Feature: Agent v2 Access Point
|
||||
When I open the Agent v2 configure page from the Agent Roster
|
||||
And I switch to the Agent v2 Access Point section
|
||||
Then I should see the Agent v2 Web app access URL
|
||||
And I record the current Agent v2 orchestration draft
|
||||
When I copy the Agent v2 Web app access URL
|
||||
Then the Agent v2 Web app access URL should show it was copied
|
||||
And the current Agent v2 orchestration draft should be unchanged
|
||||
@@ -30,7 +29,6 @@ Feature: Agent v2 Access Point
|
||||
When I open the Agent v2 configure page from the Agent Roster
|
||||
And I switch to the Agent v2 Access Point section
|
||||
Then I should see the Agent v2 Web app access URL
|
||||
And I record the current Agent v2 orchestration draft
|
||||
When I launch the Agent v2 Web app
|
||||
Then the Agent v2 Web app should open in a new tab
|
||||
And the current Agent v2 orchestration draft should be unchanged
|
||||
@@ -42,7 +40,6 @@ Feature: Agent v2 Access Point
|
||||
And Agent v2 Web app access has been enabled via API
|
||||
When I open the Agent v2 configure page from the Agent Roster
|
||||
And I switch to the Agent v2 Access Point section
|
||||
And I record the current Agent v2 orchestration draft
|
||||
And I open Agent v2 Embedded configuration
|
||||
Then I should see the Agent v2 Embedded configuration dialog
|
||||
And the current Agent v2 orchestration draft should be unchanged
|
||||
@@ -54,7 +51,6 @@ Feature: Agent v2 Access Point
|
||||
And Agent v2 Web app access has been enabled via API
|
||||
When I open the Agent v2 configure page from the Agent Roster
|
||||
And I switch to the Agent v2 Access Point section
|
||||
And I record the current Agent v2 orchestration draft
|
||||
And I open Agent v2 Web app customization
|
||||
Then I should see the Agent v2 Web app customization dialog
|
||||
And the current Agent v2 orchestration draft should be unchanged
|
||||
@@ -66,7 +62,6 @@ Feature: Agent v2 Access Point
|
||||
And Agent v2 Web app access has been enabled via API
|
||||
When I open the Agent v2 configure page from the Agent Roster
|
||||
And I switch to the Agent v2 Access Point section
|
||||
And I record the current Agent v2 orchestration draft
|
||||
And I open Agent v2 Web app settings
|
||||
Then I should see the Agent v2 Web app settings dialog
|
||||
And the current Agent v2 orchestration draft should be unchanged
|
||||
@@ -86,20 +81,6 @@ Feature: Agent v2 Access Point
|
||||
When I refresh the current page
|
||||
Then Agent v2 Web app access should be in service
|
||||
|
||||
@web-app-access @published-web-app @feature-gated
|
||||
Scenario: Disabled Web app public URL shows an unavailable state
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 disabled Web app public unavailable state is available
|
||||
And a basic configured Agent v2 test agent has been created via API
|
||||
And the Agent v2 draft has been published via API
|
||||
And Agent v2 Web app access has been enabled via API
|
||||
When I open the Agent v2 configure page from the Agent Roster
|
||||
And I switch to the Agent v2 Access Point section
|
||||
And I disable Agent v2 Web app access
|
||||
Then Agent v2 Web app access should be out of service
|
||||
When I open the disabled Agent v2 Web app URL
|
||||
Then the disabled Agent v2 Web app should show an unavailable state
|
||||
|
||||
@core @workflow-reference
|
||||
Scenario: Workflow access shows the referencing workflow
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -31,15 +31,6 @@ Feature: Agent v2 Agent Edit page
|
||||
When I open the preseeded Agent v2 configure page for "E2E New Agent Builder Tool States" from the Agent Roster
|
||||
Then I should see the Agent v2 tool state fixture tools
|
||||
|
||||
@tool-error-state @tool-states-agent @feature-gated
|
||||
Scenario: Tool credential error states are visible on the Agent Edit page
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 Tool credential error state is available
|
||||
And the Agent Builder preseeded Agent "E2E New Agent Builder Tool States" is available
|
||||
And the Agent Builder preseeded Agent "E2E New Agent Builder Tool States" includes the tool state fixture configuration
|
||||
When I open the preseeded Agent v2 configure page for "E2E New Agent Builder Tool States" from the Agent Roster
|
||||
Then Agent v2 Tool credential error state should be available
|
||||
|
||||
@core @dual-retrieval-fixture
|
||||
Scenario: Dual Knowledge Retrieval settings are visible on the Agent Edit page
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -50,23 +50,15 @@ Feature: Agent v2 build draft
|
||||
When I open the Agent v2 configure page
|
||||
Then I should see the Agent v2 Build draft pending changes
|
||||
And I should see the small Agent v2 file in the Files section
|
||||
And I should see the e2e-summary-skill Skill in the Skills section
|
||||
And I should see the supported E2E environment variable in Advanced Settings
|
||||
And the normal Agent v2 draft should still use the normal E2E prompt
|
||||
When I discard the Agent v2 Build draft
|
||||
Then I should see the normal E2E prompt in the Agent v2 prompt editor
|
||||
And I should not see the small Agent v2 file in the Files section
|
||||
And I should not see the e2e-summary-skill Skill in the Skills section
|
||||
And I should not see the supported E2E environment variable in Advanced Settings
|
||||
And the Agent v2 draft should not include the supported Build draft config
|
||||
Then the Agent v2 draft should not include the supported Build draft config
|
||||
And the Agent v2 Build draft should no longer be active
|
||||
When I refresh the current page
|
||||
Then I should see the normal E2E prompt in the Agent v2 prompt editor
|
||||
And I should not see the small Agent v2 file in the Files section
|
||||
And I should not see the e2e-summary-skill Skill in the Skills section
|
||||
And I should not see the supported E2E environment variable in Advanced Settings
|
||||
And the Agent v2 draft should not include the supported Build draft config
|
||||
And the Agent v2 Build draft should no longer be active
|
||||
|
||||
@external-model @agent-backend-runtime @stable-model
|
||||
Scenario: Applying a pending Build draft updates the normal Agent configuration
|
||||
@@ -95,9 +87,6 @@ Feature: Agent v2 build draft
|
||||
When I open the Agent v2 configure page
|
||||
Then I should see the Agent v2 Build draft pending changes
|
||||
And I should see the updated E2E prompt in the Agent v2 prompt editor
|
||||
And I should see the small Agent v2 file in the Files section
|
||||
And I should see the e2e-summary-skill Skill in the Skills section
|
||||
And I should see the supported E2E environment variable in Advanced Settings
|
||||
And the normal Agent v2 draft should still use the normal E2E prompt
|
||||
When I apply the Agent v2 Build draft via API
|
||||
Then the Agent v2 draft should include the supported Build draft config
|
||||
@@ -138,20 +127,3 @@ Feature: Agent v2 build draft
|
||||
Then I should see the Agent v2 Build draft pending changes
|
||||
And I should see the updated E2E prompt in the Agent v2 prompt editor
|
||||
And the normal Agent v2 draft should still use the normal E2E prompt
|
||||
|
||||
@build-tool-writeback @feature-gated
|
||||
Scenario: Applying a Build draft can add Dify Tools to the Agent configuration
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 Build chat Dify Tool writeback is available
|
||||
And a basic configured Agent v2 test agent has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
Then Agent v2 Build chat Dify Tool writeback should be available
|
||||
|
||||
@build-unavailable-resources @feature-gated @stable-model
|
||||
Scenario: Build chat reports unavailable Skill or Tool requests clearly
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 Build chat unavailable Skill and Tool recovery is available
|
||||
And the Agent Builder stable chat model is available
|
||||
And a runnable Agent v2 test agent has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
Then Agent v2 Build chat unavailable Skill and Tool recovery should be available
|
||||
|
||||
@@ -36,22 +36,6 @@ Feature: Agent v2 files
|
||||
When I refresh the current page
|
||||
Then I should see the special-name Agent v2 file in the Files section
|
||||
|
||||
@files-limits @feature-gated
|
||||
Scenario: Unsupported Agent v2 file formats show a clear rejection reason
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 unsupported file format rejection is available
|
||||
And a basic configured Agent v2 test agent has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
Then Agent v2 unsupported file format rejection should be available
|
||||
|
||||
@files-limits @feature-gated
|
||||
Scenario: Oversized Agent v2 files show a clear rejection reason
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 oversized file rejection is available
|
||||
And a basic configured Agent v2 test agent has been created via API
|
||||
When I open the Agent v2 configure page
|
||||
Then Agent v2 oversized file rejection should be available
|
||||
|
||||
@core @files-limits
|
||||
Scenario: Dropping multiple Agent v2 files at once is rejected
|
||||
Given I am signed in as the default E2E admin
|
||||
|
||||
@@ -44,14 +44,3 @@ Feature: Agent v2 output variables
|
||||
When I refresh the current page
|
||||
And I open the Agent v2 workflow node panel
|
||||
Then the Agent v2 workflow node task should reference the renamed file output
|
||||
|
||||
@output-reference-delete @feature-gated @stable-model
|
||||
Scenario: Workflow Agent v2 prompt output reference deletion remains explicit
|
||||
Given I am signed in as the default E2E admin
|
||||
And Agent v2 workflow task output reference deletion consistency is available
|
||||
And the Agent Builder stable chat model is available
|
||||
And a workflow app with an Agent v2 node has been created via API
|
||||
When I open the app from the app list
|
||||
And I open the Agent v2 workflow node panel
|
||||
And I insert a file output reference from the Agent v2 workflow node task editor
|
||||
Then Agent v2 workflow task output reference deletion consistency should be available
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@apps @authenticated @core
|
||||
@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
|
||||
|
||||
+2
-5
@@ -1,13 +1,10 @@
|
||||
@apps @authenticated @core @mode-matrix
|
||||
Feature: Workflow run and publish
|
||||
Feature: Workflow run
|
||||
|
||||
Scenario: Run and publish a minimal workflow app
|
||||
Scenario: Run a minimal workflow app
|
||||
Given I am signed in as the default E2E admin
|
||||
And a "workflow" app has been created via API
|
||||
And a minimal runnable workflow draft has been synced
|
||||
When I open the app from the app list
|
||||
And I run the workflow
|
||||
Then the workflow run should succeed
|
||||
When I open the publish panel
|
||||
And I publish the app
|
||||
Then the app should be marked as published
|
||||
@@ -1,12 +1,5 @@
|
||||
@auth @authenticated @core
|
||||
Feature: Sign out
|
||||
Scenario: Sign out from the apps console
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { adminCredentials } from '../../../fixtures/auth'
|
||||
import { e2eBrowser } from '../../../test-env'
|
||||
|
||||
const getAccountMenuTrigger = (world: DifyWorld) =>
|
||||
world.getPage().getByRole('button', { name: 'Account' })
|
||||
|
||||
const getCreateAppMenuTrigger = (world: DifyWorld) =>
|
||||
world.getPage().getByRole('main').getByRole('button', { name: 'Create', exact: true })
|
||||
|
||||
When(
|
||||
'I focus and activate the skip navigation link with the keyboard',
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const skipLink = page.getByRole('link', { name: 'Skip to main content' })
|
||||
const nextClickableItemKey = e2eBrowser === 'webkit' ? 'Alt+Tab' : 'Tab'
|
||||
|
||||
await page.keyboard.press(nextClickableItemKey)
|
||||
await expect(skipLink).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
},
|
||||
)
|
||||
|
||||
Then('the console main content should have keyboard focus', async function (this: DifyWorld) {
|
||||
await expect(this.getPage().getByRole('main')).toBeFocused()
|
||||
})
|
||||
|
||||
When('I complete the sign-in form using only the keyboard', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const email = page.getByLabel('Email address')
|
||||
const password = page.getByLabel('Password', { exact: true })
|
||||
const showPassword = page.getByRole('button', { name: 'Show password' })
|
||||
const submit = page.getByRole('button', { name: 'Sign in' })
|
||||
|
||||
await expect(email).toBeVisible()
|
||||
for (let tabPresses = 0; tabPresses < 10; tabPresses += 1) {
|
||||
await page.keyboard.press('Tab')
|
||||
if (await email.evaluate((element) => element === document.activeElement)) break
|
||||
}
|
||||
await expect(email).toBeFocused()
|
||||
await page.keyboard.insertText(adminCredentials.email)
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(password).toBeFocused()
|
||||
await page.keyboard.insertText(adminCredentials.password)
|
||||
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(showPassword).toBeFocused()
|
||||
await page.keyboard.press('Tab')
|
||||
await expect(submit).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
})
|
||||
|
||||
When('I open and close the account menu using the keyboard', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const trigger = getAccountMenuTrigger(this)
|
||||
|
||||
await expect(trigger).toBeEnabled()
|
||||
await trigger.press('Enter')
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
Then('the account menu trigger should regain keyboard focus', async function (this: DifyWorld) {
|
||||
await expect(getAccountMenuTrigger(this)).toBeFocused()
|
||||
})
|
||||
|
||||
When('I open and close the create app menu using the keyboard', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const trigger = getCreateAppMenuTrigger(this)
|
||||
|
||||
await trigger.press('Enter')
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
})
|
||||
|
||||
Then('the create app menu trigger should regain keyboard focus', async function (this: DifyWorld) {
|
||||
await expect(getCreateAppMenuTrigger(this)).toBeFocused()
|
||||
})
|
||||
@@ -144,13 +144,6 @@ Then(
|
||||
},
|
||||
)
|
||||
|
||||
When('I close Agent v2 API key management', async function (this: DifyWorld) {
|
||||
const apiKeyDialog = this.getPage().getByRole('dialog', { name: /API Secret key/i })
|
||||
|
||||
await apiKeyDialog.getByLabel('Close').click()
|
||||
await expect(apiKeyDialog).not.toBeVisible()
|
||||
})
|
||||
|
||||
When('I open the Agent v2 API Reference', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const apiReferenceLink = page.getByRole('link', { name: 'API Reference' })
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { getAgentComposerDraft } from '../../agent-v2/support/agent'
|
||||
import { agentBuilderExpectedTokens } from '../../agent-v2/support/agent-builder-resources'
|
||||
import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common'
|
||||
import { getCurrentAgentId, getDialog, getWebAppCard } from './access-point-helpers'
|
||||
|
||||
const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000
|
||||
|
||||
const getWebAppMessageInput = (webAppPage: Page) => webAppPage.getByPlaceholder(/^Talk to /).last()
|
||||
|
||||
const recordComposerDraftSnapshot = async (world: DifyWorld) => {
|
||||
const draft = await getAgentComposerDraft(getCurrentAgentId(world))
|
||||
world.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {})
|
||||
}
|
||||
|
||||
Then('I should see the Agent v2 Web app access URL', async function (this: DifyWorld) {
|
||||
const webAppCard = getWebAppCard(this)
|
||||
|
||||
@@ -20,13 +24,8 @@ Then('I should see the Agent v2 Web app access URL', async function (this: DifyW
|
||||
await expect(webAppCard.getByRole('link', { name: 'Launch' })).toBeVisible()
|
||||
})
|
||||
|
||||
Then('I record the current Agent v2 orchestration draft', async function (this: DifyWorld) {
|
||||
const draft = await getAgentComposerDraft(getCurrentAgentId(this))
|
||||
|
||||
this.agentBuilder.accessPoint.composerDraftSnapshot = JSON.stringify(draft.agent_soul ?? {})
|
||||
})
|
||||
|
||||
When('I copy the Agent v2 Web app access URL', async function (this: DifyWorld) {
|
||||
await recordComposerDraftSnapshot(this)
|
||||
await getWebAppCard(this).getByLabel('Copy access URL').click()
|
||||
})
|
||||
|
||||
@@ -35,6 +34,7 @@ Then('the Agent v2 Web app access URL should show it was copied', async function
|
||||
})
|
||||
|
||||
When('I launch the Agent v2 Web app', async function (this: DifyWorld) {
|
||||
await recordComposerDraftSnapshot(this)
|
||||
const launchLink = getWebAppCard(this).getByRole('link', { name: 'Launch' })
|
||||
const href = await launchLink.getAttribute('href')
|
||||
if (!href) throw new Error('Agent v2 Web app Launch link does not expose an href.')
|
||||
@@ -125,6 +125,7 @@ When('I close the Agent v2 Web app', async function (this: DifyWorld) {
|
||||
})
|
||||
|
||||
When('I open Agent v2 Embedded configuration', async function (this: DifyWorld) {
|
||||
await recordComposerDraftSnapshot(this)
|
||||
await getWebAppCard(this).getByRole('button', { name: 'Embedded' }).click()
|
||||
})
|
||||
|
||||
@@ -137,6 +138,7 @@ Then('I should see the Agent v2 Embedded configuration dialog', async function (
|
||||
})
|
||||
|
||||
When('I open Agent v2 Web app customization', async function (this: DifyWorld) {
|
||||
await recordComposerDraftSnapshot(this)
|
||||
await getWebAppCard(this).getByRole('button', { name: 'Custom Frontend' }).click()
|
||||
})
|
||||
|
||||
@@ -149,6 +151,7 @@ Then('I should see the Agent v2 Web app customization dialog', async function (t
|
||||
})
|
||||
|
||||
When('I open Agent v2 Web app settings', async function (this: DifyWorld) {
|
||||
await recordComposerDraftSnapshot(this)
|
||||
await getWebAppCard(this).getByRole('button', { name: 'Branding' }).click()
|
||||
})
|
||||
|
||||
@@ -172,66 +175,3 @@ Then(
|
||||
expect(JSON.stringify(draft.agent_soul ?? {})).toBe(snapshot)
|
||||
},
|
||||
)
|
||||
|
||||
Given(
|
||||
'Agent v2 disabled Web app public unavailable state is available',
|
||||
async function (this: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
this,
|
||||
'Disabled Agent v2 Web app public URL does not expose a stable user-visible unavailable state; the current route redirects to Web app sign-in.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation:
|
||||
'Define and implement the disabled public Web app UX before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
When('I open the disabled Agent v2 Web app URL', async function (this: DifyWorld) {
|
||||
const webAppURL = this.agentBuilder.accessPoint.webAppURL
|
||||
if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.')
|
||||
if (!this.context) throw new Error('Playwright browser context has not been initialized.')
|
||||
|
||||
const webAppPage = await this.context.newPage()
|
||||
await webAppPage.goto(webAppURL)
|
||||
|
||||
this.agentBuilder.accessPoint.webAppPage = webAppPage
|
||||
})
|
||||
|
||||
Then(
|
||||
'the disabled Agent v2 Web app should show an unavailable state',
|
||||
async function (this: DifyWorld) {
|
||||
const webAppPage = this.agentBuilder.accessPoint.webAppPage
|
||||
if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.')
|
||||
|
||||
await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await webAppPage.close()
|
||||
this.agentBuilder.accessPoint.webAppPage = undefined
|
||||
},
|
||||
)
|
||||
|
||||
When('I open the restored Agent v2 Web app URL', async function (this: DifyWorld) {
|
||||
const webAppURL = this.agentBuilder.accessPoint.webAppURL
|
||||
if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.')
|
||||
if (!this.context) throw new Error('Playwright browser context has not been initialized.')
|
||||
|
||||
const webAppPage = await this.context.newPage()
|
||||
await webAppPage.goto(webAppURL)
|
||||
|
||||
this.agentBuilder.accessPoint.webAppPage = webAppPage
|
||||
})
|
||||
|
||||
Then(
|
||||
'the restored Agent v2 Web app should not show an unavailable state',
|
||||
async function (this: DifyWorld) {
|
||||
const webAppPage = this.agentBuilder.accessPoint.webAppPage
|
||||
if (!webAppPage) throw new Error('No Agent v2 Web app page was opened.')
|
||||
|
||||
await expect(webAppPage.getByText(/app is unavailable|site is disabled/i)).not.toBeVisible()
|
||||
await webAppPage.close()
|
||||
this.agentBuilder.accessPoint.webAppPage = undefined
|
||||
},
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AccessSurfaceName } from './access-point-helpers'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { setAgentApiAccess, setAgentSiteAccessAndGetURL } from '../../agent-v2/support/access-point'
|
||||
import { getAgentAccessPath, publishAgentWithPublishableDraft } from '../../agent-v2/support/agent'
|
||||
import { publishAgentWithPublishableDraft } from '../../agent-v2/support/agent'
|
||||
import {
|
||||
getAccessRegion,
|
||||
getAccessSurfaceCard,
|
||||
@@ -31,10 +31,6 @@ Given(
|
||||
},
|
||||
)
|
||||
|
||||
When('I open the Agent v2 Access Point page', async function (this: DifyWorld) {
|
||||
await this.getPage().goto(getAgentAccessPath(getCurrentAgentId(this)))
|
||||
})
|
||||
|
||||
When(
|
||||
'I open the preseeded Agent v2 Access Point page for {string} from the Agent Roster',
|
||||
async function (this: DifyWorld, agentName: string) {
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { openAgentAdvancedSettings } from './configure-helpers'
|
||||
|
||||
When('I expand Agent v2 Advanced Settings', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' })
|
||||
const trigger = advancedSettings
|
||||
.getByRole('heading', { name: 'Advanced Settings' })
|
||||
.getByRole('button')
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Settings' }).first().click()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })).toBeVisible()
|
||||
})
|
||||
|
||||
When('I collapse Agent v2 Advanced Settings', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' })
|
||||
const trigger = advancedSettings
|
||||
.getByRole('heading', { name: 'Advanced Settings' })
|
||||
.getByRole('button')
|
||||
|
||||
await page.getByRole('button', { name: 'Advanced Settings' }).first().click()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
await expect(advancedSettings.getByRole('heading', { name: 'Env Editor' })).not.toBeVisible()
|
||||
})
|
||||
|
||||
@@ -36,7 +45,7 @@ Then(
|
||||
Then(
|
||||
'I should see the supported Agent v2 Advanced Settings entries',
|
||||
async function (this: DifyWorld) {
|
||||
const advancedSettings = await openAgentAdvancedSettings(this.getPage())
|
||||
const advancedSettings = this.getPage().getByRole('region', { name: 'Advanced Settings' })
|
||||
const envEditor = advancedSettings.getByRole('region', { name: 'Env Editor' })
|
||||
|
||||
await expect(envEditor).toBeVisible()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PostAgentByAgentIdCopyResponse } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { createE2EResourceName } from '../../../support/naming'
|
||||
import { getAgentComposerDraft, getTestAgent } from '../../agent-v2/support/agent'
|
||||
@@ -10,12 +10,7 @@ import {
|
||||
agentBuilderPreseededResources,
|
||||
} from '../../agent-v2/support/agent-builder-resources'
|
||||
import { normalAgentPrompt } from '../../agent-v2/support/agent-soul'
|
||||
import {
|
||||
asArray,
|
||||
asRecord,
|
||||
asString,
|
||||
skipBlockedPrecondition,
|
||||
} from '../../agent-v2/support/preflight/common'
|
||||
import { asArray, asRecord, asString } from '../../agent-v2/support/preflight/common'
|
||||
import { agentBuilderTestMaterials } from '../../agent-v2/support/test-materials'
|
||||
import {
|
||||
expectProviderToolActionVisible,
|
||||
@@ -78,12 +73,7 @@ When(
|
||||
const copyName = createE2EResourceName('Agent', 'copy')
|
||||
|
||||
await page.goto('/agents')
|
||||
const card = page
|
||||
.locator('article')
|
||||
.filter({
|
||||
has: page.getByRole('link', { name: agentName }),
|
||||
})
|
||||
.first()
|
||||
const card = page.getByRole('article', { name: agentName, exact: true })
|
||||
|
||||
await expect(card).toBeVisible({ timeout: 30_000 })
|
||||
await card.hover()
|
||||
@@ -268,26 +258,6 @@ Then('I should see the Agent v2 tool state fixture tools', async function (this:
|
||||
)
|
||||
})
|
||||
|
||||
async function skipToolCredentialErrorState(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 Tool credential error state is not covered: the current fixture only proves usable and not-authorized tool states.',
|
||||
{
|
||||
owner: 'seed/product',
|
||||
remediation:
|
||||
'Define a stable invalid credential fixture and the expected user-visible error label before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 Tool credential error state is available', async function (this: DifyWorld) {
|
||||
return skipToolCredentialErrorState(this)
|
||||
})
|
||||
|
||||
Then('Agent v2 Tool credential error state should be available', async function (this: DifyWorld) {
|
||||
return skipToolCredentialErrorState(this)
|
||||
})
|
||||
|
||||
Then('I should see the Agent v2 dual retrieval fixture settings', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const knowledgeSection = page.getByRole('region', { name: 'Knowledge Retrieval' })
|
||||
|
||||
@@ -22,13 +22,10 @@ import {
|
||||
updatedAgentPrompt,
|
||||
updatedAgentSoulConfig,
|
||||
} from '../../agent-v2/support/agent-soul'
|
||||
import { asArray, asRecord, skipBlockedPrecondition } from '../../agent-v2/support/preflight/common'
|
||||
import { hasToolEntry } from '../../agent-v2/support/preflight/tools'
|
||||
import {
|
||||
agentBuilderTestMaterials,
|
||||
getAgentBuilderTestMaterialPath,
|
||||
} from '../../agent-v2/support/test-materials'
|
||||
import { getPreseededToolContract } from '../../agent-v2/support/tools'
|
||||
import {
|
||||
expectAgentModelRequiredFeedback,
|
||||
getAgentEnvVariableValue,
|
||||
@@ -286,54 +283,6 @@ When('I apply the Agent v2 Build draft via API', async function (this: DifyWorld
|
||||
await applyAgentBuildDraft(getCurrentAgentId(this))
|
||||
})
|
||||
|
||||
async function skipBuildDraftToolWriteback(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Build chat Dify Tool writeback is not available: finalize/config mutation paths do not support tools; current passing coverage only verifies API-seeded Build draft apply for files, skills, and env.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation: 'Define and implement Build draft Tool writeback before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 Build chat Dify Tool writeback is available', async function (this: DifyWorld) {
|
||||
return skipBuildDraftToolWriteback(this)
|
||||
})
|
||||
|
||||
Then(
|
||||
'Agent v2 Build chat Dify Tool writeback should be available',
|
||||
async function (this: DifyWorld) {
|
||||
return skipBuildDraftToolWriteback(this)
|
||||
},
|
||||
)
|
||||
|
||||
async function skipBuildDraftUnavailableResourceRecovery(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Build chat unavailable Skill/Tool recovery is not covered: the product needs a stable user-visible failure state and deterministic request fixture before this can be automated.',
|
||||
{
|
||||
owner: 'product/seed',
|
||||
remediation:
|
||||
'Define the unavailable-resource UX contract, then seed a stable model-backed prompt that requests a missing Skill and Tool without mutating the saved Agent config.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given(
|
||||
'Agent v2 Build chat unavailable Skill and Tool recovery is available',
|
||||
async function (this: DifyWorld) {
|
||||
return skipBuildDraftUnavailableResourceRecovery(this)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'Agent v2 Build chat unavailable Skill and Tool recovery should be available',
|
||||
async function (this: DifyWorld) {
|
||||
return skipBuildDraftUnavailableResourceRecovery(this)
|
||||
},
|
||||
)
|
||||
|
||||
Then('I should see the Agent v2 Build draft pending changes', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
|
||||
@@ -438,26 +387,6 @@ Then(
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the normal Agent v2 draft should not include the e2e-summary-skill Skill',
|
||||
async function (this: DifyWorld) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const agentSoul = (await getAgentComposerDraft(getCurrentAgentId(this))).agent_soul
|
||||
|
||||
return (
|
||||
agentSoul?.config_skills?.some(
|
||||
(skill) => skill.name === agentBuilderPreseededResources.summarySkill,
|
||||
) ?? false
|
||||
)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the normal Agent v2 draft should not include the generated build note',
|
||||
async function (this: DifyWorld) {
|
||||
@@ -471,26 +400,6 @@ Then(
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the normal Agent v2 draft should not include the Agent Builder JSON Replace tool',
|
||||
async function (this: DifyWorld) {
|
||||
const agentId = getCurrentAgentId(this)
|
||||
const tool = getPreseededToolContract(this, agentBuilderPreseededResources.jsonReplaceTool)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const draft = await getAgentComposerDraft(agentId)
|
||||
const tools = asArray(asRecord(draft.agent_soul?.tools).dify_tools)
|
||||
|
||||
return hasToolEntry(tools, tool)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'the Agent v2 draft should include the supported Build draft config',
|
||||
async function (this: DifyWorld) {
|
||||
|
||||
@@ -161,16 +161,68 @@ export const uploadSummaryConfigSkillForBuildDraft = async (world: DifyWorld) =>
|
||||
|
||||
export const openAgentAdvancedSettings = async (page: ReturnType<DifyWorld['getPage']>) => {
|
||||
const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' })
|
||||
const trigger = advancedSettings
|
||||
.getByRole('heading', { name: 'Advanced Settings' })
|
||||
.getByRole('button')
|
||||
const envEditorHeading = advancedSettings.getByRole('heading', { name: 'Env Editor' })
|
||||
|
||||
if (!(await envEditorHeading.isVisible().catch(() => false)))
|
||||
await page.getByRole('button', { name: 'Advanced Settings' }).first().click()
|
||||
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(envEditorHeading).toBeVisible()
|
||||
|
||||
return advancedSettings
|
||||
}
|
||||
|
||||
const findAgentEnvVariableRows = async (advancedSettings: Locator, key: string) => {
|
||||
const matchingRows: Locator[] = []
|
||||
|
||||
for (const row of await advancedSettings.getByRole('row').all()) {
|
||||
const keyInput = row.getByRole('textbox', { name: 'Key' })
|
||||
if ((await keyInput.count()) === 1 && (await keyInput.inputValue()) === key)
|
||||
matchingRows.push(row)
|
||||
}
|
||||
|
||||
return matchingRows
|
||||
}
|
||||
|
||||
export const getAgentEnvVariableRow = async (advancedSettings: Locator, key: string) => {
|
||||
let matchingRows: Locator[] = []
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
matchingRows = await findAgentEnvVariableRows(advancedSettings, key)
|
||||
return matchingRows.length
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
return matchingRows[0]!
|
||||
}
|
||||
|
||||
export const expectAgentEnvVariableAbsent = async (advancedSettings: Locator, key: string) => {
|
||||
await expect
|
||||
.poll(async () => (await findAgentEnvVariableRows(advancedSettings, key)).length)
|
||||
.toBe(0)
|
||||
}
|
||||
|
||||
export const expectAgentEnvVariableRows = async (
|
||||
advancedSettings: Locator,
|
||||
key: string,
|
||||
value: string,
|
||||
) => {
|
||||
await getAgentEnvVariableRow(advancedSettings, key)
|
||||
const variableRows = await findAgentEnvVariableRows(advancedSettings, key)
|
||||
|
||||
for (const variableRow of variableRows) {
|
||||
await expect(variableRow.getByRole('textbox', { name: 'Key' })).toHaveValue(key)
|
||||
await expect(variableRow.getByRole('textbox', { name: 'Value' })).toHaveValue(value)
|
||||
await expect(variableRow.getByText('Plain', { exact: true })).toBeVisible()
|
||||
}
|
||||
}
|
||||
|
||||
export const expectAgentEnvVariableVisible = async (
|
||||
world: DifyWorld,
|
||||
key: string,
|
||||
@@ -178,44 +230,13 @@ export const expectAgentEnvVariableVisible = async (
|
||||
) => {
|
||||
const advancedSettings = await openAgentAdvancedSettings(world.getPage())
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = await advancedSettings.textContent()
|
||||
const inputValues = await advancedSettings
|
||||
.getByRole('textbox')
|
||||
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value))
|
||||
|
||||
return {
|
||||
hasKey: inputValues.includes(key) || !!text?.includes(key),
|
||||
hasValue: inputValues.includes(value) || !!text?.includes(value),
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual({
|
||||
hasKey: true,
|
||||
hasValue: true,
|
||||
})
|
||||
await expect(advancedSettings.getByText('Plain', { exact: true })).toBeVisible()
|
||||
await expectAgentEnvVariableRows(advancedSettings, key, value)
|
||||
}
|
||||
|
||||
export const expectAgentEnvVariableHidden = async (world: DifyWorld, key: string) => {
|
||||
const advancedSettings = await openAgentAdvancedSettings(world.getPage())
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = await advancedSettings.textContent()
|
||||
const inputValues = await advancedSettings
|
||||
.getByRole('textbox')
|
||||
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value))
|
||||
|
||||
return inputValues.includes(key) || !!text?.includes(key)
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(false)
|
||||
await expectAgentEnvVariableAbsent(advancedSettings, key)
|
||||
}
|
||||
|
||||
export const expectNormalAgentPromptDraft = async (world: DifyWorld) => {
|
||||
@@ -236,9 +257,11 @@ export const expectProviderToolActionVisible = async (
|
||||
name: tool.providerName,
|
||||
})
|
||||
await expect(provider).toBeVisible()
|
||||
await expect(provider).toHaveAttribute('aria-expanded', 'false')
|
||||
await provider.click()
|
||||
await expect(provider).toHaveAttribute('aria-expanded', 'true')
|
||||
|
||||
const action = toolsSection.getByText(tool.actionName, { exact: true })
|
||||
if (!(await action.isVisible())) await provider.click()
|
||||
await expect(action).toBeVisible()
|
||||
|
||||
return { action, tool }
|
||||
|
||||
@@ -5,8 +5,11 @@ import { getAgentComposerDraft } from '../../agent-v2/support/agent'
|
||||
import { agentBuilderFixedInputs } from '../../agent-v2/support/agent-builder-resources'
|
||||
import { getAgentBuilderTestMaterialPath } from '../../agent-v2/support/test-materials'
|
||||
import {
|
||||
expectAgentEnvVariableAbsent,
|
||||
expectAgentEnvVariableHidden,
|
||||
expectAgentEnvVariableRows,
|
||||
expectAgentEnvVariableVisible,
|
||||
getAgentEnvVariableRow,
|
||||
getAgentEnvVariables,
|
||||
getAgentEnvVariableValue,
|
||||
getCurrentAgentId,
|
||||
@@ -48,13 +51,16 @@ When(
|
||||
const advancedSettings = page.getByRole('region', { name: 'Advanced Settings' })
|
||||
|
||||
await advancedSettings.getByRole('button', { name: 'Add environment variable' }).click()
|
||||
await advancedSettings
|
||||
const newVariableRow = await getAgentEnvVariableRow(advancedSettings, '')
|
||||
await newVariableRow
|
||||
.getByRole('textbox', { name: 'Key' })
|
||||
.last()
|
||||
.fill(agentBuilderFixedInputs.envModeKey)
|
||||
await advancedSettings
|
||||
const savedVariableRow = await getAgentEnvVariableRow(
|
||||
advancedSettings,
|
||||
agentBuilderFixedInputs.envModeKey,
|
||||
)
|
||||
await savedVariableRow
|
||||
.getByRole('textbox', { name: 'Value' })
|
||||
.last()
|
||||
.fill(agentBuilderFixedInputs.envModeValue)
|
||||
await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(2)
|
||||
},
|
||||
@@ -231,22 +237,16 @@ Then(
|
||||
async function (this: DifyWorld) {
|
||||
const advancedSettings = await openAgentAdvancedSettings(this.getPage())
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
advancedSettings
|
||||
.getByRole('textbox')
|
||||
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
agentBuilderFixedInputs.envPlainKey,
|
||||
agentBuilderFixedInputs.envPlainValue,
|
||||
agentBuilderFixedInputs.envModeKey,
|
||||
agentBuilderFixedInputs.envModeValue,
|
||||
]),
|
||||
)
|
||||
await expectAgentEnvVariableRows(
|
||||
advancedSettings,
|
||||
agentBuilderFixedInputs.envPlainKey,
|
||||
agentBuilderFixedInputs.envPlainValue,
|
||||
)
|
||||
await expectAgentEnvVariableRows(
|
||||
advancedSettings,
|
||||
agentBuilderFixedInputs.envModeKey,
|
||||
agentBuilderFixedInputs.envModeValue,
|
||||
)
|
||||
await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(2)
|
||||
},
|
||||
)
|
||||
@@ -256,29 +256,12 @@ Then(
|
||||
async function (this: DifyWorld) {
|
||||
const advancedSettings = await openAgentAdvancedSettings(this.getPage())
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
advancedSettings
|
||||
.getByRole('textbox')
|
||||
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
agentBuilderFixedInputs.envModeKey,
|
||||
agentBuilderFixedInputs.envModeValue,
|
||||
]),
|
||||
)
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
advancedSettings
|
||||
.getByRole('textbox')
|
||||
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toContain(agentBuilderFixedInputs.envPlainKey)
|
||||
await expectAgentEnvVariableRows(
|
||||
advancedSettings,
|
||||
agentBuilderFixedInputs.envModeKey,
|
||||
agentBuilderFixedInputs.envModeValue,
|
||||
)
|
||||
await expectAgentEnvVariableAbsent(advancedSettings, agentBuilderFixedInputs.envPlainKey)
|
||||
await expect(advancedSettings.getByText('Plain', { exact: true })).toHaveCount(1)
|
||||
},
|
||||
)
|
||||
@@ -288,14 +271,18 @@ Then(
|
||||
async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const advancedSettings = await openAgentAdvancedSettings(page)
|
||||
|
||||
await expect(advancedSettings.getByRole('textbox', { name: 'Key' })).toHaveValue(
|
||||
const variableRow = await getAgentEnvVariableRow(
|
||||
advancedSettings,
|
||||
agentBuilderFixedInputs.envPlainKey,
|
||||
)
|
||||
await expect(advancedSettings.getByRole('textbox', { name: 'Value' })).toHaveValue(
|
||||
|
||||
await expect(variableRow.getByRole('textbox', { name: 'Key' })).toHaveValue(
|
||||
agentBuilderFixedInputs.envPlainKey,
|
||||
)
|
||||
await expect(variableRow.getByRole('textbox', { name: 'Value' })).toHaveValue(
|
||||
agentBuilderFixedInputs.envPlainValue,
|
||||
)
|
||||
await expect(advancedSettings.getByText('Plain', { exact: true })).toBeVisible()
|
||||
await expect(variableRow.getByText('Plain', { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible()
|
||||
},
|
||||
)
|
||||
@@ -324,22 +311,16 @@ Then(
|
||||
const page = this.getPage()
|
||||
const advancedSettings = await openAgentAdvancedSettings(page)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
advancedSettings
|
||||
.getByRole('textbox')
|
||||
.evaluateAll((inputs) => inputs.map((input) => (input as HTMLInputElement).value)),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
agentBuilderFixedInputs.envPlainKey,
|
||||
agentBuilderFixedInputs.envPlainValue,
|
||||
agentBuilderFixedInputs.envAfterInvalidImportKey,
|
||||
agentBuilderFixedInputs.envAfterInvalidImportValue,
|
||||
]),
|
||||
)
|
||||
await expectAgentEnvVariableRows(
|
||||
advancedSettings,
|
||||
agentBuilderFixedInputs.envPlainKey,
|
||||
agentBuilderFixedInputs.envPlainValue,
|
||||
)
|
||||
await expectAgentEnvVariableRows(
|
||||
advancedSettings,
|
||||
agentBuilderFixedInputs.envAfterInvalidImportKey,
|
||||
agentBuilderFixedInputs.envAfterInvalidImportValue,
|
||||
)
|
||||
await expect(page.getByRole('button', { name: /^Build$/i })).toBeVisible()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common'
|
||||
import { agentBuilderTestMaterials } from '../../agent-v2/support/test-materials'
|
||||
import {
|
||||
expectAgentConfigFileHidden,
|
||||
@@ -124,45 +123,3 @@ Then(
|
||||
await expectAgentConfigFileSaved(this, 'specialFilename')
|
||||
},
|
||||
)
|
||||
|
||||
async function skipUnsupportedFileFormatRejection(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 unsupported file format rejection is not stable: default upload configuration allows arbitrary extensions unless UPLOAD_FILE_EXTENSION_BLACKLIST is seeded.',
|
||||
{
|
||||
owner: 'product/seed',
|
||||
remediation:
|
||||
'Define Agent config file type restrictions or seed UPLOAD_FILE_EXTENSION_BLACKLIST before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 unsupported file format rejection is available', async function (this: DifyWorld) {
|
||||
return skipUnsupportedFileFormatRejection(this)
|
||||
})
|
||||
|
||||
Then(
|
||||
'Agent v2 unsupported file format rejection should be available',
|
||||
async function (this: DifyWorld) {
|
||||
return skipUnsupportedFileFormatRejection(this)
|
||||
},
|
||||
)
|
||||
|
||||
async function skipOversizedFileRejection(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 oversized file rejection lacks a clear user-visible reason: the current upload dialog collapses upload and commit failures into a generic failure toast.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation: 'Expose a stable user-visible file-size error before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given('Agent v2 oversized file rejection is available', async function (this: DifyWorld) {
|
||||
return skipOversizedFileRejection(this)
|
||||
})
|
||||
|
||||
Then('Agent v2 oversized file rejection should be available', async function (this: DifyWorld) {
|
||||
return skipOversizedFileRejection(this)
|
||||
})
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { DataTable } from '@cucumber/cucumber'
|
||||
import type { DeclaredOutputConfig } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AgentV2WorkflowOutputVariable, DifyWorld } from '../../support/world'
|
||||
import { Given, Then, When } from '@cucumber/cucumber'
|
||||
import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
import { getWorkflowDraft } from '../../../support/api'
|
||||
import { skipBlockedPrecondition } from '../../agent-v2/support/preflight/common'
|
||||
|
||||
const agentV2WorkflowNodeId = 'agent-v2'
|
||||
const taskFileOutputName = 'e2e_report.pdf'
|
||||
@@ -51,11 +50,12 @@ const waitForWorkflowDraftSave = (world: DifyWorld, appId: string) =>
|
||||
|
||||
const openWorkflowOutputVariablesPanel = async (world: DifyWorld) => {
|
||||
const page = world.getPage()
|
||||
const outputVariablesButton = page.getByRole('button', { name: 'Output Variables' })
|
||||
const newOutputButton = page.getByRole('button', { name: 'New output' })
|
||||
|
||||
if (!(await newOutputButton.isVisible().catch(() => false)))
|
||||
await page.getByRole('button', { name: 'Output Variables' }).click()
|
||||
|
||||
await expect(outputVariablesButton).toHaveAttribute('aria-expanded', 'false')
|
||||
await outputVariablesButton.click()
|
||||
await expect(outputVariablesButton).toHaveAttribute('aria-expanded', 'true')
|
||||
await expect(newOutputButton).toBeVisible()
|
||||
}
|
||||
|
||||
@@ -345,29 +345,3 @@ async function expectAgentTaskOutputReference(
|
||||
await expect(page.getByText('file', { exact: true })).toBeVisible()
|
||||
if (unexpectedName) await expect(page.getByText(unexpectedName, { exact: true })).toHaveCount(0)
|
||||
}
|
||||
|
||||
async function skipWorkflowTaskOutputReferenceDeletionConsistency(world: DifyWorld) {
|
||||
return skipBlockedPrecondition(
|
||||
world,
|
||||
'Agent v2 workflow task output deletion consistency is not available: deleting an output from the list currently leaves the Prompt token without a stable user-visible invalid-reference state.',
|
||||
{
|
||||
owner: 'product',
|
||||
remediation:
|
||||
'Define whether deletion should sync the Prompt token, block deletion, or expose an invalid-reference state before enabling this scenario.',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
Given(
|
||||
'Agent v2 workflow task output reference deletion consistency is available',
|
||||
async function (this: DifyWorld) {
|
||||
return skipWorkflowTaskOutputReferenceDeletionConsistency(this)
|
||||
},
|
||||
)
|
||||
|
||||
Then(
|
||||
'Agent v2 workflow task output reference deletion consistency should be available',
|
||||
async function (this: DifyWorld) {
|
||||
return skipWorkflowTaskOutputReferenceDeletionConsistency(this)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '../../agent-v2/support/preflight/agents'
|
||||
import {
|
||||
skipMissingIndexingPreseededDataset,
|
||||
skipMissingPreseededDataset,
|
||||
skipMissingReadyPreseededDataset,
|
||||
} from '../../agent-v2/support/preflight/datasets'
|
||||
import {
|
||||
@@ -81,16 +80,6 @@ Given(
|
||||
},
|
||||
)
|
||||
|
||||
Given(
|
||||
'the Agent Builder preseeded dataset {string} is available',
|
||||
async function (this: DifyWorld, resourceName: string) {
|
||||
const resource = await skipMissingPreseededDataset(this, resourceName)
|
||||
if (resource === 'skipped') return resource
|
||||
|
||||
this.agentBuilder.preflight.preseededResources[resourceName] = resource
|
||||
},
|
||||
)
|
||||
|
||||
Given(
|
||||
'the Agent Builder preseeded dataset {string} is indexed and ready',
|
||||
async function (this: DifyWorld, resourceName: string) {
|
||||
|
||||
@@ -36,8 +36,7 @@ Given(
|
||||
|
||||
When('I open the Agent v2 workflow node panel', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
const workflowCanvas = page.locator('#workflow-container')
|
||||
const agentNode = workflowCanvas.getByRole('button', { name: 'Agent' }).first()
|
||||
const agentNode = page.getByRole('button', { name: 'Agent', exact: true })
|
||||
|
||||
await expect(agentNode).toBeVisible({ timeout: 30_000 })
|
||||
await agentNode.click()
|
||||
|
||||
@@ -16,8 +16,8 @@ When('I enter a unique E2E app name', async function (this: DifyWorld) {
|
||||
|
||||
When('I confirm app creation', async function (this: DifyWorld) {
|
||||
const createButton = this.getPage()
|
||||
.getByRole('dialog')
|
||||
.getByRole('button', { name: /^Create(?:\s|$)/ })
|
||||
.last()
|
||||
|
||||
await expect(createButton).toBeEnabled()
|
||||
await createButton.click()
|
||||
@@ -25,12 +25,9 @@ When('I confirm app creation', async function (this: DifyWorld) {
|
||||
|
||||
When('I select the {string} app type', async function (this: DifyWorld, appType: string) {
|
||||
const dialog = this.getPage().getByRole('dialog')
|
||||
// The modal defaults to ADVANCED_CHAT, so the preview panel immediately renders
|
||||
// <h4>Chatflow</h4> alongside the card's <div>Chatflow</div>.
|
||||
// locator('div').getByText(...) would still match the <h4> because getByText
|
||||
// searches inside each div for any descendant. Use :text-is() instead, which
|
||||
// targets only <div> elements whose own normalised text equals appType exactly.
|
||||
const appTypeCard = dialog.locator(`div:text-is("${appType}")`)
|
||||
const appTypeCard = dialog.getByRole('button', {
|
||||
name: new RegExp(`^${appType.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`),
|
||||
})
|
||||
|
||||
await expect(appTypeCard).toBeVisible()
|
||||
await appTypeCard.click()
|
||||
|
||||
@@ -17,14 +17,9 @@ When('I open the options menu for the last created E2E app', async function (thi
|
||||
|
||||
const page = this.getPage()
|
||||
const appLink = page.getByRole('link', { name: appName, exact: true })
|
||||
const appCard = page
|
||||
.locator('div')
|
||||
.filter({ has: appLink })
|
||||
.filter({ has: page.getByRole('button', { name: 'More' }) })
|
||||
.last()
|
||||
await expect(appLink).toBeVisible()
|
||||
await appCard.hover()
|
||||
await appCard.getByRole('button', { name: 'More' }).click()
|
||||
await appLink.hover()
|
||||
await page.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click()
|
||||
})
|
||||
|
||||
When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Then, When } from '@cucumber/cucumber'
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
When('I open the publish panel', async function (this: DifyWorld) {
|
||||
await this.getPage().getByRole('button', { name: 'Publish' }).first().click()
|
||||
await this.getPage().getByRole('button', { name: 'Publish', exact: true }).click()
|
||||
})
|
||||
|
||||
When('I publish the app', async function (this: DifyWorld) {
|
||||
|
||||
@@ -21,12 +21,17 @@ When('I enable the Web App share', async function (this: DifyWorld) {
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: new RegExp(escapeRegExp(appName)) }).click()
|
||||
await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 })
|
||||
await page.getByRole('switch').first().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) {
|
||||
await expect(this.getPage().getByText('In Service').first()).toBeVisible({ timeout: 10_000 })
|
||||
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) {
|
||||
|
||||
@@ -21,5 +21,7 @@ When('I run the workflow', async function (this: DifyWorld) {
|
||||
Then('the workflow run should succeed', async function (this: DifyWorld) {
|
||||
const page = this.getPage()
|
||||
await page.getByText('DETAIL', { exact: true }).click()
|
||||
await expect(page.getByText('SUCCESS', { exact: true }).first()).toBeVisible({ timeout: 55_000 })
|
||||
await expect(page.getByRole('status').getByText('SUCCESS', { exact: true })).toBeVisible({
|
||||
timeout: 55_000,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,14 +6,14 @@ import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { After, AfterAll, Before, BeforeAll, setDefaultTimeout, Status } from '@cucumber/cucumber'
|
||||
import { chromium } from '@playwright/test'
|
||||
import { chromium, webkit } from '@playwright/test'
|
||||
import { AUTH_BOOTSTRAP_TIMEOUT_MS, ensureAuthenticatedState } from '../../fixtures/auth'
|
||||
import { deleteTestApp } from '../../support/api'
|
||||
import { runCleanupTasks } from '../../support/cleanup'
|
||||
import { runCleanupTasks, shouldFailForCleanupErrors } from '../../support/cleanup'
|
||||
import { deleteTestDataset } from '../../support/datasets'
|
||||
import { getVoiceInputTestMaterialPath } from '../../support/test-materials'
|
||||
import { deleteBuiltinToolCredential } from '../../support/tools'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo } from '../../test-env'
|
||||
import { baseURL, cucumberHeadless, cucumberSlowMo, e2eBrowser } from '../../test-env'
|
||||
import { deleteTestAgent } from '../agent-v2/support/agent'
|
||||
import {
|
||||
deleteAgentConfigFile,
|
||||
@@ -91,12 +91,13 @@ const captureDiagnosticPage = async (
|
||||
BeforeAll({ timeout: AUTH_BOOTSTRAP_TIMEOUT_MS }, async () => {
|
||||
await mkdir(artifactsDir, { recursive: true })
|
||||
|
||||
browser = await chromium.launch({
|
||||
const browserType = e2eBrowser === 'webkit' ? webkit : chromium
|
||||
browser = await browserType.launch({
|
||||
headless: cucumberHeadless,
|
||||
slowMo: cucumberSlowMo,
|
||||
})
|
||||
|
||||
console.warn(`[e2e] session cache bootstrap against ${baseURL}`)
|
||||
console.warn(`[e2e] ${e2eBrowser} session cache bootstrap against ${baseURL}`)
|
||||
await ensureAuthenticatedState(browser, baseURL)
|
||||
})
|
||||
|
||||
@@ -120,6 +121,9 @@ Before(async function (this: DifyWorld, { pickle }) {
|
||||
const scenarioTags = pickle.tags.map((tag) => tag.name)
|
||||
const isMicrophoneScenario = scenarioTags.includes('@microphone')
|
||||
const isUnauthenticatedScenario = scenarioTags.includes('@unauthenticated')
|
||||
if (isMicrophoneScenario && e2eBrowser !== 'chromium')
|
||||
throw new Error('Microphone scenarios require E2E_BROWSER=chromium.')
|
||||
|
||||
const scenarioBrowser = isMicrophoneScenario ? await getMicrophoneBrowser() : browser
|
||||
|
||||
if (isUnauthenticatedScenario) await this.startUnauthenticatedSession(scenarioBrowser)
|
||||
@@ -151,7 +155,7 @@ After(
|
||||
|
||||
const message = `Cleanup errors:\n${closeErrors.join('\n')}`
|
||||
this.attach(message, 'text/plain')
|
||||
if (result?.status === Status.PASSED) throw new Error(message)
|
||||
if (shouldFailForCleanupErrors(result?.status)) throw new Error(message)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -196,7 +200,7 @@ After(
|
||||
|
||||
const message = `Cleanup errors:\n${cleanupErrors.join('\n')}`
|
||||
this.attach(message, 'text/plain')
|
||||
if (result?.status === Status.PASSED) throw new Error(message)
|
||||
if (shouldFailForCleanupErrors(result?.status)) throw new Error(message)
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"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:install": "playwright install --with-deps chromium webkit",
|
||||
"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",
|
||||
|
||||
@@ -81,6 +81,7 @@ export const validateE2eEnv = () =>
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: process.env.E2E_AGENT_DECISION_MODEL_TYPE,
|
||||
E2E_API_URL: process.env.E2E_API_URL,
|
||||
E2E_BASE_URL: process.env.E2E_BASE_URL,
|
||||
E2E_BROWSER: process.env.E2E_BROWSER,
|
||||
E2E_BROKEN_MODEL_NAME: process.env.E2E_BROKEN_MODEL_NAME,
|
||||
E2E_BROKEN_MODEL_PROVIDER: process.env.E2E_BROKEN_MODEL_PROVIDER,
|
||||
E2E_BROKEN_MODEL_TYPE: process.env.E2E_BROKEN_MODEL_TYPE,
|
||||
@@ -123,6 +124,7 @@ export const validateE2eEnv = () =>
|
||||
E2E_AGENT_DECISION_MODEL_TYPE: z.string().min(1).optional(),
|
||||
E2E_API_URL: z.url().optional(),
|
||||
E2E_BASE_URL: z.url().optional(),
|
||||
E2E_BROWSER: z.enum(['chromium', 'webkit']).optional(),
|
||||
E2E_BROKEN_MODEL_NAME: z.string().min(1).optional(),
|
||||
E2E_BROKEN_MODEL_PROVIDER: z.string().min(1).optional(),
|
||||
E2E_BROKEN_MODEL_TYPE: z.string().min(1).optional(),
|
||||
|
||||
+34
-15
@@ -1,6 +1,13 @@
|
||||
import type { ManagedProcess } from '../support/process'
|
||||
import { mkdir, readFile, rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { runCleanupTasks } from '../support/cleanup'
|
||||
import {
|
||||
assertCucumberReport,
|
||||
formatCucumberReportSummary,
|
||||
getCucumberReportGate,
|
||||
readCucumberReportSummary,
|
||||
} from '../support/cucumber-report'
|
||||
import { startLoggedProcess, stopManagedProcess, waitForUrl } from '../support/process'
|
||||
import { startWebServer, stopWebServer } from '../support/web-server'
|
||||
import { apiURL, baseURL, reuseExistingWebServer } from '../test-env'
|
||||
@@ -150,19 +157,17 @@ const main = async () => {
|
||||
const cleanup = async () => {
|
||||
if (!cleanupPromise) {
|
||||
cleanupPromise = (async () => {
|
||||
await stopWebServer()
|
||||
await stopManagedProcess(celeryProcess)
|
||||
await stopManagedProcess(apiProcess)
|
||||
await stopManagedProcess(difyAgentProcess)
|
||||
await stopManagedProcess(shellctlProcess)
|
||||
const cleanupErrors = await runCleanupTasks([
|
||||
{ label: 'Stop web server', run: stopWebServer },
|
||||
{ label: 'Stop celery worker', run: () => stopManagedProcess(celeryProcess) },
|
||||
{ label: 'Stop API server', run: () => stopManagedProcess(apiProcess) },
|
||||
{ label: 'Stop agent backend', run: () => stopManagedProcess(difyAgentProcess) },
|
||||
{ label: 'Stop shellctl sandbox', run: () => stopManagedProcess(shellctlProcess) },
|
||||
...(startMiddlewareForRun ? [{ label: 'Stop middleware', run: stopMiddleware }] : []),
|
||||
])
|
||||
|
||||
if (startMiddlewareForRun) {
|
||||
try {
|
||||
await stopMiddleware()
|
||||
} catch {
|
||||
// Cleanup should continue even if middleware shutdown fails.
|
||||
}
|
||||
}
|
||||
if (cleanupErrors.length > 0)
|
||||
throw new Error(`E2E teardown errors:\n${cleanupErrors.join('\n')}`)
|
||||
})()
|
||||
}
|
||||
|
||||
@@ -170,9 +175,13 @@ const main = async () => {
|
||||
}
|
||||
|
||||
const onTerminate = () => {
|
||||
void cleanup().finally(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
void cleanup()
|
||||
.catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error))
|
||||
})
|
||||
.finally(() => {
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
process.once('SIGINT', onTerminate)
|
||||
@@ -265,6 +274,16 @@ const main = async () => {
|
||||
env: cucumberEnv,
|
||||
})
|
||||
|
||||
const reportGate = getCucumberReportGate(cucumberEnv)
|
||||
if (reportGate) {
|
||||
const reportPath = path.join(cucumberReportDir, 'report.json')
|
||||
const reportSummary = await readCucumberReportSummary(reportPath)
|
||||
console.warn(
|
||||
`[e2e] cucumber report ${reportGate.profile}: ${formatCucumberReportSummary(reportSummary)}`,
|
||||
)
|
||||
assertCucumberReport(reportSummary, reportGate)
|
||||
}
|
||||
|
||||
process.exitCode = result.exitCode
|
||||
} finally {
|
||||
process.off('SIGINT', onTerminate)
|
||||
|
||||
@@ -55,9 +55,13 @@ const middlewareDataPaths = [
|
||||
const e2eStatePaths = [
|
||||
path.join(e2eDir, '.auth'),
|
||||
path.join(e2eDir, 'cucumber-report'),
|
||||
path.join(e2eDir, 'cucumber-report-non-external'),
|
||||
path.join(e2eDir, 'cucumber-report-webkit'),
|
||||
path.join(e2eDir, '.logs'),
|
||||
path.join(e2eDir, '.logs-non-external'),
|
||||
path.join(e2eDir, '.logs-webkit'),
|
||||
path.join(e2eDir, 'playwright-report'),
|
||||
path.join(e2eDir, 'seed-report'),
|
||||
path.join(e2eDir, 'test-results'),
|
||||
]
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ export type CleanupTask = {
|
||||
run: () => Promise<void> | void
|
||||
}
|
||||
|
||||
export const shouldFailForCleanupErrors = (status: string | undefined) =>
|
||||
status === 'PASSED' || status === 'SKIPPED'
|
||||
|
||||
export async function runCleanupTasks(tasks: CleanupTask[]): Promise<string[]> {
|
||||
const errors: string[] = []
|
||||
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
|
||||
type CucumberEmbedding = {
|
||||
data?: string
|
||||
mime_type?: string
|
||||
}
|
||||
|
||||
type CucumberStep = {
|
||||
embeddings?: CucumberEmbedding[]
|
||||
hidden?: boolean
|
||||
result?: {
|
||||
status?: string
|
||||
}
|
||||
}
|
||||
|
||||
type CucumberScenario = {
|
||||
name?: string
|
||||
steps?: CucumberStep[]
|
||||
tags?: { name?: string }[]
|
||||
type?: string
|
||||
}
|
||||
|
||||
export type CucumberReport = {
|
||||
elements?: CucumberScenario[]
|
||||
uri?: string
|
||||
}[]
|
||||
|
||||
export type CucumberReportSummary = {
|
||||
blockedScenarios: {
|
||||
name: string
|
||||
tags: string[]
|
||||
uri: string
|
||||
}[]
|
||||
blockedSkipped: number
|
||||
failed: number
|
||||
other: number
|
||||
passed: number
|
||||
selected: number
|
||||
skipped: number
|
||||
unexpectedSkipped: number
|
||||
}
|
||||
|
||||
export type CucumberReportGate = {
|
||||
allowedBlockedScenarios: Record<string, string[]>
|
||||
maxSkipped: number
|
||||
maxUnexpectedSkipped: number
|
||||
minPassed: number
|
||||
minSelected: number
|
||||
profile: string
|
||||
}
|
||||
|
||||
const reportGateProfiles = {
|
||||
core: {
|
||||
allowedBlockedScenarios: {
|
||||
'features/agent-v2/access-point.feature': ['Workflow access shows the referencing workflow'],
|
||||
'features/agent-v2/advanced-settings.feature': [
|
||||
'Content Moderation keyword preset replies are saved and restored',
|
||||
],
|
||||
'features/agent-v2/agent-edit.feature': [
|
||||
'Saved orchestration sections are visible on the Agent Edit page',
|
||||
'Duplicated Agent inherits configuration without changing the original Agent',
|
||||
'Tool states are visible on the Agent Edit page',
|
||||
'Dual Knowledge Retrieval settings are visible on the Agent Edit page',
|
||||
'Agent Edit opens the same Agent in Agent Console',
|
||||
],
|
||||
'features/agent-v2/configure-persistence.feature': [
|
||||
'Selecting a stable model in Configure persists after refresh',
|
||||
'Persisted Agent v2 instructions remain visible after refresh',
|
||||
],
|
||||
'features/agent-v2/knowledge.feature': [
|
||||
'Agent decide Knowledge Retrieval settings are saved and restored',
|
||||
'Custom query Knowledge Retrieval settings are saved and restored',
|
||||
'Removing Knowledge Retrieval clears the saved dataset reference',
|
||||
],
|
||||
'features/agent-v2/output-variables.feature': [
|
||||
'Workflow Agent v2 output variables persist after refresh',
|
||||
'Workflow Agent v2 nested object output variables persist after refresh',
|
||||
'Workflow Agent v2 prompt output reference stays synced when renamed',
|
||||
],
|
||||
'features/agent-v2/preflight.feature': [
|
||||
'Stable chat model is available',
|
||||
'Default speech-to-text model is available',
|
||||
'Agent-decision chat model is available',
|
||||
'Broken chat model is available for recovery scenarios',
|
||||
'JSON Replace tool is available',
|
||||
'Tavily Search tool is available',
|
||||
'Agent knowledge base is available',
|
||||
'Indexing knowledge base is available',
|
||||
'Full config Agent is available',
|
||||
'Full config Agent includes the summary Skill',
|
||||
'Full config Agent includes core fixture configuration',
|
||||
'Content Moderation Settings is enabled',
|
||||
'Tool states Agent is available',
|
||||
'Tool states Agent includes tool state fixture configuration',
|
||||
'OAuth2 tool Agent includes credential fixture configuration',
|
||||
'Dual retrieval Agent is available',
|
||||
'Dual retrieval Agent includes dual retrieval fixture configuration',
|
||||
'Published Web app Agent exposes Web app access',
|
||||
'Backend API-enabled Agent is available',
|
||||
'Backend API-enabled Agent exposes API access with a key',
|
||||
'Workflow reference Agent is available',
|
||||
'Reference workflow is available',
|
||||
'Workflow reference Agent is used by the reference workflow',
|
||||
],
|
||||
'features/agent-v2/publish.feature': [
|
||||
'Publish a configured Agent v2 draft',
|
||||
'Publish action follows unpublished changes',
|
||||
'Published Agent v2 version remains isolated from draft edits',
|
||||
'Restoring a published Agent v2 version shows the restored configuration in Builder',
|
||||
],
|
||||
'features/agent-v2/tools.feature': [
|
||||
'JSON Replace tool is saved after adding it from the Tools selector',
|
||||
'OAuth2 tool credentials stay authorized after Configure autosaves',
|
||||
],
|
||||
},
|
||||
maxSkipped: 44,
|
||||
maxUnexpectedSkipped: 0,
|
||||
minPassed: 65,
|
||||
minSelected: 109,
|
||||
},
|
||||
external: {
|
||||
allowedBlockedScenarios: {},
|
||||
maxSkipped: 0,
|
||||
maxUnexpectedSkipped: 0,
|
||||
minPassed: 11,
|
||||
minSelected: 11,
|
||||
},
|
||||
'webkit-browser-smoke': {
|
||||
allowedBlockedScenarios: {},
|
||||
maxSkipped: 0,
|
||||
maxUnexpectedSkipped: 0,
|
||||
minPassed: 4,
|
||||
minSelected: 4,
|
||||
},
|
||||
} satisfies Record<string, Omit<CucumberReportGate, 'profile'>>
|
||||
|
||||
export const getCucumberReportGate = (env: NodeJS.ProcessEnv): CucumberReportGate | undefined => {
|
||||
const profile = env.E2E_CUCUMBER_REPORT_PROFILE?.trim()
|
||||
if (!profile) return undefined
|
||||
|
||||
const gate = reportGateProfiles[profile as keyof typeof reportGateProfiles]
|
||||
if (!gate) throw new Error(`Unknown Cucumber report gate profile "${profile}".`)
|
||||
|
||||
return {
|
||||
...gate,
|
||||
allowedBlockedScenarios: Object.fromEntries(
|
||||
Object.entries(gate.allowedBlockedScenarios).map(([uri, names]) => [uri, [...names]]),
|
||||
),
|
||||
profile,
|
||||
}
|
||||
}
|
||||
|
||||
const failureStatuses = new Set(['ambiguous', 'failed', 'pending', 'undefined', 'unknown'])
|
||||
|
||||
const hasBlockedPrecondition = (steps: CucumberStep[]) =>
|
||||
steps
|
||||
.filter((step) => !step.hidden && step.result?.status?.toLowerCase() === 'skipped')
|
||||
.flatMap((step) => step.embeddings || [])
|
||||
.filter((embedding) => embedding.mime_type === 'text/plain' && embedding.data)
|
||||
.some((embedding) => {
|
||||
const contents = Buffer.from(embedding.data || '', 'base64').toString('utf8')
|
||||
return contents.startsWith('Blocked precondition:')
|
||||
})
|
||||
|
||||
export const summarizeCucumberReport = (report: CucumberReport): CucumberReportSummary => {
|
||||
const summary: CucumberReportSummary = {
|
||||
blockedScenarios: [],
|
||||
blockedSkipped: 0,
|
||||
failed: 0,
|
||||
other: 0,
|
||||
passed: 0,
|
||||
selected: 0,
|
||||
skipped: 0,
|
||||
unexpectedSkipped: 0,
|
||||
}
|
||||
|
||||
for (const feature of report) {
|
||||
for (const scenario of feature.elements || []) {
|
||||
if (scenario.type !== 'scenario') continue
|
||||
|
||||
summary.selected += 1
|
||||
const steps = scenario.steps || []
|
||||
const statuses = steps
|
||||
.map((step) => step.result?.status?.toLowerCase())
|
||||
.filter((status): status is string => Boolean(status))
|
||||
|
||||
if (statuses.some((status) => failureStatuses.has(status))) {
|
||||
summary.failed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (steps.length === 0 || statuses.length !== steps.length) {
|
||||
summary.other += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (statuses.includes('skipped')) {
|
||||
summary.skipped += 1
|
||||
if (hasBlockedPrecondition(steps)) {
|
||||
summary.blockedSkipped += 1
|
||||
summary.blockedScenarios.push({
|
||||
name: scenario.name || '<unnamed scenario>',
|
||||
tags: (scenario.tags || []).flatMap((tag) => (tag.name ? [tag.name] : [])),
|
||||
uri: feature.uri || '<unknown feature>',
|
||||
})
|
||||
} else summary.unexpectedSkipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (statuses.length > 0 && statuses.every((status) => status === 'passed')) {
|
||||
summary.passed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
summary.other += 1
|
||||
}
|
||||
}
|
||||
|
||||
return summary
|
||||
}
|
||||
|
||||
export const readCucumberReportSummary = async (reportPath: string) => {
|
||||
const contents = await readFile(reportPath, 'utf8')
|
||||
const report = JSON.parse(contents) as CucumberReport
|
||||
return summarizeCucumberReport(report)
|
||||
}
|
||||
|
||||
export const assertCucumberReport = (summary: CucumberReportSummary, gate: CucumberReportGate) => {
|
||||
const errors: string[] = []
|
||||
const allowedBlockedScenarios = new Set(
|
||||
Object.entries(gate.allowedBlockedScenarios).flatMap(([uri, names]) =>
|
||||
names.map((name) => `${uri}\0${name}`),
|
||||
),
|
||||
)
|
||||
const disallowedBlockedScenarios = summary.blockedScenarios.filter(
|
||||
(scenario) => !allowedBlockedScenarios.has(`${scenario.uri}\0${scenario.name}`),
|
||||
)
|
||||
|
||||
if (summary.selected < gate.minSelected)
|
||||
errors.push(`selected scenarios ${summary.selected} is below minimum ${gate.minSelected}`)
|
||||
if (summary.passed < gate.minPassed)
|
||||
errors.push(`passed scenarios ${summary.passed} is below minimum ${gate.minPassed}`)
|
||||
if (summary.skipped > gate.maxSkipped)
|
||||
errors.push(`skipped scenarios ${summary.skipped} exceeds maximum ${gate.maxSkipped}`)
|
||||
if (summary.unexpectedSkipped > gate.maxUnexpectedSkipped) {
|
||||
errors.push(
|
||||
`unexpected skipped scenarios ${summary.unexpectedSkipped} exceeds maximum ${gate.maxUnexpectedSkipped}`,
|
||||
)
|
||||
}
|
||||
if (disallowedBlockedScenarios.length > 0) {
|
||||
errors.push(
|
||||
`blocked scenarios not present in the checked-in allowlist: ${disallowedBlockedScenarios
|
||||
.map((scenario) => `${scenario.uri}: ${scenario.name}`)
|
||||
.join(', ')}`,
|
||||
)
|
||||
}
|
||||
if (summary.failed > 0) errors.push(`failed scenarios ${summary.failed} exceeds maximum 0`)
|
||||
if (summary.other > 0) errors.push(`unclassified scenarios ${summary.other} exceeds maximum 0`)
|
||||
|
||||
if (errors.length > 0)
|
||||
throw new Error(
|
||||
[
|
||||
`Cucumber report gate "${gate.profile}" failed:`,
|
||||
...errors.map((error) => `- ${error}`),
|
||||
].join('\n'),
|
||||
)
|
||||
}
|
||||
|
||||
export const formatCucumberReportSummary = (summary: CucumberReportSummary) =>
|
||||
[
|
||||
`selected=${summary.selected}`,
|
||||
`passed=${summary.passed}`,
|
||||
`skipped=${summary.skipped}`,
|
||||
`blockedSkipped=${summary.blockedSkipped}`,
|
||||
`unexpectedSkipped=${summary.unexpectedSkipped}`,
|
||||
`failed=${summary.failed}`,
|
||||
`other=${summary.other}`,
|
||||
].join(' ')
|
||||
@@ -2,11 +2,22 @@ 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 supportedE2EBrowsers = ['chromium', 'webkit'] as const
|
||||
export type E2EBrowser = (typeof supportedE2EBrowsers)[number]
|
||||
|
||||
export const resolveE2EBrowser = (value: string | undefined): E2EBrowser => {
|
||||
if (value === undefined) return 'chromium'
|
||||
if (supportedE2EBrowsers.some((browser) => browser === value)) return value as E2EBrowser
|
||||
|
||||
throw new Error(`Unsupported E2E browser "${value}".`)
|
||||
}
|
||||
|
||||
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 e2eBrowser = resolveE2EBrowser(process.env.E2E_BROWSER)
|
||||
export const reuseExistingWebServer = process.env.E2E_REUSE_WEB_SERVER
|
||||
? process.env.E2E_REUSE_WEB_SERVER !== '0'
|
||||
: !process.env.CI
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runCleanupTasks } from '../support/cleanup'
|
||||
import { runCleanupTasks, shouldFailForCleanupErrors } from '../support/cleanup'
|
||||
|
||||
describe('runCleanupTasks', () => {
|
||||
it('runs every task in order', async () => {
|
||||
@@ -47,3 +47,16 @@ describe('runCleanupTasks', () => {
|
||||
expect(errors).toEqual(['sync cleanup: sync failure', 'async cleanup: async failure'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldFailForCleanupErrors', () => {
|
||||
it.each(['PASSED', 'SKIPPED'])('fails a %s scenario when cleanup fails', (status) => {
|
||||
expect(shouldFailForCleanupErrors(status)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['FAILED', 'AMBIGUOUS', 'PENDING', 'UNDEFINED', 'UNKNOWN'])(
|
||||
'preserves an existing %s result when cleanup fails',
|
||||
(status) => {
|
||||
expect(shouldFailForCleanupErrors(status)).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertCucumberReport,
|
||||
getCucumberReportGate,
|
||||
summarizeCucumberReport,
|
||||
} from '../support/cucumber-report'
|
||||
|
||||
const step = (status?: string, options: { blockedReason?: string; hidden?: boolean } = {}) => ({
|
||||
...(options.blockedReason
|
||||
? {
|
||||
embeddings: [
|
||||
{
|
||||
data: Buffer.from(`Blocked precondition: ${options.blockedReason}`).toString('base64'),
|
||||
mime_type: 'text/plain',
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(options.hidden ? { hidden: true } : {}),
|
||||
...(status ? { result: { status } } : {}),
|
||||
})
|
||||
|
||||
const scenario = (name: string, steps: ReturnType<typeof step>[], tags: string[] = []) => ({
|
||||
name,
|
||||
steps,
|
||||
tags: tags.map((tag) => ({ name: tag })),
|
||||
type: 'scenario',
|
||||
})
|
||||
|
||||
describe('summarizeCucumberReport', () => {
|
||||
it('separates passed, explicitly blocked, and unexpected skipped scenarios', () => {
|
||||
const summary = summarizeCucumberReport([
|
||||
{
|
||||
elements: [
|
||||
scenario('passes', [step('passed')]),
|
||||
scenario(
|
||||
'blocked',
|
||||
[step('passed'), step('skipped', { blockedReason: 'fixture' })],
|
||||
['@fixture'],
|
||||
),
|
||||
scenario('unexpected skip', [step('skipped')]),
|
||||
],
|
||||
uri: 'features/example.feature',
|
||||
},
|
||||
])
|
||||
|
||||
expect(summary).toEqual({
|
||||
blockedScenarios: [
|
||||
{
|
||||
name: 'blocked',
|
||||
tags: ['@fixture'],
|
||||
uri: 'features/example.feature',
|
||||
},
|
||||
],
|
||||
blockedSkipped: 1,
|
||||
failed: 0,
|
||||
other: 0,
|
||||
passed: 1,
|
||||
selected: 3,
|
||||
skipped: 2,
|
||||
unexpectedSkipped: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a failed hook as a failed scenario', () => {
|
||||
const summary = summarizeCucumberReport([
|
||||
{
|
||||
elements: [scenario('cleanup fails', [step('passed'), step('failed', { hidden: true })])],
|
||||
uri: 'features/example.feature',
|
||||
},
|
||||
])
|
||||
|
||||
expect(summary.failed).toBe(1)
|
||||
expect(summary.passed).toBe(0)
|
||||
})
|
||||
|
||||
it('classifies a scenario with a missing step status as unclassified', () => {
|
||||
const summary = summarizeCucumberReport([
|
||||
{
|
||||
elements: [scenario('missing status', [step('passed', { hidden: true }), step()])],
|
||||
uri: 'features/example.feature',
|
||||
},
|
||||
])
|
||||
|
||||
expect(summary.other).toBe(1)
|
||||
expect(summary.passed).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertCucumberReport', () => {
|
||||
it('accepts an explicitly blocked readiness report within configured limits', () => {
|
||||
const summary = {
|
||||
blockedScenarios: [
|
||||
{ name: 'fixture blocked', tags: ['@fixture'], uri: 'features/example.feature' },
|
||||
{ name: 'preflight blocked', tags: ['@preflight'], uri: 'features/example.feature' },
|
||||
],
|
||||
blockedSkipped: 2,
|
||||
failed: 0,
|
||||
other: 0,
|
||||
passed: 3,
|
||||
selected: 5,
|
||||
skipped: 2,
|
||||
unexpectedSkipped: 0,
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertCucumberReport(summary, {
|
||||
allowedBlockedScenarios: {
|
||||
'features/example.feature': ['fixture blocked', 'preflight blocked'],
|
||||
},
|
||||
maxSkipped: 2,
|
||||
maxUnexpectedSkipped: 0,
|
||||
minPassed: 3,
|
||||
minSelected: 5,
|
||||
profile: 'core',
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects zero coverage, all-skipped coverage, and unexplained skips', () => {
|
||||
const summary = {
|
||||
blockedScenarios: [
|
||||
{ name: 'fixture blocked', tags: ['@fixture'], uri: 'features/example.feature' },
|
||||
],
|
||||
blockedSkipped: 1,
|
||||
failed: 0,
|
||||
other: 0,
|
||||
passed: 0,
|
||||
selected: 2,
|
||||
skipped: 2,
|
||||
unexpectedSkipped: 1,
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertCucumberReport(summary, {
|
||||
allowedBlockedScenarios: {
|
||||
'features/example.feature': ['fixture blocked'],
|
||||
},
|
||||
maxSkipped: 2,
|
||||
maxUnexpectedSkipped: 0,
|
||||
minPassed: 1,
|
||||
minSelected: 3,
|
||||
profile: 'external',
|
||||
}),
|
||||
).toThrow(
|
||||
[
|
||||
'Cucumber report gate "external" failed:',
|
||||
'- selected scenarios 2 is below minimum 3',
|
||||
'- passed scenarios 0 is below minimum 1',
|
||||
'- unexpected skipped scenarios 1 exceeds maximum 0',
|
||||
].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects a blocked scenario that replaces an allowed scenario with the same dependency tag', () => {
|
||||
const summary = {
|
||||
blockedScenarios: [
|
||||
{ name: 'core regression', tags: ['@fixture'], uri: 'features/example.feature' },
|
||||
],
|
||||
blockedSkipped: 1,
|
||||
failed: 0,
|
||||
other: 0,
|
||||
passed: 1,
|
||||
selected: 2,
|
||||
skipped: 1,
|
||||
unexpectedSkipped: 0,
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
assertCucumberReport(summary, {
|
||||
allowedBlockedScenarios: {
|
||||
'features/example.feature': ['fixture blocked'],
|
||||
},
|
||||
maxSkipped: 1,
|
||||
maxUnexpectedSkipped: 0,
|
||||
minPassed: 1,
|
||||
minSelected: 2,
|
||||
profile: 'core',
|
||||
}),
|
||||
).toThrow(
|
||||
'blocked scenarios not present in the checked-in allowlist: features/example.feature: core regression',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCucumberReportGate', () => {
|
||||
it('returns no gate unless a profile is configured', () => {
|
||||
expect(getCucumberReportGate({})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('returns the checked-in gate for a known profile', () => {
|
||||
expect(
|
||||
getCucumberReportGate({
|
||||
E2E_CUCUMBER_REPORT_PROFILE: 'webkit-browser-smoke',
|
||||
}),
|
||||
).toEqual({
|
||||
allowedBlockedScenarios: {},
|
||||
maxSkipped: 0,
|
||||
maxUnexpectedSkipped: 0,
|
||||
minPassed: 4,
|
||||
minSelected: 4,
|
||||
profile: 'webkit-browser-smoke',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects an unknown profile instead of silently weakening the gate', () => {
|
||||
expect(() =>
|
||||
getCucumberReportGate({
|
||||
E2E_CUCUMBER_REPORT_PROFILE: 'custom',
|
||||
}),
|
||||
).toThrow('Unknown Cucumber report gate profile "custom".')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveE2EBrowser } from '../test-env'
|
||||
|
||||
describe('resolveE2EBrowser', () => {
|
||||
it('uses Chromium by default', () => {
|
||||
expect(resolveE2EBrowser(undefined)).toBe('chromium')
|
||||
})
|
||||
|
||||
it.each(['chromium', 'webkit'] as const)('accepts %s', (browser) => {
|
||||
expect(resolveE2EBrowser(browser)).toBe(browser)
|
||||
})
|
||||
|
||||
it('rejects unsupported browsers', () => {
|
||||
expect(() => resolveE2EBrowser('firefox')).toThrow('Unsupported E2E browser "firefox".')
|
||||
})
|
||||
})
|
||||
@@ -10,7 +10,8 @@
|
||||
* - Access mode icons
|
||||
*/
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { AppCard } from '@/app/components/apps/app-card'
|
||||
@@ -278,8 +279,14 @@ const renderAppCard = (app?: Partial<App>) => {
|
||||
})
|
||||
}
|
||||
|
||||
const openOperationsMenu = () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
|
||||
const openOperationsMenu = async (appName = 'Test Chat App') => {
|
||||
const user = userEvent.setup()
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: `common.operation.moreActionsFor:{"name":"${appName}"}`,
|
||||
}),
|
||||
)
|
||||
return user
|
||||
}
|
||||
|
||||
describe('App Card Operations Flow', () => {
|
||||
@@ -334,15 +341,15 @@ describe('App Card Operations Flow', () => {
|
||||
it('should show delete confirmation and call API on confirm', async () => {
|
||||
renderAppCard({ id: 'app-to-delete', name: 'Deletable App' })
|
||||
|
||||
openOperationsMenu()
|
||||
fireEvent.click(await screen.findByText('common.operation.delete'))
|
||||
const user = await openOperationsMenu('Deletable App')
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'common.operation.delete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('app.deleteAppConfirmTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Deletable App' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
await user.type(screen.getByRole('textbox'), 'Deletable App')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDeleteAppMutation).toHaveBeenCalledWith('app-to-delete')
|
||||
@@ -355,9 +362,9 @@ describe('App Card Operations Flow', () => {
|
||||
it('should open edit modal and call updateAppInfo on confirm', async () => {
|
||||
renderAppCard({ id: 'app-edit', name: 'Editable App' })
|
||||
|
||||
openOperationsMenu()
|
||||
fireEvent.click(await screen.findByText('app.editApp'))
|
||||
fireEvent.click(await screen.findByTestId('confirm-edit'))
|
||||
const user = await openOperationsMenu('Editable App')
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'app.editApp' }))
|
||||
await user.click(await screen.findByRole('button', { name: 'Confirm' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateAppInfo).toHaveBeenCalledWith(
|
||||
@@ -375,8 +382,8 @@ describe('App Card Operations Flow', () => {
|
||||
it('should call exportAppConfig for completion apps', async () => {
|
||||
renderAppCard({ id: 'app-export', mode: AppModeEnum.COMPLETION, name: 'Export App' })
|
||||
|
||||
openOperationsMenu()
|
||||
fireEvent.click(await screen.findByText('app.export'))
|
||||
const user = await openOperationsMenu('Export App')
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'app.export' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(exportAppConfig).toHaveBeenCalledWith(
|
||||
@@ -392,7 +399,9 @@ describe('App Card Operations Flow', () => {
|
||||
renderAppCard({ name: 'Readonly App', created_by: 'another-user', permission_keys: [] })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.more' }),
|
||||
screen.queryByRole('button', {
|
||||
name: /common\.operation\.moreActionsFor/,
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -402,21 +411,16 @@ describe('App Card Operations Flow', () => {
|
||||
it('should show switch option for chat mode apps', async () => {
|
||||
renderAppCard({ id: 'app-switch', mode: AppModeEnum.CHAT })
|
||||
|
||||
openOperationsMenu()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('app.switch')).toBeInTheDocument()
|
||||
})
|
||||
await openOperationsMenu()
|
||||
expect(await screen.findByRole('menuitem', { name: 'app.switch' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('should not show switch option for workflow apps', async () => {
|
||||
renderAppCard({ id: 'app-wf', mode: AppModeEnum.WORKFLOW, name: 'WF App' })
|
||||
|
||||
openOperationsMenu()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('app.switch')).not.toBeInTheDocument()
|
||||
})
|
||||
await openOperationsMenu('WF App')
|
||||
expect(await screen.findByRole('menu')).toBeVisible()
|
||||
expect(screen.queryByRole('menuitem', { name: 'app.switch' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactElement } from 'react'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
@@ -181,6 +181,20 @@ describe('AppCard', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should expose the web app controls as a named region', () => {
|
||||
render(<AppCard appInfo={appInfo} onChangeStatus={mockOnChangeStatus} />)
|
||||
|
||||
const webAppCard = screen.getByRole('region', {
|
||||
name: /(?:^|\.)overview\.appInfo\.title(?=$|:)/,
|
||||
})
|
||||
|
||||
expect(
|
||||
within(webAppCard).getByRole('switch', {
|
||||
name: /(?:^|\.)overview\.appInfo\.title(?=$|:)/,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open the workflow web app directly when launch is clicked even with hidden inputs', () => {
|
||||
mockWorkflow = {
|
||||
graph: {
|
||||
|
||||
@@ -292,6 +292,8 @@ function AppCard({
|
||||
|
||||
return (
|
||||
<div
|
||||
role="region"
|
||||
aria-label={basicName}
|
||||
className={`${isInPanel ? 'border-t border-l-[0.5px]' : 'border-[0.5px] shadow-xs'} w-full max-w-full rounded-xl border-effects-highlight ${className ?? ''} ${cardState.isMinimalState ? 'h-12' : ''}`}
|
||||
>
|
||||
<div
|
||||
@@ -364,6 +366,7 @@ function AppCard({
|
||||
render={
|
||||
<div>
|
||||
<Switch
|
||||
aria-label={basicName}
|
||||
checked={cardState.runningStatus}
|
||||
onCheckedChange={onChangeStatus}
|
||||
disabled={cardState.toggleDisabled}
|
||||
@@ -381,6 +384,7 @@ function AppCard({
|
||||
</Popover>
|
||||
) : (
|
||||
<Switch
|
||||
aria-label={basicName}
|
||||
checked={cardState.runningStatus}
|
||||
onCheckedChange={onChangeStatus}
|
||||
disabled={cardState.toggleDisabled}
|
||||
|
||||
@@ -582,7 +582,9 @@ describe('AppCard', () => {
|
||||
expect(screen.queryByRole('link', { name: 'Preview Only App' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.more' }),
|
||||
screen.queryByRole('button', {
|
||||
name: /common\.operation\.moreActionsFor/,
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(tagSelector)
|
||||
@@ -617,7 +619,9 @@ describe('AppCard', () => {
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'app.studio.starApp' })).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.more' }),
|
||||
screen.queryByRole('button', {
|
||||
name: /common\.operation\.moreActionsFor/,
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(card)
|
||||
|
||||
@@ -643,7 +643,10 @@ export function AppCardActionBar({ app, onRefresh }: AppCardActionBarProps) {
|
||||
onOpenChange={setIsOperationsMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
|
||||
aria-label={t(($) => $['operation.moreActionsFor'], {
|
||||
ns: 'common',
|
||||
name: app.name,
|
||||
})}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
@@ -1271,7 +1274,10 @@ export function AppCard({
|
||||
onOpenChange={setIsOperationsMenuOpen}
|
||||
>
|
||||
<DropdownMenuTrigger
|
||||
aria-label={t(($) => $['operation.more'], { ns: 'common' })}
|
||||
aria-label={t(($) => $['operation.moreActionsFor'], {
|
||||
ns: 'common',
|
||||
name: app.name,
|
||||
})}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden',
|
||||
isOperationsMenuOpen ? 'bg-state-base-hover' : 'hover:bg-state-base-hover',
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AppContextStateMockState } from '@/__tests__/utils/mock-app-contex
|
||||
import type { ModalContextState } from '@/context/modal-context'
|
||||
import type { ProviderContextState } from '@/context/provider-context'
|
||||
import { fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||
import { Plan } from '@/app/components/billing/type'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
@@ -255,6 +256,13 @@ describe('AccountDropdown', () => {
|
||||
expect(screen.getByRole('button', { name: 'common.account.account' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the account trigger disabled in server-rendered markup', () => {
|
||||
const container = document.createElement('div')
|
||||
container.innerHTML = renderToString(<AppSelector />)
|
||||
|
||||
expect(container.querySelector('button[aria-label="common.account.account"]')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should show EDU badge for education accounts', () => {
|
||||
// Arrange
|
||||
vi.mocked(useProviderContext).mockReturnValue({
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useState, useSyncExternalStore } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resetUser } from '@/app/components/base/amplitude/utils'
|
||||
import {
|
||||
@@ -33,10 +33,19 @@ type AccountDropdownProps = {
|
||||
const mainNavMenuPopupClassName =
|
||||
'w-60 max-w-80 overflow-hidden bg-components-panel-bg-blur! p-0! backdrop-blur-[5px]'
|
||||
|
||||
const subscribeHydrationState = () => () => {}
|
||||
const getHydrationSnapshot = () => false
|
||||
const getServerHydrationSnapshot = () => true
|
||||
|
||||
export default function AppSelector({ trigger, variant = 'default' }: AccountDropdownProps = {}) {
|
||||
const router = useRouter()
|
||||
const [aboutVisible, setAboutVisible] = useState(false)
|
||||
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
|
||||
const isHydrating = useSyncExternalStore(
|
||||
subscribeHydrationState,
|
||||
getHydrationSnapshot,
|
||||
getServerHydrationSnapshot,
|
||||
)
|
||||
const { t } = useTranslation()
|
||||
const userProfile = useAtomValue(userProfileAtom)
|
||||
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
|
||||
@@ -64,6 +73,7 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro
|
||||
<DropdownMenu open={isAccountMenuOpen} onOpenChange={setIsAccountMenuOpen}>
|
||||
{trigger ? (
|
||||
<DropdownMenuTrigger
|
||||
disabled={isHydrating}
|
||||
render={trigger({
|
||||
isOpen: isAccountMenuOpen,
|
||||
ariaLabel: t(($) => $['account.account'], { ns: 'common' }),
|
||||
@@ -71,9 +81,10 @@ export default function AppSelector({ trigger, variant = 'default' }: AccountDro
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenuTrigger
|
||||
disabled={isHydrating}
|
||||
aria-label={t(($) => $['account.account'], { ns: 'common' })}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge',
|
||||
'inline-flex items-center rounded-[20px] p-0.5 hover:bg-background-default-dodge disabled:cursor-default disabled:hover:bg-transparent',
|
||||
isAccountMenuOpen && 'bg-background-default-dodge',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -22,7 +22,7 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => {
|
||||
aria-label={ariaLabel}
|
||||
title={userProfile.name}
|
||||
className={cn(
|
||||
'flex min-w-0 shrink items-center rounded-full text-left text-components-main-nav-text transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden',
|
||||
'flex min-w-0 shrink items-center rounded-full text-left text-components-main-nav-text transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-default disabled:hover:bg-transparent',
|
||||
compact ? 'justify-center p-1' : 'max-w-[180px] gap-3 py-1 pr-4 pl-1',
|
||||
isOpen && 'bg-state-base-hover',
|
||||
)}
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('Status', () => {
|
||||
it('renders succeeded metadata values', () => {
|
||||
render(<Status status="succeeded" time={1.234} tokens={8} />)
|
||||
|
||||
expect(screen.getByText('SUCCESS')).toBeInTheDocument()
|
||||
expect(screen.getByRole('status')).toHaveTextContent('SUCCESS')
|
||||
expect(screen.getByText('1.234s')).toBeInTheDocument()
|
||||
expect(screen.getByText('8 Tokens')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -14,6 +14,7 @@ const StatusContainer: FC<Props> = ({ status, children }) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
'relative rounded-lg border border-workflow-display-disabled-border-1 px-3 py-2.5 system-xs-regular break-all',
|
||||
status === 'succeeded' &&
|
||||
|
||||
+24
-17
@@ -130,12 +130,15 @@ function EnvEditorScope({
|
||||
function EnvEditorCell({
|
||||
children,
|
||||
className,
|
||||
role,
|
||||
}: {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
role?: React.AriaRole
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
role={role}
|
||||
className={cn(
|
||||
'flex min-h-7 min-w-0 items-center border-r border-divider-subtle last:border-r-0',
|
||||
className,
|
||||
@@ -205,16 +208,16 @@ function EnvEditorRow({
|
||||
: 'grid-cols-[minmax(120px,180px)_minmax(160px,1fr)_28px]'
|
||||
const shouldMaskValue = variable.masked && !isValueRevealed
|
||||
const displayedValue = shouldMaskValue ? maskedEnvValue : variable.value
|
||||
|
||||
return (
|
||||
<div
|
||||
role="row"
|
||||
className={cn(
|
||||
'grid min-h-7 border-t border-divider-subtle',
|
||||
gridClassName,
|
||||
isHighlighted && 'bg-background-default-hover',
|
||||
)}
|
||||
>
|
||||
<EnvEditorCell>
|
||||
<EnvEditorCell role="cell">
|
||||
{editable ? (
|
||||
<EnvEditorInput
|
||||
aria-label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.keyColumn'])}
|
||||
@@ -236,7 +239,7 @@ function EnvEditorRow({
|
||||
</span>
|
||||
)}
|
||||
</EnvEditorCell>
|
||||
<EnvEditorCell>
|
||||
<EnvEditorCell role="cell">
|
||||
{editable && !shouldMaskValue ? (
|
||||
<EnvEditorInput
|
||||
aria-label={t(($) => $['agentDetail.configure.advancedSettings.envEditor.valueColumn'])}
|
||||
@@ -275,11 +278,11 @@ function EnvEditorRow({
|
||||
)}
|
||||
</EnvEditorCell>
|
||||
{showScope && (
|
||||
<EnvEditorCell>
|
||||
<EnvEditorCell role="cell">
|
||||
<EnvEditorScope editable={editable} scope={variable.scope} onChange={onScopeChange} />
|
||||
</EnvEditorCell>
|
||||
)}
|
||||
<EnvEditorCell className="justify-center">
|
||||
<EnvEditorCell role="cell" className="justify-center">
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -319,7 +322,6 @@ function EnvEditorDraftRow({
|
||||
const valuePlaceholder = t(
|
||||
($) => $['agentDetail.configure.advancedSettings.envEditor.valuePlaceholder'],
|
||||
)
|
||||
|
||||
const renderDraftPlaceholder = (label: string) => (
|
||||
<span className="min-w-0 truncate px-3 system-xs-regular text-components-input-text-placeholder">
|
||||
{label}
|
||||
@@ -342,15 +344,15 @@ function EnvEditorDraftRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('grid min-h-7 border-t border-divider-subtle', gridClassName)}>
|
||||
<EnvEditorCell>{renderDraftPlaceholder(keyPlaceholder)}</EnvEditorCell>
|
||||
<EnvEditorCell>{renderDraftValueCell()}</EnvEditorCell>
|
||||
<div role="row" className={cn('grid min-h-7 border-t border-divider-subtle', gridClassName)}>
|
||||
<EnvEditorCell role="cell">{renderDraftPlaceholder(keyPlaceholder)}</EnvEditorCell>
|
||||
<EnvEditorCell role="cell">{renderDraftValueCell()}</EnvEditorCell>
|
||||
{showScope && (
|
||||
<EnvEditorCell>
|
||||
<EnvEditorCell role="cell">
|
||||
<EnvEditorScope scope="plain" />
|
||||
</EnvEditorCell>
|
||||
)}
|
||||
<EnvEditorCell />
|
||||
<EnvEditorCell role="cell" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -381,6 +383,7 @@ export function EnvVariablesTable({
|
||||
showScope?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const tableLabel = t(($) => $['agentDetail.configure.advancedSettings.envEditor.label'])
|
||||
const gridClassName = showScope
|
||||
? 'grid-cols-[minmax(76px,1fr)_minmax(84px,1.25fr)_72px_28px]'
|
||||
: 'grid-cols-[minmax(120px,180px)_minmax(160px,1fr)_28px]'
|
||||
@@ -406,26 +409,30 @@ export function EnvVariablesTable({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-divider-regular bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-3">
|
||||
<div className={cn('grid min-h-7 text-text-tertiary', gridClassName)}>
|
||||
<EnvEditorCell>
|
||||
<div
|
||||
role="table"
|
||||
aria-label={tableLabel}
|
||||
className="overflow-hidden rounded-lg border border-divider-regular bg-components-panel-on-panel-item-bg shadow-xs shadow-shadow-shadow-3"
|
||||
>
|
||||
<div role="row" className={cn('grid min-h-7 text-text-tertiary', gridClassName)}>
|
||||
<EnvEditorCell role="columnheader">
|
||||
<span className="px-3 system-xs-medium-uppercase">
|
||||
{t(($) => $['agentDetail.configure.advancedSettings.envEditor.keyColumn'])}
|
||||
</span>
|
||||
</EnvEditorCell>
|
||||
<EnvEditorCell>
|
||||
<EnvEditorCell role="columnheader">
|
||||
<span className="px-3 system-xs-medium-uppercase">
|
||||
{t(($) => $['agentDetail.configure.advancedSettings.envEditor.valueColumn'])}
|
||||
</span>
|
||||
</EnvEditorCell>
|
||||
{showScope && (
|
||||
<EnvEditorCell>
|
||||
<EnvEditorCell role="columnheader">
|
||||
<span className="px-3 system-xs-medium-uppercase">
|
||||
{t(($) => $['agentDetail.configure.advancedSettings.envEditor.scopeColumn'])}
|
||||
</span>
|
||||
</EnvEditorCell>
|
||||
)}
|
||||
<EnvEditorCell className="justify-center">
|
||||
<EnvEditorCell role="columnheader" className="justify-center">
|
||||
{onAdd && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -129,6 +129,12 @@ describe('AgentRosterList', () => {
|
||||
expect(screen.queryByText('agent')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('exposes each agent card with the agent name', () => {
|
||||
renderList([createAgent()])
|
||||
|
||||
expect(screen.getByRole('article', { name: 'Research Agent' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('uses the Figma-aligned card title and role typography', () => {
|
||||
renderList([createAgent()])
|
||||
|
||||
|
||||
@@ -161,7 +161,10 @@ function AgentRosterItem({ agent }: { agent: AgentAppPartial }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="group relative col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:content-[''] hover:shadow-lg has-[>div>a:focus-visible]:after:inset-ring-2 has-[>div>a:focus-visible]:after:inset-ring-state-accent-solid">
|
||||
<article
|
||||
aria-labelledby={nameId}
|
||||
className="group relative col-span-1 h-36.5 min-w-0 overflow-hidden rounded-xl border-[0.5px] border-solid border-components-card-border bg-components-card-bg shadow-xs shadow-shadow-shadow-3 transition-shadow duration-200 ease-in-out after:pointer-events-none after:absolute after:inset-0 after:rounded-xl after:content-[''] hover:shadow-lg has-[>div>a:focus-visible]:after:inset-ring-2 has-[>div>a:focus-visible]:after:inset-ring-state-accent-solid"
|
||||
>
|
||||
<div className="flex h-full min-w-0 flex-col">
|
||||
<Link
|
||||
href={`/agents/${agent.id}/configure`}
|
||||
|
||||
@@ -471,6 +471,7 @@
|
||||
"operation.log": "Log",
|
||||
"operation.more": "More",
|
||||
"operation.moreActions": "More actions",
|
||||
"operation.moreActionsFor": "More actions for {{name}}",
|
||||
"operation.no": "No",
|
||||
"operation.noSearchCount": "0 {{content}}",
|
||||
"operation.noSearchResults": "No {{content}} were found",
|
||||
|
||||
Reference in New Issue
Block a user