You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@apisix.apache.org by ju...@apache.org on 2025/05/13 12:40:42 UTC

(apisix-dashboard) branch master updated: feat: e2e workflow, auth test (#3050)

This is an automated email from the ASF dual-hosted git repository.

juzhiyuan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-dashboard.git


The following commit(s) were added to refs/heads/master by this push:
     new 8cdd4f424 feat: e2e workflow, auth test (#3050)
8cdd4f424 is described below

commit 8cdd4f4241b5a90ae6fc03c7720170e2ce63cb8a
Author: YYYoung <is...@outlook.com>
AuthorDate: Tue May 13 20:40:36 2025 +0800

    feat: e2e workflow, auth test (#3050)
---
 .devcontainer/devcontainer.json                    |   3 +-
 .devcontainer/docker-compose.override.yml          |  10 ++
 .devcontainer/docker-compose.yml                   |  41 +----
 .github/workflows/e2e.yml                          |  94 +++++++++++
 .gitignore                                         |   8 +
 e2e/auth.spec.ts                                   |  67 ++++++++
 .../apisix_conf.yml => e2e/server/Dockerfile       |  42 +++--
 {.devcontainer => e2e/server}/apisix_conf.yml      |   0
 .../server/docker-compose.common.yml               |  49 +++---
 .../server/docker-compose.yml                      |  54 ++++---
 .../apisix_conf.yml => e2e/server/nginx.conf       |  38 ++---
 src/components/form/Btn.tsx => e2e/utils/common.ts |  19 ++-
 src/components/form/Btn.tsx => e2e/utils/env.ts    |  25 ++-
 e2e/utils/test.ts                                  |  70 +++++++++
 eslint.config.js                                   |  69 +++++++-
 package.json                                       |  16 +-
 playwright.config.ts                               |  60 +++++++
 pnpm-lock.yaml                                     | 174 +++++++++++++++++++++
 src/components/form/Btn.tsx                        |  10 +-
 tsconfig.app.json                                  |   5 +-
 tsconfig.e2e.json                                  |  27 ++++
 tsconfig.json                                      |   7 +-
 22 files changed, 731 insertions(+), 157 deletions(-)

diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index ead684d11..22c663e3f 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -50,7 +50,8 @@
         "lokalise.i18n-ally",
         "formulahendry.auto-close-tag",
         "formulahendry.auto-rename-tag",
-        "github.vscode-pull-request-github"
+        "github.vscode-pull-request-github",
+        "redhat.vscode-yaml"
       ]
     }
   }
diff --git a/.devcontainer/docker-compose.override.yml b/.devcontainer/docker-compose.override.yml
new file mode 100644
index 000000000..9a546f623
--- /dev/null
+++ b/.devcontainer/docker-compose.override.yml
@@ -0,0 +1,10 @@
+services:
+  apisix:
+    ports:
+      - '9180:9180/tcp'
+      - '9080:9080/tcp'
+      - '9091:9091/tcp'
+      - '9443:9443/tcp'
+  etcd:
+    ports:
+      - '2379:2379/tcp'
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 7167b8aa0..3180bb90e 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -1,3 +1,7 @@
+include:
+  - path:
+    - ../e2e/server/docker-compose.common.yml
+    - docker-compose.override.yml
 services:
   apisix-dashboard:
     build:
@@ -11,40 +15,3 @@ services:
     ports:
       - '5173:5173'
       - '5174:5174'
-
-  apisix:
-    image: 'apache/apisix:3.12.0-debian'
-    restart: always
-    volumes:
-      - ./apisix_conf.yml:/usr/local/apisix/conf/config.yaml:ro
-    depends_on:
-      - etcd
-    ports:
-      - '9180:9180/tcp'
-      - '9080:9080/tcp'
-      - '9091:9091/tcp'
-      - '9443:9443/tcp'
-    networks:
-      - apisix
-
-  etcd:
-    image: bitnami/etcd:3.5
-    restart: always
-    volumes:
-      - etcd_data:/bitnami/etcd
-    environment:
-      ETCD_ENABLE_V2: 'true'
-      ALLOW_NONE_AUTHENTICATION: 'yes'
-      ETCD_ADVERTISE_CLIENT_URLS: 'http://etcd:2379'
-      ETCD_LISTEN_CLIENT_URLS: 'http://0.0.0.0:2379'
-    ports:
-      - '2379:2379/tcp'
-    networks:
-      - apisix
-
-networks:
-  apisix:
-    driver: bridge
-
-volumes:
-  etcd_data:
diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
new file mode 100644
index 000000000..4ccef7fac
--- /dev/null
+++ b/.github/workflows/e2e.yml
@@ -0,0 +1,94 @@
+name: Frontend e2e test
+
+on:
+  push:
+    branches:
+      - "**"
+  pull_request:
+    types: [labeled, synchronize]
+    branches:
+        - "**"
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  test:
+    if: ${{ contains(github.event.pull_request.labels.*.name, 'e2e-test') }}
+    timeout-minutes: 40
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Get PR Labels
+        id: get-labels
+        uses: actions/github-script@v6
+        with:
+          script: |
+            const labels = context.payload.pull_request.labels.map(label => label.name);
+            core.setOutput("labels", labels.join(","));
+      - name: Set image label as environment variable
+        run: |
+          export LABELS=${{ steps.get-labels.outputs.labels }}
+          echo "LABELS=${LABELS}" >> $GITHUB_ENV
+        shell: bash
+
+      # ensure this in https://github.com/apache/infrastructure-actions/blob/main/actions.yml
+      - uses: pnpm/action-setup@v4
+        name: Install pnpm
+        with:
+          run_install: false
+
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "22"
+          cache: "pnpm"
+
+      - name: Install dependencies
+        run: |
+          pnpm install --frozen-lockfile
+
+      - name: Install Playwright Browsers
+        run: |
+          pnpm exec playwright install --with-deps
+
+      - name: Run e2e server
+        working-directory: ./e2e/server
+        run: |
+          docker compose up -d
+
+      - name: Waiting dashboard service to be healthy
+        working-directory: ./e2e/server
+        run: |
+          E2E_SERVER="dashboard-e2e"
+          TIMEOUT=30
+          timeout $TIMEOUT bash -c '
+            until [ "$(docker inspect --format="{{.State.Health.Status}}" $(docker compose ps -q '$E2E_SERVER'))" = "healthy" ]; do
+              echo "'$E2E_SERVER' is starting..."
+              sleep 5
+            done
+          ' || (echo "$E2E_SERVER not healthy after $TIMEOUT seconds" && exit 1)
+
+      - name: Run e2e tests
+        run: |
+          pnpm e2e
+
+      - uses: actions/upload-artifact@v4
+        if: ${{ !cancelled() }}
+        with:
+          name: test-results
+          path: apps/site-e2e/test-results/
+          retention-days: 7
+
+      - uses: actions/upload-artifact@v4
+        if: ${{ !cancelled() }}
+        with:
+          name: playwright-report
+          path: playwright-report/
+          retention-days: 7
+
+      - name: Print Components Logs
+        if: failure()
+        run: |
+          docker ps --format '{{.Names}}' | xargs -I{} bash -c "echo ================= {} ==================== &&  docker logs {} && echo ================= {} ===================="
diff --git a/.gitignore b/.gitignore
index 15c6f2f74..3e3417ee5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,11 @@ dist-ssr
 *.sw?
 
 /.pnpm-store
+
+# Playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+
+.eslintcache
diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts
new file mode 100644
index 000000000..58cd14e4c
--- /dev/null
+++ b/e2e/auth.spec.ts
@@ -0,0 +1,67 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect } from '@playwright/test';
+import { test } from '@utils/test';
+
+import { getAPISIXConf } from './utils/common';
+
+// use empty storage state to avoid auth
+test.use({ storageState: { cookies: [], origins: [] } });
+
+test('can auth with admin key', { tag: '@auth' }, async ({ page }) => {
+  const settingsModal = page.getByRole('dialog', { name: 'Settings' });
+  const adminKeyInput = page.getByRole('textbox', { name: 'Admin Key' });
+  const failedMsg = page.getByText('failed to check token');
+
+  const checkSettingsModal = async () => {
+    await expect(failedMsg).toBeVisible();
+    await expect(settingsModal).toBeVisible();
+    await expect(page.getByText('UI Commit SHA')).toBeVisible();
+  };
+
+  await test.step('fill wrong admin key, settings modal always be visible', async () => {
+    await checkSettingsModal();
+
+    await expect(adminKeyInput).toBeEmpty();
+    await adminKeyInput.fill('wrong-admin-key');
+    await page
+      .getByRole('dialog', { name: 'Settings' })
+      .getByRole('button')
+      .click();
+
+    await page.reload();
+    await expect(failedMsg).toBeVisible();
+    await expect(settingsModal).toBeVisible();
+  });
+
+  await test.step('fill correct admin key, settings modal can be hidden', async () => {
+    await page.reload();
+    await checkSettingsModal();
+
+    const { adminKey } = await getAPISIXConf();
+    await adminKeyInput.clear();
+    await adminKeyInput.fill(adminKey);
+    await page
+      .getByRole('dialog', { name: 'Settings' })
+      .getByRole('button')
+      .click();
+
+    await page.reload();
+    await expect(failedMsg).toBeHidden();
+  });
+});
diff --git a/.devcontainer/apisix_conf.yml b/e2e/server/Dockerfile
similarity index 51%
copy from .devcontainer/apisix_conf.yml
copy to e2e/server/Dockerfile
index 94532cf57..3d675a471 100644
--- a/.devcontainer/apisix_conf.yml
+++ b/e2e/server/Dockerfile
@@ -15,28 +15,26 @@
 # limitations under the License.
 #
 
-apisix:
-  node_listen: 9080 # APISIX listening port
-  enable_ipv6: false
-  proxy_mode: http&stream
-  stream_proxy:
-    tcp:
-      - 9100
-    udp:
-      - 9200
+FROM node:22-alpine AS base
 
-deployment:
-  admin:
-    allow_admin: # https://nginx.org/en/docs/http/ngx_http_access_module.html#allow
-      - 0.0.0.0/0 # We need to restrict ip access rules for security. 0.0.0.0/0 is for test.
+# install deps
+FROM base AS deps
+WORKDIR /app
+COPY package.json pnpm-lock.yaml* ./
+RUN \
+  if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm install; \
+  else echo 'Lockfile not found.' && exit 1; \
+  fi
 
-    admin_key:
-      - name: "admin"
-        key: edd1c9f034335f136f87ad84b625c8f1
-        role: admin # admin: manage all configuration data
+# build ui
+FROM base AS builder
+WORKDIR /app
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+# we need git to get commit sha
+RUN apk add --no-cache git
+RUN corepack enable pnpm && pnpm build
 
-  etcd:
-    host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster.
-      - "http://etcd:2379" # multiple etcd address
-    prefix: "/apisix" # apisix configurations prefix
-    timeout: 30 # 30 seconds
+# serve ui
+FROM nginx:1.28.0-alpine-slim AS runner
+COPY --from=builder /app/dist /usr/share/nginx/html
diff --git a/.devcontainer/apisix_conf.yml b/e2e/server/apisix_conf.yml
similarity index 100%
copy from .devcontainer/apisix_conf.yml
copy to e2e/server/apisix_conf.yml
diff --git a/.devcontainer/apisix_conf.yml b/e2e/server/docker-compose.common.yml
similarity index 52%
copy from .devcontainer/apisix_conf.yml
copy to e2e/server/docker-compose.common.yml
index 94532cf57..d165416aa 100644
--- a/.devcontainer/apisix_conf.yml
+++ b/e2e/server/docker-compose.common.yml
@@ -15,28 +15,33 @@
 # limitations under the License.
 #
 
-apisix:
-  node_listen: 9080 # APISIX listening port
-  enable_ipv6: false
-  proxy_mode: http&stream
-  stream_proxy:
-    tcp:
-      - 9100
-    udp:
-      - 9200
+services:
+  apisix:
+    image: 'apache/apisix:3.12.0-debian'
+    restart: always
+    volumes:
+      - ./apisix_conf.yml:/usr/local/apisix/conf/config.yaml:ro
+    depends_on:
+      - etcd
+    networks:
+      - apisix
 
-deployment:
-  admin:
-    allow_admin: # https://nginx.org/en/docs/http/ngx_http_access_module.html#allow
-      - 0.0.0.0/0 # We need to restrict ip access rules for security. 0.0.0.0/0 is for test.
+  etcd:
+    image: bitnami/etcd:3.5
+    restart: always
+    volumes:
+      - etcd_data:/bitnami/etcd
+    environment:
+      ETCD_ENABLE_V2: 'true'
+      ALLOW_NONE_AUTHENTICATION: 'yes'
+      ETCD_ADVERTISE_CLIENT_URLS: 'http://etcd:2379'
+      ETCD_LISTEN_CLIENT_URLS: 'http://0.0.0.0:2379'
+    networks:
+      - apisix
 
-    admin_key:
-      - name: "admin"
-        key: edd1c9f034335f136f87ad84b625c8f1
-        role: admin # admin: manage all configuration data
+networks:
+  apisix:
+    driver: bridge
 
-  etcd:
-    host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster.
-      - "http://etcd:2379" # multiple etcd address
-    prefix: "/apisix" # apisix configurations prefix
-    timeout: 30 # 30 seconds
+volumes:
+  etcd_data:
diff --git a/.devcontainer/apisix_conf.yml b/e2e/server/docker-compose.yml
similarity index 51%
copy from .devcontainer/apisix_conf.yml
copy to e2e/server/docker-compose.yml
index 94532cf57..0e558a548 100644
--- a/.devcontainer/apisix_conf.yml
+++ b/e2e/server/docker-compose.yml
@@ -15,28 +15,32 @@
 # limitations under the License.
 #
 
-apisix:
-  node_listen: 9080 # APISIX listening port
-  enable_ipv6: false
-  proxy_mode: http&stream
-  stream_proxy:
-    tcp:
-      - 9100
-    udp:
-      - 9200
-
-deployment:
-  admin:
-    allow_admin: # https://nginx.org/en/docs/http/ngx_http_access_module.html#allow
-      - 0.0.0.0/0 # We need to restrict ip access rules for security. 0.0.0.0/0 is for test.
-
-    admin_key:
-      - name: "admin"
-        key: edd1c9f034335f136f87ad84b625c8f1
-        role: admin # admin: manage all configuration data
-
-  etcd:
-    host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster.
-      - "http://etcd:2379" # multiple etcd address
-    prefix: "/apisix" # apisix configurations prefix
-    timeout: 30 # 30 seconds
+include:
+  - docker-compose.common.yml
+services:
+  dashboard-e2e:
+    build:
+      context: ../..
+      dockerfile: e2e/server/Dockerfile
+    volumes:
+      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
+    networks:
+      - apisix
+    ports:
+      - '6174:6174'
+    depends_on:
+      - apisix
+    healthcheck:
+      test:
+        [
+          'CMD',
+          'wget',
+          '--quiet',
+          '--tries=1',
+          '--spider',
+          'http://127.0.0.1:6174/ui/',
+        ]
+      interval: 10s
+      timeout: 5s
+      retries: 5
+      start_period: 10s
diff --git a/.devcontainer/apisix_conf.yml b/e2e/server/nginx.conf
similarity index 51%
rename from .devcontainer/apisix_conf.yml
rename to e2e/server/nginx.conf
index 94532cf57..1fde87e09 100644
--- a/.devcontainer/apisix_conf.yml
+++ b/e2e/server/nginx.conf
@@ -15,28 +15,22 @@
 # limitations under the License.
 #
 
-apisix:
-  node_listen: 9080 # APISIX listening port
-  enable_ipv6: false
-  proxy_mode: http&stream
-  stream_proxy:
-    tcp:
-      - 9100
-    udp:
-      - 9200
+server {
+  listen 6174;
 
-deployment:
-  admin:
-    allow_admin: # https://nginx.org/en/docs/http/ngx_http_access_module.html#allow
-      - 0.0.0.0/0 # We need to restrict ip access rules for security. 0.0.0.0/0 is for test.
+  location /ui {
+      rewrite ^/ui$ /ui/ permanent;
+  }
 
-    admin_key:
-      - name: "admin"
-        key: edd1c9f034335f136f87ad84b625c8f1
-        role: admin # admin: manage all configuration data
+  location /ui/ {
+    alias /usr/share/nginx/html/;
+    index index.html;
+    try_files $uri $uri/ /ui/index.html;
+  }
 
-  etcd:
-    host: # it's possible to define multiple etcd hosts addresses of the same etcd cluster.
-      - "http://etcd:2379" # multiple etcd address
-    prefix: "/apisix" # apisix configurations prefix
-    timeout: 30 # 30 seconds
+  location / {
+    proxy_pass http://apisix:9180;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+  }
+}
diff --git a/src/components/form/Btn.tsx b/e2e/utils/common.ts
similarity index 60%
copy from src/components/form/Btn.tsx
copy to e2e/utils/common.ts
index f429338fa..6646fc216 100644
--- a/src/components/form/Btn.tsx
+++ b/e2e/utils/common.ts
@@ -14,11 +14,18 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { Button, type ButtonProps } from '@mantine/core';
-import { useFormContext, useFormState } from 'react-hook-form';
+import { readFile } from 'node:fs/promises';
+import path from 'node:path';
 
-export const FormSubmitBtn = (props: ButtonProps) => {
-  const form = useFormContext();
-  const { isSubmitting } = useFormState(form);
-  return <Button type="submit" loading={isSubmitting} {...props} />;
+import { parse } from 'yaml';
+
+type APISIXConf = {
+  deployment: { admin: { admin_key: { key: string }[] } };
+};
+export const getAPISIXConf = async () => {
+  const currentDir = new URL('.', import.meta.url).pathname;
+  const confPath = path.join(currentDir, '../server/apisix_conf.yml');
+  const file = await readFile(confPath, 'utf-8');
+  const res = parse(file) as APISIXConf;
+  return { adminKey: res.deployment.admin.admin_key[0].key };
 };
diff --git a/src/components/form/Btn.tsx b/e2e/utils/env.ts
similarity index 60%
copy from src/components/form/Btn.tsx
copy to e2e/utils/env.ts
index f429338fa..58b6fc6f5 100644
--- a/src/components/form/Btn.tsx
+++ b/e2e/utils/env.ts
@@ -14,11 +14,22 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { Button, type ButtonProps } from '@mantine/core';
-import { useFormContext, useFormState } from 'react-hook-form';
+import { config } from 'dotenv';
+import { parseEnv } from 'znv';
+import { z } from 'zod';
 
-export const FormSubmitBtn = (props: ButtonProps) => {
-  const form = useFormContext();
-  const { isSubmitting } = useFormState(form);
-  return <Button type="submit" loading={isSubmitting} {...props} />;
-};
+import { BASE_PATH } from '../../src/config/constant';
+
+config({
+  path: ['./.env', './.env.local', './.env.development.local'],
+});
+
+export const env = parseEnv(process.env, {
+  E2E_TARGET_URL: z
+    .string()
+    .url()
+    .default(`http://localhost:6174${BASE_PATH}/`)
+    .describe(
+      `If you want to access the test server from dev container playwright to host e2e server, try http://host.docker.internal:6174${BASE_PATH}/`
+    ),
+});
diff --git a/e2e/utils/test.ts b/e2e/utils/test.ts
new file mode 100644
index 000000000..9f0ef0bce
--- /dev/null
+++ b/e2e/utils/test.ts
@@ -0,0 +1,70 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { test as baseTest } from '@playwright/test';
+import { getAPISIXConf } from './common';
+import path from 'node:path';
+import { existsSync } from 'node:fs';
+import { env } from './env';
+
+export const test = baseTest.extend<{}, { workerStorageState: string }>({
+  storageState: ({ workerStorageState }, use) => use(workerStorageState),
+  workerStorageState: [
+    async ({ browser }, use) => {
+      // Use parallelIndex as a unique identifier for each worker.
+      const id = test.info().parallelIndex;
+      const fileName = path.resolve(
+        test.info().project.outputDir,
+        `.auth/${id}.json`
+      );
+
+      if (existsSync(fileName)) {
+        return await use(fileName);
+      }
+
+      const page = await browser.newPage({ storageState: undefined });
+
+      // have to use env here, because the baseURL is not available in worker
+      await page.goto(env.E2E_TARGET_URL);
+
+      // we need to authenticate
+      const settingsModal = page.getByRole('dialog', { name: 'Settings' });
+      if (await settingsModal.isVisible()) {
+        const { adminKey } = await getAPISIXConf();
+
+        const adminKeyInput = page.getByRole('textbox', { name: 'Admin Key' });
+        await adminKeyInput.clear();
+        await adminKeyInput.fill(adminKey);
+        await page
+          .getByRole('dialog', { name: 'Settings' })
+          .getByRole('button')
+          .click();
+
+        await page.reload();
+        await page.waitForLoadState('networkidle');
+      }
+
+      await page.context().storageState({ path: fileName });
+      await page.close();
+      await use(fileName);
+    },
+    { scope: 'worker' },
+  ],
+  page: async ({ baseURL, page }, use) => {
+    await page.goto(baseURL);
+    await use(page);
+  },
+});
diff --git a/eslint.config.js b/eslint.config.js
index 23f5d799b..caf018beb 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,19 +1,74 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
 import js from '@eslint/js'
-import globals from 'globals'
+import headers from "eslint-plugin-headers";
+import importPlugin from 'eslint-plugin-import';
+import playwright from 'eslint-plugin-playwright'
 import react from 'eslint-plugin-react'
 import reactHooks from 'eslint-plugin-react-hooks'
 import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
-import unusedImports from 'eslint-plugin-unused-imports'
 import simpleImportSort from 'eslint-plugin-simple-import-sort'
-import importPlugin from 'eslint-plugin-import';
-import headers from "eslint-plugin-headers";
+import unusedImports from 'eslint-plugin-unused-imports'
+import globals from 'globals'
+import tseslint from 'typescript-eslint'
+
+const importRules = tseslint.config({
+  plugins: {
+    "unused-imports": unusedImports,
+    "simple-import-sort": simpleImportSort,
+    "import": importPlugin,
+  },
+  rules: {
+    "no-unused-vars": "off",
+    "unused-imports/no-unused-imports": "error",
+    "unused-imports/no-unused-vars": [
+      "warn",
+      {
+        "vars": "all",
+        "varsIgnorePattern": "^_",
+        "args": "after-used",
+        "argsIgnorePattern": "^_",
+      },
+    ],
+    "simple-import-sort/imports": "error",
+    "simple-import-sort/exports": "error",
+    "import/first": "error",
+    "import/newline-after-import": "error",
+    "import/no-duplicates": "error",
+    "import/no-unresolved": 'off',
+    "import/no-named-as-default-member": 'off',
+  }
+})
+
+const e2eRules = tseslint.config({
+  extends: [importRules],
+    ...playwright.configs['flat/recommended'],
+    files: ['e2e/**/*.spec.ts'],
+    rules: {
+      ...playwright.configs['flat/recommended'].rules,
+    },
+});
 
 export default tseslint.config(
   { ignores: ['dist', 'src/routeTree.gen.ts'] },
+  e2eRules,
   {
-    extends: [js.configs.recommended, ...tseslint.configs.recommended],
-    files: ['**/*.{ts,tsx}'],
+    extends: [js.configs.recommended, ...tseslint.configs.recommended, importRules],
+    files: ['src/**/*.{ts,tsx}', 'eslint.config.js'],
     languageOptions: {
       ecmaVersion: 2020,
       globals: globals.browser,
diff --git a/package.json b/package.json
index 573c43354..5cc86b491 100644
--- a/package.json
+++ b/package.json
@@ -6,9 +6,11 @@
   "scripts": {
     "dev": "vite",
     "build": "tsc -b && vite build",
-    "lint": "eslint .",
+    "lint": "eslint . --cache --max-warnings=0 --no-warn-ignored",
+    "lint:fix": "pnpm lint --fix",
     "preview": "vite preview",
-    "prepare": "husky"
+    "prepare": "husky",
+    "e2e": "playwright test"
   },
   "dependencies": {
     "@ant-design/pro-components": "^2.8.7",
@@ -25,6 +27,7 @@
     "axios": "^1.8.4",
     "clsx": "^2.1.1",
     "dayjs": "^1.11.13",
+    "dotenv": "^16.5.0",
     "fast-clean": "^1.4.0",
     "i18next": "^25.0.1",
     "immer": "^10.1.1",
@@ -41,13 +44,16 @@
     "react-hook-form": "^7.56.1",
     "react-i18next": "^15.4.1",
     "react-use": "^17.6.0",
+    "znv": "^0.5.0",
     "zod": "^3.24.3",
     "zod-empty": "^1.4.4"
   },
   "devDependencies": {
     "@eslint/js": "^9.22.0",
+    "@estruyf/github-actions-reporter": "^1.10.0",
     "@hookform/devtools": "^4.4.0",
     "@iconify-json/material-symbols": "^1.2.20",
+    "@playwright/test": "^1.52.0",
     "@svgr/core": "^8.1.0",
     "@svgr/plugin-jsx": "^8.1.0",
     "@tanstack/react-query-devtools": "^5.74.4",
@@ -61,6 +67,7 @@
     "eslint": "^9.22.0",
     "eslint-plugin-headers": "^1.2.1",
     "eslint-plugin-import": "^2.31.0",
+    "eslint-plugin-playwright": "^2.2.0",
     "eslint-plugin-react": "^7.37.5",
     "eslint-plugin-react-hooks": "^5.2.0",
     "eslint-plugin-react-refresh": "^0.4.19",
@@ -76,11 +83,12 @@
     "unplugin-icons": "^22.1.0",
     "unplugin-info": "^1.2.2",
     "vite": "^6.3.5",
-    "vite-tsconfig-paths": "^5.1.4"
+    "vite-tsconfig-paths": "^5.1.4",
+    "yaml": "^2.7.1"
   },
   "lint-staged": {
     "*.{js,jsx,ts,tsx}": [
-      "eslint --fix --max-warnings=0 --no-warn-ignored"
+      "pnpm lint:fix"
     ]
   },
   "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 000000000..f517f239b
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,60 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { defineConfig, devices } from '@playwright/test';
+
+import { env } from './e2e/utils/env';
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+  tsconfig: './tsconfig.e2e.json',
+  testDir: './e2e',
+  outputDir: './test-results',
+  fullyParallel: true,
+  forbidOnly: !!process.env.CI,
+  retries: process.env.CI ? 2 : 0,
+  workers: process.env.CI ? 1 : undefined,
+  reporter: [
+    ['html'],
+    ['list'],
+    [
+      '@estruyf/github-actions-reporter',
+      {
+        useDetails: true,
+        showError: true,
+      },
+    ],
+  ],
+  use: {
+    baseURL: env.E2E_TARGET_URL,
+    trace: 'on-first-retry',
+  },
+
+  projects: [
+    {
+      name: 'chromium',
+      use: {
+        ...devices['Desktop Chrome'],
+        viewport: { width: 1920, height: 1080 },
+        permissions: ['clipboard-read'],
+        // use chrome
+        // channel: "chrome",
+      },
+    },
+  ],
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 908a984ec..2b7effbc0 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -50,6 +50,9 @@ importers:
       dayjs:
         specifier: ^1.11.13
         version: 1.11.13
+      dotenv:
+        specifier: ^16.5.0
+        version: 16.5.0
       fast-clean:
         specifier: ^1.4.0
         version: 1.4.0
@@ -98,6 +101,9 @@ importers:
       react-use:
         specifier: ^17.6.0
         version: 17.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      znv:
+        specifier: ^0.5.0
+        version: 0.5.0(zod@3.24.3)
       zod:
         specifier: ^3.24.3
         version: 3.24.3
@@ -108,12 +114,18 @@ importers:
       '@eslint/js':
         specifier: ^9.22.0
         version: 9.25.1
+      '@estruyf/github-actions-reporter':
+        specifier: ^1.10.0
+        version: 1.10.0(@playwright/test@1.52.0)
       '@hookform/devtools':
         specifier: ^4.4.0
         version: 4.4.0(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
       '@iconify-json/material-symbols':
         specifier: ^1.2.20
         version: 1.2.20
+      '@playwright/test':
+        specifier: ^1.52.0
+        version: 1.52.0
       '@svgr/core':
         specifier: ^8.1.0
         version: 8.1.0(typescript@5.8.3)
@@ -153,6 +165,9 @@ importers:
       eslint-plugin-import:
         specifier: ^2.31.0
         version: 2.31.0(@typescript-eslint/parser@8.32.0(eslint@9.25.1)(typescript@5.8.3))(eslint@9.25.1)
+      eslint-plugin-playwright:
+        specifier: ^2.2.0
+        version: 2.2.0(eslint@9.25.1)
       eslint-plugin-react:
         specifier: ^7.37.5
         version: 7.37.5(eslint@9.25.1)
@@ -201,9 +216,24 @@ importers:
       vite-tsconfig-paths:
         specifier: ^5.1.4
         version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.14.1)(sugarss@4.0.1(postcss@8.5.3))(tsx@4.19.3)(yaml@2.7.1))
+      yaml:
+        specifier: ^2.7.1
+        version: 2.7.1
 
 packages:
 
+  '@actions/core@1.11.1':
+    resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==}
+
+  '@actions/exec@1.1.1':
+    resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==}
+
+  '@actions/http-client@2.2.3':
+    resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==}
+
+  '@actions/io@1.1.3':
+    resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==}
+
   '@ampproject/remapping@2.3.0':
     resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
     engines: {node: '>=6.0.0'}
@@ -710,6 +740,15 @@ packages:
     resolution: {integrity: sha512-ZAoA40rNMPwSm+AeHpCq8STiNAwzWLJuP8Xv4CHIc9wv/PSuExjMrmjfYNj682vW0OOiZ1HKxzvjQr9XZIisQA==}
     engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
 
+  '@estruyf/github-actions-reporter@1.10.0':
+    resolution: {integrity: sha512-u54QKI8TgUqkhNpOjyNh0hFktFSQhiQH1hs9o8jJ/FwCTlJeNCryzknzP9+B1R/jeg0cXY9QuwLcJNZCBc+Ajw==}
+    peerDependencies:
+      '@playwright/test': ^1.42.1
+
+  '@fastify/busboy@2.1.1':
+    resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
+    engines: {node: '>=14'}
+
   '@floating-ui/core@1.7.0':
     resolution: {integrity: sha512-FRdBLykrPPA6P76GGGqlex/e7fbe0F1ykgxHYNXQsH/iTEtjMj/f9bpY5oQqbjt5VgZvgz/uKXbGuROijh3VLA==}
 
@@ -850,6 +889,11 @@ packages:
     resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==}
     engines: {node: '>= 8'}
 
+  '@playwright/test@1.52.0':
+    resolution: {integrity: sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   '@rc-component/async-validator@5.0.4':
     resolution: {integrity: sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==}
     engines: {node: '>=14.x'}
@@ -1395,6 +1439,11 @@ packages:
     resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
     engines: {node: '>=12'}
 
+  ansi-to-html@0.7.2:
+    resolution: {integrity: sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==}
+    engines: {node: '>=8.0.0'}
+    hasBin: true
+
   ansis@3.17.0:
     resolution: {integrity: sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==}
     engines: {node: '>=14'}
@@ -1687,6 +1736,10 @@ packages:
   dot-case@3.0.4:
     resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==}
 
+  dotenv@16.5.0:
+    resolution: {integrity: sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==}
+    engines: {node: '>=12'}
+
   dunder-proto@1.0.1:
     resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
     engines: {node: '>= 0.4'}
@@ -1697,6 +1750,9 @@ packages:
   emoji-regex@10.4.0:
     resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
 
+  entities@2.2.0:
+    resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==}
+
   entities@4.5.0:
     resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
     engines: {node: '>=0.12'}
@@ -1796,6 +1852,12 @@ packages:
       '@typescript-eslint/parser':
         optional: true
 
+  eslint-plugin-playwright@2.2.0:
+    resolution: {integrity: sha512-qSQpAw7RcSzE3zPp8FMGkthaCWovHZ/BsXtpmnGax9vQLIovlh1bsZHEa2+j2lv9DWhnyeLM/qZmp7ffQZfQvg==}
+    engines: {node: '>=16.6.0'}
+    peerDependencies:
+      eslint: '>=8.40.0'
+
   eslint-plugin-react-hooks@5.2.0:
     resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
     engines: {node: '>=10'}
@@ -1951,6 +2013,11 @@ packages:
     resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
     engines: {node: '>= 6'}
 
+  fsevents@2.3.2:
+    resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
   fsevents@2.3.3:
     resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
     engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -2015,6 +2082,10 @@ packages:
     resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==}
     engines: {node: '>=4'}
 
+  globals@13.24.0:
+    resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==}
+    engines: {node: '>=8'}
+
   globals@14.0.0:
     resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
     engines: {node: '>=18'}
@@ -2357,6 +2428,11 @@ packages:
   lru-cache@5.1.1:
     resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
 
+  marked@12.0.2:
+    resolution: {integrity: sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==}
+    engines: {node: '>= 18'}
+    hasBin: true
+
   math-intrinsics@1.1.0:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
@@ -2593,6 +2669,16 @@ packages:
   pkg-types@2.1.0:
     resolution: {integrity: sha512-wmJwA+8ihJixSoHKxZJRBQG1oY8Yr9pGLzRmSsNms0iNWyHHAlZCa7mmKiFR10YPZuz/2k169JiS/inOjBCZ2A==}
 
+  playwright-core@1.52.0:
+    resolution: {integrity: sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  playwright@1.52.0:
+    resolution: {integrity: sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
   possible-typed-array-names@1.1.0:
     resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
     engines: {node: '>= 0.4'}
@@ -3350,10 +3436,18 @@ packages:
     engines: {node: '>=18.0.0'}
     hasBin: true
 
+  tunnel@0.0.6:
+    resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
+    engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
+
   type-check@0.4.0:
     resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
     engines: {node: '>= 0.8.0'}
 
+  type-fest@0.20.2:
+    resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==}
+    engines: {node: '>=10'}
+
   type-fest@4.41.0:
     resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==}
     engines: {node: '>=16'}
@@ -3396,6 +3490,10 @@ packages:
   undici-types@6.21.0:
     resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
 
+  undici@5.29.0:
+    resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
+    engines: {node: '>=14.0'}
+
   unplugin-icons@22.1.0:
     resolution: {integrity: sha512-ect2ZNtk1Zgwb0NVHd0C1IDW/MV+Jk/xaq4t8o6rYdVS3+L660ZdD5kTSQZvsgdwCvquRw+/wYn75hsweRjoIA==}
     peerDependencies:
@@ -3623,6 +3721,11 @@ packages:
     resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
     engines: {node: '>=10'}
 
+  znv@0.5.0:
+    resolution: {integrity: sha512-o4s0CemQU7+ByxBQQnqe72QtlVDsw1GAPxBbTCbQP3sNe7/5bztRJ8fUf2dbOuhZrMapWe82hpOTBZ7QVnjZ2g==}
+    peerDependencies:
+      zod: ^3.24.2
+
   zod-empty@1.4.4:
     resolution: {integrity: sha512-TXHS6P/HoFsF4pft4d/6LvQ2meCkgMrAtdG7Hz9RzGt3qBjTCWK+A616MmJBSxzP3mCp8EXxyMIyA1vVzyEJgA==}
     engines: {node: '>=20.10.0', npm: '>=8.1.0'}
@@ -3635,6 +3738,22 @@ packages:
 
 snapshots:
 
+  '@actions/core@1.11.1':
+    dependencies:
+      '@actions/exec': 1.1.1
+      '@actions/http-client': 2.2.3
+
+  '@actions/exec@1.1.1':
+    dependencies:
+      '@actions/io': 1.1.3
+
+  '@actions/http-client@2.2.3':
+    dependencies:
+      tunnel: 0.0.6
+      undici: 5.29.0
+
+  '@actions/io@1.1.3': {}
+
   '@ampproject/remapping@2.3.0':
     dependencies:
       '@jridgewell/gen-mapping': 0.3.8
@@ -4257,6 +4376,15 @@ snapshots:
       '@eslint/core': 0.13.0
       levn: 0.4.1
 
+  '@estruyf/github-actions-reporter@1.10.0(@playwright/test@1.52.0)':
+    dependencies:
+      '@actions/core': 1.11.1
+      '@playwright/test': 1.52.0
+      ansi-to-html: 0.7.2
+      marked: 12.0.2
+
+  '@fastify/busboy@2.1.1': {}
+
   '@floating-ui/core@1.7.0':
     dependencies:
       '@floating-ui/utils': 0.2.9
@@ -4421,6 +4549,10 @@ snapshots:
       '@nodelib/fs.scandir': 2.1.5
       fastq: 1.19.1
 
+  '@playwright/test@1.52.0':
+    dependencies:
+      playwright: 1.52.0
+
   '@rc-component/async-validator@5.0.4':
     dependencies:
       '@babel/runtime': 7.27.0
@@ -4956,6 +5088,10 @@ snapshots:
 
   ansi-styles@6.2.1: {}
 
+  ansi-to-html@0.7.2:
+    dependencies:
+      entities: 2.2.0
+
   ansis@3.17.0: {}
 
   antd@5.24.8(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
@@ -5340,6 +5476,8 @@ snapshots:
       no-case: 3.0.4
       tslib: 2.8.1
 
+  dotenv@16.5.0: {}
+
   dunder-proto@1.0.1:
     dependencies:
       call-bind-apply-helpers: 1.0.2
@@ -5350,6 +5488,8 @@ snapshots:
 
   emoji-regex@10.4.0: {}
 
+  entities@2.2.0: {}
+
   entities@4.5.0: {}
 
   environment@1.1.0: {}
@@ -5543,6 +5683,11 @@ snapshots:
       - eslint-import-resolver-webpack
       - supports-color
 
+  eslint-plugin-playwright@2.2.0(eslint@9.25.1):
+    dependencies:
+      eslint: 9.25.1
+      globals: 13.24.0
+
   eslint-plugin-react-hooks@5.2.0(eslint@9.25.1):
     dependencies:
       eslint: 9.25.1
@@ -5729,6 +5874,9 @@ snapshots:
       es-set-tostringtag: 2.1.0
       mime-types: 2.1.35
 
+  fsevents@2.3.2:
+    optional: true
+
   fsevents@2.3.3:
     optional: true
 
@@ -5800,6 +5948,10 @@ snapshots:
 
   globals@11.12.0: {}
 
+  globals@13.24.0:
+    dependencies:
+      type-fest: 0.20.2
+
   globals@14.0.0: {}
 
   globals@15.15.0: {}
@@ -6136,6 +6288,8 @@ snapshots:
     dependencies:
       yallist: 3.1.1
 
+  marked@12.0.2: {}
+
   math-intrinsics@1.1.0: {}
 
   mdn-data@2.0.14: {}
@@ -6373,6 +6527,14 @@ snapshots:
       exsolve: 1.0.5
       pathe: 2.0.3
 
+  playwright-core@1.52.0: {}
+
+  playwright@1.52.0:
+    dependencies:
+      playwright-core: 1.52.0
+    optionalDependencies:
+      fsevents: 2.3.2
+
   possible-typed-array-names@1.1.0: {}
 
   postcss-js@4.0.1(postcss@8.5.3):
@@ -7268,10 +7430,14 @@ snapshots:
     optionalDependencies:
       fsevents: 2.3.3
 
+  tunnel@0.0.6: {}
+
   type-check@0.4.0:
     dependencies:
       prelude-ls: 1.2.1
 
+  type-fest@0.20.2: {}
+
   type-fest@4.41.0: {}
 
   typed-array-buffer@1.0.3:
@@ -7330,6 +7496,10 @@ snapshots:
 
   undici-types@6.21.0: {}
 
+  undici@5.29.0:
+    dependencies:
+      '@fastify/busboy': 2.1.1
+
   unplugin-icons@22.1.0(@svgr/core@8.1.0(typescript@5.8.3)):
     dependencies:
       '@antfu/install-pkg': 1.0.0
@@ -7514,6 +7684,10 @@ snapshots:
 
   yocto-queue@0.1.0: {}
 
+  znv@0.5.0(zod@3.24.3):
+    dependencies:
+      zod: 3.24.3
+
   zod-empty@1.4.4(typescript@5.8.3)(zod@3.24.3):
     dependencies:
       just-clone: 6.2.0
diff --git a/src/components/form/Btn.tsx b/src/components/form/Btn.tsx
index f429338fa..ac25ab41b 100644
--- a/src/components/form/Btn.tsx
+++ b/src/components/form/Btn.tsx
@@ -14,10 +14,16 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-import { Button, type ButtonProps } from '@mantine/core';
+import {
+  Button,
+  type ButtonProps,
+  type PolymorphicComponentProps,
+} from '@mantine/core';
 import { useFormContext, useFormState } from 'react-hook-form';
 
-export const FormSubmitBtn = (props: ButtonProps) => {
+export const FormSubmitBtn = (
+  props: PolymorphicComponentProps<'button', ButtonProps>
+) => {
   const form = useFormContext();
   const { isSubmitting } = useFormState(form);
   return <Button type="submit" loading={isSubmitting} {...props} />;
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 209dd64ed..3614a202f 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -31,5 +31,8 @@
       "unplugin-icons/types/react",
     ]
   },
-  "include": ["src"]
+  "include": [
+    "src",
+    "e2e/utils/env.ts"
+  ]
 }
diff --git a/tsconfig.e2e.json b/tsconfig.e2e.json
new file mode 100644
index 000000000..beb40ddac
--- /dev/null
+++ b/tsconfig.e2e.json
@@ -0,0 +1,27 @@
+{
+  "compilerOptions": {
+    "lib": [
+      "es2021",
+      "dom"
+    ],
+    "allowJs": true,
+    "sourceMap": false,
+    "target": "ESNext",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "baseUrl": ".",
+    "allowSyntheticDefaultImports": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "noImplicitAny": false,
+    "paths": {
+      "@utils/*": [
+        "./e2e/utils/*"
+      ]
+    }
+  },
+  "include": [
+    "e2e/**/*.ts",
+  ]
+}
diff --git a/tsconfig.json b/tsconfig.json
index 1ffef600d..7827100ee 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -2,6 +2,11 @@
   "files": [],
   "references": [
     { "path": "./tsconfig.app.json" },
-    { "path": "./tsconfig.node.json" }
+    {
+      "path": "./tsconfig.node.json"
+    },
+    {
+      "path": "./tsconfig.e2e.json"
+    }
   ]
 }