diff --git a/.dockerignore b/.dockerignore index 843f12a7be..02eb328c13 100644 --- a/.dockerignore +++ b/.dockerignore @@ -60,6 +60,7 @@ cpu.out /tests/e2e/reports /tests/e2e/test-artifacts /tests/e2e/test-snapshots +/tests/e2e/test-data /tests/*.ini /node_modules /yarn.lock diff --git a/.gitignore b/.gitignore index 0791a17c71..87323b6756 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ cpu.out /tests/e2e/gitea-e2e-* /tests/e2e/indexers-* /tests/e2e/reports +/tests/e2e/test-data /tests/e2e/test-artifacts /tests/e2e/test-snapshots /tests/*.ini diff --git a/Makefile b/Makefile index 33bc5fdfc3..eca2d151f2 100644 --- a/Makefile +++ b/Makefile @@ -939,11 +939,23 @@ devstar: echo "Successfully build devstar.cn/devstar/devstar-runtime-container:latest"; \ fi docker build -t devstar-studio:latest -f docker/Dockerfile.devstar . - .PHONY: docker docker: docker build --disable-content-trust=false -t $(DOCKER_REF) . -# support also build args docker build --build-arg GITEA_VERSION=v1.2.3 --build-arg TAGS="bindata sqlite sqlite_unlock_notify" . +# support also build args docker build --build-arg GITEA_VERSION=v1.2.3 --build-arg TAGS="bindata sqlite sqlite_unlock_notify" + + +ifeq ($(TARGET_URL),) + E2E_DEPS = devstar +else + E2E_DEPS = +endif + +.PHONY: e2e-test +e2e-test: $(E2E_DEPS) + @echo "正在启动E2E-TEST..." + @TARGET_URL=$(TARGET_URL) E2E_USERNAME=$(E2E_USERNAME) E2E_PASSWORD=$(E2E_PASSWORD) ./tests/e2e/run-e2e-tests.sh + # This endif closes the if at the top of the file endif diff --git a/playwright.config.ts b/playwright.config.ts index d1cd299e25..8f3b1c0392 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,98 +1,52 @@ -import {devices} from '@playwright/test'; -import {env} from 'node:process'; -import type {PlaywrightTestConfig} from '@playwright/test'; +import { devices } from '@playwright/test'; +import { env } from 'node:process'; +import type { PlaywrightTestConfig } from '@playwright/test'; -const BASE_URL = env.GITEA_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000'; +const BASE_URL = env.DEVSTAR_URL?.replace?.(/\/$/g, '') || 'http://localhost:3000'; -export default { - testDir: './tests/e2e/', - testMatch: /.*\.test\.e2e\.ts/, // Match any .test.e2e.ts files - - /* Maximum time one test can run for. */ - timeout: 30 * 1000, +const config: PlaywrightTestConfig = { + testDir: './specs', + testMatch: /specs\/.*\.test\.ts/, + timeout: 500000, expect: { - - /** - * Maximum time expect() should wait for the condition to be met. - * For example in `await expect(locator).toHaveText();` - */ - timeout: 2000, + timeout: 15000, }, - - /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: Boolean(env.CI), - - /* Retry on CI only */ retries: env.CI ? 2 : 0, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: env.CI ? 'list' : [['list'], ['html', {outputFolder: 'tests/e2e/reports/', open: 'never'}]], + reporter: env.CI ? 'list' : [['list'], ['html', { + outputFolder: 'playwright-report/html', + open: 'never' + }]], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - headless: true, // set to false to debug - + headless: true, locale: 'en-US', - - /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ - actionTimeout: 1000, - - /* Maximum time allowed for navigation, such as `page.goto()`. */ - navigationTimeout: 5 * 1000, - - /* Base URL to use in actions like `await page.goto('/')`. */ - baseURL: BASE_URL, - - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + actionTimeout: 15000, + navigationTimeout: 15000, + baseURL: BASE_URL, trace: 'on-first-retry', - screenshot: 'only-on-failure', }, - - /* Configure projects for major browsers */ projects: [ { name: 'chromium', - - /* Project-specific settings. */ use: { ...devices['Desktop Chrome'], }, }, - - // disabled because of https://github.com/go-gitea/gitea/issues/21355 - // { - // name: 'firefox', - // use: { - // ...devices['Desktop Firefox'], - // }, - // }, - - { - name: 'webkit', - use: { - ...devices['Desktop Safari'], - }, - }, - - /* Test against mobile viewports. */ - { - name: 'Mobile Chrome', - use: { - ...devices['Pixel 5'], - }, - }, - { - name: 'Mobile Safari', - use: { - ...devices['iPhone 12'], - }, - }, ], + outputDir: 'playwright-report/test-artifacts/', + snapshotDir: 'playwright-report/test-snapshots/', +}; - /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - outputDir: 'tests/e2e/test-artifacts/', - /* Folder for test artifacts such as screenshots, videos, traces, etc. */ - snapshotDir: 'tests/e2e/test-snapshots/', -} satisfies PlaywrightTestConfig; +const skipInstall = env.E2E_SKIP_INSTALL === 'true'; + +if (skipInstall) { + console.log(`已跳过 global-setup.`); +} else { + console.log(`[Playwright Config] 探测结果: E2E_SKIP_INSTALL is not 'true'. 已启用 global-setup.`); + config.globalSetup = require.resolve('./global-setup.ts'); +} +export default config satisfies PlaywrightTestConfig; diff --git a/tests/e2e/.dockerignore b/tests/e2e/.dockerignore new file mode 100644 index 0000000000..f0d0cf7e83 --- /dev/null +++ b/tests/e2e/.dockerignore @@ -0,0 +1,11 @@ +# 1. 忽略 Node.js 依赖目录 +/node_modules +# 2. 忽略本地的测试报告和结果 +/reports +/test-data +/test-artifacts +/Readme.md +/utils_e2e.ts +/utils_e2e_test.go +/e2e_test.go + diff --git a/tests/e2e/Dockerfile b/tests/e2e/Dockerfile new file mode 100644 index 0000000000..681f919230 --- /dev/null +++ b/tests/e2e/Dockerfile @@ -0,0 +1,23 @@ +# +# 这是 "test-runner" 服务的 Dockerfile +# + +# 1. 使用微软官方的 Playwright 镜像 +# 它已经内置了所有浏览器 (Chromium, Firefox, WebKit) 和操作系统依赖 +FROM mcr.microsoft.com/playwright:v1.53.2-jammy + +# 2. 设置工作目录 +WORKDIR /app + +# 3. 复制 "依赖清单" 文件 +COPY package*.json ./ + +# 4. 安装npm 依赖 (即 @playwright/test) +RUN npm install + +# 5. 复制所有的测试代码到容器中 +# (包括 playwright.config.ts, global-setup.ts 和 specs/ 目录) +COPY . . + +# 6. 默认命令 +CMD ["npx", "playwright", "test"] diff --git a/tests/e2e/README.md b/tests/e2e/README.md index db083793d8..00dcd11ea8 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1,92 +1,20 @@ -# End to end tests - -E2e tests largely follow the same syntax as [integration tests](../integration). -Whereas integration tests are intended to mock and stress the back-end, server-side code, e2e tests the interface between front-end and back-end, as well as visual regressions with both assertions and visual comparisons. -They can be run with make commands for the appropriate backends, namely: -```shell -make test-sqlite -make test-pgsql -make test-mysql -make test-mssql -``` - -Make sure to perform a clean front-end build before running tests: -``` -make clean frontend -``` - -## Install playwright system dependencies -``` -npx playwright install-deps -``` - - -## Run all tests via local act_runner -``` -act_runner exec -W ./.github/workflows/pull-e2e-tests.yml --event=pull_request --default-actions-url="https://github.com" -i catthehacker/ubuntu:runner-latest -``` - -## Run sqlite e2e tests -Start tests -``` -make test-e2e-sqlite -``` - -## Run MySQL e2e tests -Setup a MySQL database inside docker -``` -docker run -e "MYSQL_DATABASE=test" -e "MYSQL_ALLOW_EMPTY_PASSWORD=yes" -p 3306:3306 --rm --name mysql mysql:latest #(just ctrl-c to stop db and clean the container) -docker run -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" --rm --name elasticsearch elasticsearch:7.6.0 #(in a second terminal, just ctrl-c to stop db and clean the container) -``` -Start tests based on the database container -``` -TEST_MYSQL_HOST=localhost:3306 TEST_MYSQL_DBNAME=test TEST_MYSQL_USERNAME=root TEST_MYSQL_PASSWORD='' make test-e2e-mysql -``` - -## Run pgsql e2e tests -Setup a pgsql database inside docker -``` -docker run -e "POSTGRES_DB=test" -p 5432:5432 --rm --name pgsql postgres:latest #(just ctrl-c to stop db and clean the container) -``` -Start tests based on the database container -``` -TEST_PGSQL_HOST=localhost:5432 TEST_PGSQL_DBNAME=test TEST_PGSQL_USERNAME=postgres TEST_PGSQL_PASSWORD=postgres make test-e2e-pgsql -``` - -## Run mssql e2e tests -Setup a mssql database inside docker -``` -docker run -e "ACCEPT_EULA=Y" -e "MSSQL_PID=Standard" -e "SA_PASSWORD=MwantsaSecurePassword1" -p 1433:1433 --rm --name mssql microsoft/mssql-server-linux:latest #(just ctrl-c to stop db and clean the container) -``` -Start tests based on the database container -``` -TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=gitea_test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-e2e-mssql -``` - -## Running individual tests - -Example command to run `example.test.e2e.ts` test file: - -_Note: unlike integration tests, this filtering is at the file level, not function_ - -For SQLite: +##E2E端到端测试流程 -``` -make test-e2e-sqlite#example ``` -For other databases(replace `mssql` to `mysql` or `pgsql`): +make devstar -``` -TEST_MSSQL_HOST=localhost:1433 TEST_MSSQL_DBNAME=test TEST_MSSQL_USERNAME=sa TEST_MSSQL_PASSWORD=MwantsaSecurePassword1 make test-e2e-mssql#example -``` +public/assets/install.sh start --image=devstar-studio:latest -## Visual testing +make e2e-test TARGET_URL="..." # 使用默认账号testuser 密码12345678 -Although the main goal of e2e is assertion testing, we have added a framework for visual regress testing. If you are working on front-end features, please use the following: - - Check out `main`, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1` to generate outputs. This will initially fail, as no screenshots exist. You can run the e2e tests again to assert it passes. - - Check out your branch, `make clean frontend`, and run e2e tests with `VISUAL_TEST=1`. You should be able to assert you front-end changes don't break any other tests unintentionally. +make e2e-test TARGET_URL="..." E2E_USERNAME="your_name" E2E_PASSWORD="your_password" # 使用你的账号和密码 +`` -VISUAL_TEST=1 will create screenshots in tests/e2e/test-snapshots. The test will fail the first time this is enabled (until we get visual test image persistence figured out), because it will be testing against an empty screenshot folder. -ACCEPT_VISUAL=1 will overwrite the snapshot images with new images. +##注意:url不可以是localhost,这样容器无法访问,也无法正常安装webterminal +##说明:目前有两种测试的用法, +1.主要的流程是通过make devstar 本地代码构建镜像,public/assets/install.sh start --image=devstar-studio:latest 通过install.sh脚本创建容器,并在make e2e-test TARGET_URL="..."中输入devstar容器的url,如果首次安装会执行安装脚本,如果已经安装过,请输入你的账号,密码,否则按默认账号和密码登录。 +2.单机CI使用make e2e-test,由脚本执行容器的创建,安装和测试,需要docker环境和项目代码。 + + diff --git a/tests/e2e/docker-compose.override.yml b/tests/e2e/docker-compose.override.yml new file mode 100644 index 0000000000..14e18b8289 --- /dev/null +++ b/tests/e2e/docker-compose.override.yml @@ -0,0 +1,5 @@ +services: + test-runner: + depends_on: + devstar: + condition: service_healthy diff --git a/tests/e2e/docker-compose.test.yml b/tests/e2e/docker-compose.test.yml new file mode 100644 index 0000000000..1b5e851483 --- /dev/null +++ b/tests/e2e/docker-compose.test.yml @@ -0,0 +1,43 @@ +version: '3.8' + +services: + # 服务一: DevStar + devstar: + #现在make devstar负责构建镜像 + image: devstar-studio:latest + pull_policy: never + ports: + - "80:3000" + - "2222:2222" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ./test-data/devstar_data:/var/lib/gitea + - ./test-data/devstar_data:/etc/gitea + # 健康检查。test-runner 会等待这个检查通过 + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"] + interval: 10s + timeout: 5s + retries: 30 + # 服务二: Playwright + test-runner: + # 从 'tests/' 目录下的 Dockerfile 构建 + build: + context: . + # 等待 devstar 的 "healthcheck" 通过后,才启动 + user: "${CURRENT_UID}:${CURRENT_GID}" + environment: + - DEVSTAR_URL=${DEVSTAR_URL} + - E2E_SKIP_INSTALL=${E2E_SKIP_INSTALL} + - E2E_USERNAME=${E2E_USERNAME} + - E2E_PASSWORD=${E2E_PASSWORD} + - E2E_MODE=${E2E_MODE} + volumes: + # 也挂载 Docker Socket + - /var/run/docker.sock:/var/run/docker.sock + - ../../playwright.config.ts:/app/playwright.config.ts + # 将测试报告写回到宿主机的 ./reports 目录 + - ./reports:/app/playwright-report + # 覆盖默认命令,强制运行测试并生成我们想要的报告 + command: > + npx playwright test diff --git a/tests/e2e/example.test.e2e.ts b/tests/e2e/example.test.e2e.ts deleted file mode 100644 index 1689f1b8ef..0000000000 --- a/tests/e2e/example.test.e2e.ts +++ /dev/null @@ -1,56 +0,0 @@ -import {test, expect} from '@playwright/test'; -import {login_user, save_visual, load_logged_in_context} from './utils_e2e.ts'; - -test.beforeAll(async ({browser}, workerInfo) => { - await login_user(browser, workerInfo, 'user2'); -}); - -test('homepage', async ({page}) => { - const response = await page.goto('/'); - expect(response?.status()).toBe(200); // Status OK - await expect(page).toHaveTitle(/^Gitea: Git with a cup of tea\s*$/); - await expect(page.locator('.logo')).toHaveAttribute('src', '/assets/img/logo.svg'); -}); - -test('register', async ({page}, workerInfo) => { - const response = await page.goto('/user/sign_up'); - expect(response?.status()).toBe(200); // Status OK - await page.locator('input[name=user_name]').fill(`e2e-test-${workerInfo.workerIndex}`); - await page.locator('input[name=email]').fill(`e2e-test-${workerInfo.workerIndex}@test.com`); - await page.locator('input[name=password]').fill('test123test123'); - await page.locator('input[name=retype]').fill('test123test123'); - await page.click('form button.ui.primary.button:visible'); - // Make sure we routed to the home page. Else login failed. - expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); - await expect(page.locator('.secondary-nav span>img.ui.avatar')).toBeVisible(); - await expect(page.locator('.ui.positive.message.flash-success')).toHaveText('Account was successfully created. Welcome!'); - - save_visual(page); -}); - -test('login', async ({page}, workerInfo) => { - const response = await page.goto('/user/login'); - expect(response?.status()).toBe(200); // Status OK - - await page.locator('input[name=user_name]').fill(`user2`); - await page.locator('input[name=password]').fill(`password`); - await page.click('form button.ui.primary.button:visible'); - - await page.waitForLoadState('networkidle'); // eslint-disable-line playwright/no-networkidle - - expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); - - save_visual(page); -}); - -test('logged in user', async ({browser}, workerInfo) => { - const context = await load_logged_in_context(browser, workerInfo, 'user2'); - const page = await context.newPage(); - - await page.goto('/'); - - // Make sure we routed to the home page. Else login failed. - expect(page.url()).toBe(`${workerInfo.project.use.baseURL}/`); - - save_visual(page); -}); diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000000..e144b1d154 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -0,0 +1,68 @@ + +import { chromium, type FullConfig } from '@playwright/test'; +import { env } from 'node:process'; +import { URL } from 'url'; + +async function globalSetup(config: FullConfig) { + const mode = env.E2E_MODE; + const {baseURL} = config.projects[0].use; + const isInstalledMode=env.E2E_SKIP_INSTALL; + const DEFAULT_E2E_USER = 'testuser'; + const DEFAULT_E2E_PASS = '12345678'; + if (!baseURL) { + throw new Error('[GlobalSetup] 致命错误: baseURL 或 storageState 未定义!'); + } + const browser = await chromium.launch(); + const page = await browser.newPage(); + if (mode === 'url') { + try { + const url1=env.DEVSTAR_URL; + await page.goto(url1, { timeout: 15000 }); + console.log('[GlobalSetup] 检测到安装界面!正在开始自动化安装...'); + await page.getByText('Server and Third-Party Service Settings').click(); + await page.getByRole('checkbox', { name: 'Enable user sign-in via Wechat QR Code.' }).uncheck(); + await page.getByRole('checkbox', { name: 'Require a CAPTCHA for user' }).uncheck(); + await page.getByText('Administrator Account Settings').click(); + await page.getByRole('textbox', { name: 'Administrator Username' }).fill('testuser'); + await page.getByRole('textbox', { name: 'Email Address' }).fill('ilovcatlyn750314@gmail.com'); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill('12345678'); + await page.getByRole('textbox', { name: 'Confirm Password' }).fill('12345678'); + await page.getByRole('button', { name: 'Install Gitea'}).click(); + await page.waitForTimeout(90000); + } catch (error) { + console.error('[GlobalSetup] "URL 模式" 登录失败:', error); + await page.screenshot({ path: 'playwright-report/global-setup-login-failure.png' }); + throw error; + } + } else if (mode === 'compose') { + console.log('[GlobalSetup] 模式: 构建(Compose). 正在执行构建模式的安装脚本...'); + try { + const hostGateway = env.DOCKER_HOST_GATEWAY || '172.17.0.1'; + const giteaBaseURL = `http://${hostGateway}:80`; + const serverDomain = hostGateway; + const browser = await chromium.launch(); + await page.goto(baseURL, { timeout: 15000 }); + console.log('[GlobalSetup] 检测到安装界面!正在开始自动化安装...'); + await page.getByRole('textbox', { name: 'Server Domain *' }).fill(serverDomain); + await page.getByRole('textbox', { name: 'Gitea Base URL *' }).fill(giteaBaseURL); + await page.getByText('Server and Third-Party Service Settings').click(); + await page.getByRole('checkbox', { name: 'Enable user sign-in via Wechat QR Code.' }).uncheck(); + await page.getByRole('checkbox', { name: 'Require a CAPTCHA for user' }).uncheck(); + await page.getByText('Administrator Account Settings').click(); + await page.getByRole('textbox', { name: 'Administrator Username' }).fill('testuser'); + await page.getByRole('textbox', { name: 'Email Address' }).fill('ilovcatlyn750314@gmail.com'); + await page.getByRole('textbox', { name: 'Password', exact: true }).fill('12345678'); + await page.getByRole('textbox', { name: 'Confirm Password' }).fill('12345678'); + await page.getByRole('button', { name: 'Install Gitea'}).click(); + console.log("安装中,请耐心等待"); + await page.waitForTimeout(90000); + } catch (error) { + console.error('[GlobalSetup] "构建模式" 安装失败:', error); + await page.screenshot({ path: 'playwright-report/global-setup-failure.png' }); + throw error; + } + } else { + throw new Error(`[GlobalSetup] 未知的 E2E_MODE: "${mode}"`); + } +} +export default globalSetup; \ No newline at end of file diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000000..b8ace7087a --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,20 @@ +{ + "name": "devstar-e2e-runner", + "version": "1.0.0", + "description": "Isolated E2E test runner for DevStar Studio", + "main": "index.js", + "scripts": { + "test": "npx playwright test" + }, + "keywords": [ + "playwright", + "e2e", + "devstar" + ], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "1.53.2" + }, + "dependencies": {} +} diff --git a/tests/e2e/run-e2e-tests.sh b/tests/e2e/run-e2e-tests.sh new file mode 100755 index 0000000000..c170eb2980 --- /dev/null +++ b/tests/e2e/run-e2e-tests.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# 这是一个“一键运行”E2E 测试的脚本 +# 它会处理所有清理、权限、拉取和执行工作 +# 任何命令失败立即退出 +set -e +# +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +# +PROJECT_ROOT=$( cd -- "$SCRIPT_DIR/../.." &> /dev/null && pwd ) +export CURRENT_UID=$(id -u) +export CURRENT_GID=$(id -g) +# +cd "$PROJECT_ROOT" +echo "===== [1/5] 清理旧的测试环境... =====" +# 彻底销毁旧的 compose 环境,-v 会删除关联的数据卷 +docker compose -f tests/e2e/docker-compose.test.yml down -v --remove-orphans +docker image prune -f +# 清理并重建报告和数据目录 +rm -rf ./tests/e2e/reports ./tests/e2e/test-data +mkdir -p ./tests/e2e/reports/html ./tests/e2e/test-data/devstar_data +echo "清理完成。" +echo "" + +echo "===== [2/5] 设置权限... =====" +# 容器内的用户需要写入数据目录 +chmod -R 777 ./tests/e2e/test-data/devstar_data + +# 【关键】允许容器访问宿主机的 Docker Socket +if [ -z "$TARGET_URL" ]; then + sudo chmod 666 /var/run/docker.sock + echo "权限设置完成。" + echo "" +else + echo "跳过docker.sock权限修改 =====" +fi + +if [ -z "$TARGET_URL" ]; then + + echo "===== [3/5] 构建模式: 构建/拉取依赖镜像... =====" + #现在make devstar 处理镜像的构建 + docker pull mengning997/webterminal:latest + docker tag mengning997/webterminal:latest devstar.cn/devstar/webterminal:latest + + echo "===== 现在检测Docker宿主机网关=====" + DOCKER_HOST_GATEWAY="172.17.0.1" + if [ "$OS_TYPE" = "Darwin" ]; then + DOCKER_HOST_GATEWAY="host.docker.internal" + elif [ "$OS_TYPE" = "Linux" ]; then + LINUX_IP=$(ip addr show docker0 | grep -Po 'inet \K[\d\.]+') + if [ -n "$LINUX_IP" ]; then + DOCKER_HOST_GATEWAY=$LINUX_IP + else + echo "警告: 无法动态检测 'docker0' IP, 回退到 172.17.0.1" + fi + fi + export DOCKER_HOST_GATEWAY +else + echo "===== [3/5] URL 模式: 跳过依赖镜像和网关检测。 =====" +fi +echo "===== [4/5] 启动并运行测试... =====" +# 检查从 Makefile 传来的 TARGET_URL 变量是否为空 +if [ -n "$TARGET_URL" ]; then + # --- [A] URL 模式 --- + echo " 模式: [URL模式]. 目标: $TARGET_URL" + export DEVSTAR_URL=$TARGET_URL + export E2E_MODE="url" + echo " 正在检查安装状态..." + PATH_TO_CHECK="/user/login" + EXPECTED_CODE_IF_INSTALLED="200" + PROBE_URL="${TARGET_URL}${PATH_TO_CHECK}" + HTTP_CODE=$(curl -L -s -o /dev/null -w "%{http_code}" "$PROBE_URL") + + if [ "$HTTP_CODE" -eq "$EXPECTED_CODE_IF_INSTALLED" ]; then + echo " 探测结果: 目标已安装 (在 ${PROBE_URL} 收到 HTTP ${HTTP_CODE})." + export E2E_SKIP_INSTALL="true" + export E2E_USERNAME + export E2E_PASSWORD + export E2E_ADMIN_ID + else + echo "没有安装,下面执行安装脚本! " + export E2E_SKIP_INSTALL="false" + fi + command docker compose \ + -f tests/e2e/docker-compose.test.yml \ + up \ + --build \ + --exit-code-from test-runner \ + test-runner +else + # --- [B] 构建模式 --- + echo "==> 模式: [构建模式]. 正在本地启动 devstar..." + + # 1. 导出内部 URL + export DEVSTAR_URL="http://devstar:3000" + export E2E_MODE="compose" + export E2E_SKIP_INSTALL="false" + echo "即将执行: docker compose -f tests/e2e/docker-compose.test.yml up --build --wait --exit-code-from test-runner" + # 2. 启动所有服务 + command docker compose \ + -f tests/e2e/docker-compose.test.yml \ + -f tests/e2e/docker-compose.override.yml \ + up \ + --build \ + --abort-on-container-exit \ + --exit-code-from test-runner +fi +# 捕获 test-runner 的退出码 +EXIT_CODE=$? +echo "" +echo "" + +echo "===== [5/5] 测试运行完成 =====" +echo "HTML 报告已生成在: ./reports/html" +ls -l ./tests/e2e/reports/html +echo "" + +# 以 test-runner 的退出码退出 +exit $EXIT_CODE diff --git a/tests/e2e/specs/devcontainer.e2e.test.ts b/tests/e2e/specs/devcontainer.e2e.test.ts new file mode 100644 index 0000000000..ad909ae4fb --- /dev/null +++ b/tests/e2e/specs/devcontainer.e2e.test.ts @@ -0,0 +1,186 @@ +import { test, expect } from '@playwright/test'; +import { link } from 'node:fs'; +import { env } from 'node:process'; +import { Login } from './utils.e2e'; + + +const DEFAULT_E2E_USER = 'testuser'; +const DEFAULT_E2E_PASS = '12345678'; +const DEFAULT_ADMIN_ID = '1'; +const isAlreadyInstalled = env.E2E_SKIP_INSTALL === 'true'; +const url1=env.DEVSTAR_URL; +const GITEA_URL = (env.E2E_MODE === 'url') ? url1 : 'http://devstar:3000'; +const TEST_USER = isAlreadyInstalled ? (env.E2E_USERNAME || DEFAULT_E2E_USER) : DEFAULT_E2E_USER; +const TEST_PASS = isAlreadyInstalled ? (env.E2E_PASSWORD || DEFAULT_E2E_PASS) : DEFAULT_E2E_PASS; +const TEST_ADMIN_USER_ID = isAlreadyInstalled ? (env.E2E_ADMIN_ID || DEFAULT_ADMIN_ID) : DEFAULT_ADMIN_ID; +const repoName='e2e-test-repo'; +test.describe('devcontainer 相关测试',()=>{ +test.beforeEach(async ({ page }) => { + + await Login(page); + }); +test('DevContainer 功能和配置', async ({ page,context }) => { + + console.log(`正在创建新仓库: ${repoName}`); + await page.goto(GITEA_URL + '/repo/create'); + await page.fill('input[name="repo_name"]', repoName); + await page.getByRole('button', { name: 'Create Repository' }).click(); + await expect(page).toHaveURL(GITEA_URL + '/' + TEST_USER + '/' + repoName); + + console.log("仓库创建成功."); + await expect(page).toHaveURL(GITEA_URL + '/' + TEST_USER + '/' + repoName); + + console.log("正在点击 'Dev Container' 标签页..."); + await page.getByRole('link', { name: 'Dev Container' }).click(); + + console.log("正在点击 'Create' (创建模板) 按钮..."); + await page.getByRole('button', { name: /Create/i }).click(); + await page.waitForTimeout(10000); + + console.log("模板已创建. 正在点击 'Edit' 按钮..."); + await expect(page.getByText('devcontainer.json')).toBeVisible(); + await page.getByRole('link', { name: 'Edit' }).click(); + await page.waitForTimeout(5000); + console.log("已跳转到编辑器. 正在修改内容"); + const newJsonAsObject = { + "name": "Gitea DevContainer", + "image": "mcr.microsoft.com/devcontainers/go:1.24-bookworm", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "lts" + }, + "ghcr.io/devcontainers/features/git-lfs:1.2.2": {}, + "ghcr.io/devcontainers-extra/features/poetry:2": {}, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12" + }, + "ghcr.io/warrenbuckley/codespace-features/sqlite:1": {} + }, + "customizations": { + "vscode": { + "settings": {}, + "extensions": [ + "editorconfig.editorconfig", + "dbaeumer.vscode-eslint", + "golang.go", + "stylelint.vscode-stylelint", + "DavidAnson.vscode-markdownlint", + "Vue.volar", + "ms-azuretools.vscode-docker", + "vitest.explorer", + "cweijan.vscode-database-client2", + "GitHub.vscode-pull-request-github", + "Azurite.azurite" + ] + } + }, + "portsAttributes": { + "3000": { + "label": "Gitea Web", + "onAutoForward": "notify" + } + }, + "postCreateCommand": "make deps" + }; + + //转换为JSON 字符串 + const newJsonString = JSON.stringify(newJsonAsObject); + + // 设置焦点 + await page.locator('.view-lines > div:nth-child(20)').click(); + console.log("正在手动删除模板内容 "); + for (let i = 0; i < 500; i++) { + await page.keyboard.press('Backspace'); + } + //粘贴字符串 + console.log("正在粘贴JSON 内容..."); + await page.keyboard.insertText(newJsonString); + await page.getByRole('button', { name: 'Commit Changes' }).click(); + console.log("devcontainer.json 修改并提交成功."); + console.log("正在导航回 Dev Container 标签页进行验证..."); + await page.getByRole('link', { name: 'Dev Container' }).click(); + + console.log("创建开发容器"); + await page.getByRole('button', { name: 'Create Dev Container' }).click(); + console.log("正在创建"); + await expect(page.getByRole('button', { name: 'Stop Dev Container' })) + .toBeVisible({ timeout: 180000 }); + console.log("Dev container 创建成功!"); + await page.getByRole('button',{ name: 'Stop Dev Container'}).click(); + console.log("正在停止"); + await expect(page.getByRole('button',{ name: 'Start Dev Container'})).toBeVisible({ timeout: 180000}); + console.log("成功停止开发容器!"); + await page.getByRole('button',{name: 'Start Dev Container'}).click(); + console.log("正在启动开发容器"); + await expect(page.getByRole('button',{name:'Stop Dev Container'})).toBeVisible({ timeout: 180000}); + console.log("成功启动!"); + console.log("保存开发容器"); + const pagePromise = context.waitForEvent('page'); + console.log("打开webterminal"); + await page.getByRole('link',{name: 'open with WebTerminal'}).click(); + const newPage = await pagePromise; + await newPage.waitForLoadState(); // 等待新页面加载完成 + console.log("Web Terminal: 新标签页已打开!"); + //await expect(newPage.getByText('Successfully connected to the container')).toBeVisible(); //这里ttyd里的信息PlayWright看不见,容器的交互没办法自动化测试 + await page.getByRole('link', { name: 'Delete Dev Container' }).click(); + await page.locator('#delete-repo-devcontainer-of-user-modal') + .getByRole('button', { name: 'Yes' }) + .click(); + console.log('正在删除!'); + await expect(page.getByRole('button', { name: 'Create Dev Container' })).toBeVisible(); + console.log('成功删除!'); + console.log("test1 检查通过!"); + + //console.log("test2仓库"); + //await page.goto(GITEA_URL + '/'+ TEST_REPO_EMPTY); + //await page.getByRole('link', { name: 'Dev Container' }).click(); + //await expect(page.getByText('Oops, it looks like there is no Dev Container Setting in this repository.')).toBeVisible(); + //console.log("test2 检查通过"); + + //console.log("test3仓库"); + //await page.goto(GITEA_URL+ '/' + TEST_REPO_INVALID); + //await page.getByRole('link', { name: 'Dev Container' }).click(); + //await expect(page.getByText(' Invalid Dev Container Configuration')).toBeVisible(); + //console.log("test3检查通过"); +}); +test('权限修改相关', async ({ page }) => { + + console.log('权限配置'); + await page.getByRole('link', { name: 'Site Administration' }).click(); + await page.getByText('Identity & Access').click(); + await page.getByRole('link', { name: 'User Accounts' }).click(); + await page.getByRole('row') + .filter({ hasText: TEST_USER }) + .getByRole('link', { name: 'Edit' }) + .click(); + const devContainerCheckbox = page.getByLabel(/May Create Devcontainers/i); + await devContainerCheckbox.uncheck(); + await page.getByRole('button', { name: 'Update User Account' }).click(); + await page.goto(GITEA_URL + '/' + "e2e-devcontainer-test"); + const devContainerLink = page.getByRole('link', { name: 'Dev Container' }); + await expect(devContainerLink).toBeHidden({ timeout: 10000 }); + console.log('权限设置成功!'); + + console.log('现在恢复原环境'); + await page.goto(GITEA_URL+ '/-/admin/users/' + TEST_ADMIN_USER_ID + '/edit'); + await devContainerCheckbox.check(); + await page.getByRole('button', { name: 'Update User Account' }).click(); + + console.log('现在清理测试仓库'); + console.log("正在导航到仓库设置页面..."); + await page.goto(GITEA_URL + '/' + TEST_USER + '/' + repoName + '/settings'); + + console.log("正在点击 'Delete This Repository' 按钮..."); + await page.getByRole('button', { name: 'Delete This Repository' }).click(); + await page.locator('#delete-repo-modal').waitFor(); + console.log(`正在输入 '${repoName}' 进行确认...`); + await page.locator('#repo_name_to_delete').fill(repoName); + + console.log("正在点击最终的删除确认按钮..."); + await page.getByRole('button', { name: 'Delete Repository' }).click(); + + +}); +}) + + diff --git a/tests/e2e/specs/utils.e2e.ts b/tests/e2e/specs/utils.e2e.ts new file mode 100644 index 0000000000..1085eca328 --- /dev/null +++ b/tests/e2e/specs/utils.e2e.ts @@ -0,0 +1,75 @@ +import { type Page, expect } from '@playwright/test'; +import { env } from 'node:process'; + +const DEFAULT_E2E_USER = 'testuser'; +const DEFAULT_E2E_PASS = '12345678'; +const mode=env.E2E_MODE; + +export async function Login(page: Page) { + +const isInstalled = env.E2E_SKIP_INSTALL === 'true'; + +let username: string | undefined; +let password: string | undefined; +let wasUsingDefault = false; +if(mode === 'url'){ + const url1=env.DEVSTAR_URL; + if (isInstalled) { + // "已安装" 模式 + + username = env.E2E_USERNAME || DEFAULT_E2E_USER; + password = env.E2E_PASSWORD || DEFAULT_E2E_PASS; + if (!env.E2E_USERNAME) { + wasUsingDefault = true; + } + console.log(`"已安装"模式, 尝试用 ${username} 登录...`); + } else { + // "未安装" 模式 + username = DEFAULT_E2E_USER; + password = DEFAULT_E2E_PASS; + wasUsingDefault=true; + console.log(` "刚安装"模式, 尝试用 ${username} 登录...`); + } + try { + await page.goto(url1 + '/user/login'); + const captchaInput = page.locator('input[name="captcha"]'); + if (await captchaInput.isVisible()) { + throw new Error('检测到验证码 (CAPTCHA)! E2E 测试无法继续。'); + } + await page.fill('#user_name',username); + await page.fill('#password', password); + await page.getByRole('button', { name: 'Sign In' }).click(); + await expect(page).toHaveURL(url1+ '/'); + console.log(`[LoginHelper] 用户 [${username}] 登录成功!`); + } catch (error) { + console.error(`[LoginHelper] 登录失败! 原始错误: ${error.message}`); + + let hint: string; + + if (error.message.includes('CAPTCHA')) { + // 提示 1: 验证码 + hint = `请禁用验证码!\n` ; + } else if (wasUsingDefault) { + // 提示 2: 你没输入, 且默认值失败了 + hint = `1. 登录失败, 且你没有提供 README.md 里描述的环境变量。\n` + + `2. 脚本自动尝试了默认用户 (${DEFAULT_E2E_USER}),但失败了。\n` + + `3. 请检查默认用户 (${DEFAULT_E2E_USER}) 在该目标上是否存在, 且密码是否正确。`; + + } else { + hint = `1. 登录失败, 你提供了 E2E_USERNAME (${username})。\n` + + `2. 请检查你传入的 E2E_USERNAME 和 E2E_PASSWORD 环境变量是否正确。`; + } + + throw new Error( + `[LoginHelper] 登录失败。\n\n${hint}\n` + ); + } +} +else if(mode=='compose'){ + await page.fill('#user_name',username); + await page.fill('#password', password); + await page.getByRole('button', { name: 'Sign In' }).click(); + await expect(page).toHaveURL(env.DEVSTAR_URL+ '/'); + console.log(`[LoginHelper] 登录成功!`); +} +}