#!/bin/bash # E2E 测试环境一键搭建: # 1. 在本地 MySQL 创建隔离的 mao_e2e 库与专用账号 # 2. 执行全部 Flyway 迁移(DELIMITER 语法经 mysql 客户端执行,绕开 TS 执行器限制) # 3. 写入最小种子数据,避免 analytics 等页面走空态分支 # 4. 生成 backend-ts/.env.e2e(不入 git) # 之后 npm test 会通过 playwright webServer 自动拉起后端(:9180)与两个前端 dev server。 # # 用法:bash scripts/e2e-setup.sh [--force] [--skip-seed] # --force 重建数据库(丢弃已有 e2e 数据) # --skip-seed 只建表不写种子数据 # # 可用环境变量: # E2E_MYSQL_HOST 默认 127.0.0.1 # E2E_MYSQL_PORT 默认 3306 # E2E_MYSQL_ROOT_PASS root 密码,默认空(auth_socket 登录) # E2E_MYSQL_USER 默认 mao_e2e # E2E_MYSQL_PASS 默认 MaoE2e_2026! # E2E_DB 默认 mao_e2e set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" MIGRATION_DIR="$ROOT_DIR/backend-ts/db/migration" E2E_MYSQL_HOST="${E2E_MYSQL_HOST:-localhost}" E2E_MYSQL_PORT="${E2E_MYSQL_PORT:-3306}" E2E_MYSQL_ROOT_PASS="${E2E_MYSQL_ROOT_PASS:-}" E2E_MYSQL_USER="${E2E_MYSQL_USER:-mao_e2e}" E2E_MYSQL_PASS="${E2E_MYSQL_PASS:-MaoE2e_2026!}" E2E_DB="${E2E_DB:-mao_e2e}" FORCE=0 SEED=1 for arg in "$@"; do case "$arg" in --force) FORCE=1 ;; --skip-seed) SEED=0 ;; *) echo "未知参数: $arg"; exit 1 ;; esac done MYSQL_ROOT() { # root 常见两种登录方式:auth_socket(不传 -h,走 socket)与 TCP 密码。 if [[ -n "$E2E_MYSQL_ROOT_PASS" ]]; then mysql -h"$E2E_MYSQL_HOST" -P"$E2E_MYSQL_PORT" -uroot -p"$E2E_MYSQL_ROOT_PASS" "$@" elif mysql -uroot -e "SELECT 1" >/dev/null 2>&1; then mysql -uroot "$@" else mysql -h"$E2E_MYSQL_HOST" -P"$E2E_MYSQL_PORT" -uroot "$@" fi } MYSQL_E2E() { mysql -h"$E2E_MYSQL_HOST" -P"$E2E_MYSQL_PORT" -u"$E2E_MYSQL_USER" -p"$E2E_MYSQL_PASS" "$E2E_DB" "$@" } command -v mysql >/dev/null || { echo "错误:未安装 mysql 客户端"; exit 1; } [[ -d "$MIGRATION_DIR" ]] || { echo "错误:找不到迁移目录 $MIGRATION_DIR"; exit 1; } echo "==> 检查 MySQL 连接" MYSQL_ROOT -e "SELECT 1" >/dev/null 2>&1 || { echo "错误:无法用 root 连接 MySQL($E2E_MYSQL_HOST:$E2E_MYSQL_PORT)。" echo "如 root 有密码,请设置环境变量 E2E_MYSQL_ROOT_PASS 后重试。" exit 1 } if [[ "$FORCE" == "1" ]]; then echo "==> --force:删除并重建数据库 $E2E_DB" MYSQL_ROOT -e "DROP DATABASE IF EXISTS \`$E2E_DB\`" fi echo "==> 创建数据库与账号(已存在则跳过)" MYSQL_ROOT < 确保 flyway_schema_history 存在" MYSQL_E2E <<'SQL' CREATE TABLE IF NOT EXISTS flyway_schema_history ( installed_rank INT NOT NULL, version VARCHAR(50) NULL, description VARCHAR(200) NOT NULL, type VARCHAR(20) NOT NULL, script VARCHAR(1000) NOT NULL, checksum INT NULL, installed_by VARCHAR(100) NOT NULL, installed_on TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, execution_time INT NOT NULL, success TINYINT(1) NOT NULL, PRIMARY KEY (installed_rank) ); SQL # Flyway CRC32:逐行(不含换行符)crc32 累加,取有符号 int32,与 src/db/flyway.ts 的 # flywayChecksum 保持一致,保证后端启动时校验通过、不会重复迁移。 checksum_of() { node -e " const { crc32 } = require('node:zlib'); const fs = require('node:fs'); let content = fs.readFileSync(process.argv[1], 'utf8').replace(/^\uFEFF/, ''); const lines = content.split(/\r\n|\n|\r/); if (lines.length && lines[lines.length - 1] === '') lines.pop(); let h = 0; for (const line of lines) h = crc32(Buffer.from(line, 'utf8'), h); console.log(h | 0); " "$1" } applied() { MYSQL_E2E -N -e "SELECT COUNT(*) FROM flyway_schema_history WHERE version='$1' AND success=1" 2>/dev/null } # 与 src/db/flyway.ts 同口径:同版本号只执行先看到的文件,后一个会被静默跳过。 dups="$( for f in "$MIGRATION_DIR"/V*.sql; do basename "$f" | sed -E 's/^V([0-9.]+)__.*\.sql$/\1/' done | sort | uniq -d )" if [[ -n "$dups" ]]; then echo "错误:迁移版本号重复($dups)。请改号后再执行,否则后一个文件不会进库。" exit 1 fi echo "==> 执行迁移($(ls "$MIGRATION_DIR" | grep -c '^V') 个文件)" RANK=0 for f in "$MIGRATION_DIR"/V*.sql; do name="$(basename "$f")" ver="$(echo "$name" | sed -E 's/^V([0-9.]+)__.*\.sql$/\1/')" desc="$(echo "$name" | sed -E 's/^V[0-9.]+__//; s/\.sql$//' | tr '_' ' ')" RANK=$((RANK + 1)) if [[ "$(applied "$ver")" != "0" ]]; then echo " 跳过 ${name}(已应用)" continue fi start=$(date +%s%N) # DELIMITER/存储过程只有 mysql 客户端能正确解析,故逐文件经客户端执行。 MYSQL_E2E < "$f" || { echo "迁移失败: $name"; exit 1; } ms=$(( ($(date +%s%N) - start) / 1000000 )) sum="$(checksum_of "$f")" MYSQL_E2E -e "INSERT INTO flyway_schema_history (installed_rank,version,description,type,script,checksum,installed_by,execution_time,success) VALUES ($RANK,'$ver','$desc','SQL','$name',$sum,'e2e-setup',$ms,1)" >/dev/null echo " 应用 $name" done if [[ "$SEED" == "1" ]]; then echo "==> 写入种子数据(幂等:已有 session 数据则跳过)" existing="$(MYSQL_E2E -N -e "SELECT COUNT(*) FROM session" 2>/dev/null || echo 0)" if [[ "$existing" != "0" ]]; then echo " 已有 $existing 条 session,跳过" else model_count="$(MYSQL_E2E -N -e "SELECT COUNT(*) FROM llm_model")" if [[ "$model_count" == "0" ]]; then MYSQL_E2E -e "INSERT INTO llm_model (name, provider, api_protocol, effort, base_url, api_key, model_id, is_default, status) VALUES ('e2e-mock', 'openai', 'OPENAI', '', 'https://mock.local/v1', 'sk-e2e', 'e2e-mock', 1, 1)" 2>/dev/null || \ MYSQL_E2E -e "INSERT INTO llm_model (name, provider, api_protocol, base_url, api_key, model_id) VALUES ('e2e-mock', 'openai', 'OPENAI', 'https://mock.local/v1', 'sk-e2e', 'e2e-mock')" fi MYSQL_E2E <<'SQL' INSERT INTO agent (name, description, system_prompt, creator_id) VALUES ('数据分析助手', '帮助用户分析数据', '你是数据分析助手', 1), ('代码助手', '帮助用户写代码', '你是代码助手', 1); INSERT INTO session (user_id, agent_id, title, status, phase, execution_mode, workspace, created_at, last_activity_at) VALUES (1, 1, '分析销售数据', 'ACTIVE', 'COMPLETED', 'LOCAL', NULL, NOW(), NOW()), (1, 2, '修复登录bug', 'ACTIVE', 'COMPLETED', 'LOCAL', NULL, NOW(), NOW()), (1, 1, '生成周报', 'ACTIVE', 'RUNNING', 'LOCAL', NULL, NOW(), NOW()); SQL MYSQL_E2E <<'SQL' INSERT INTO message (session_id, role, content, token_count, created_at) VALUES (1, 'USER', '帮我分析这份销售数据', 120, NOW()), (1, 'ASSISTANT', '好的,我来分析', 350, NOW()), (2, 'USER', '登录页面报错了', 100, NOW()), (2, 'ASSISTANT', '我来看看日志', 280, NOW()), (3, 'USER', '生成本周周报', 90, NOW()); SQL # llm_usage / llm_call 需要 model_id,取最小可用模型(种子模型或迁移内置模型均可) MYSQL_E2E <<'SQL' INSERT INTO llm_usage (user_id, session_id, model_id, scene, prompt_tokens, completion_tokens, total_tokens, success, created_at) SELECT 1, 1, (SELECT MIN(id) FROM llm_model), 'chat', 800, 1200, 2000, 1, NOW(); INSERT INTO llm_usage (user_id, session_id, model_id, scene, prompt_tokens, completion_tokens, total_tokens, success, created_at) SELECT 1, 2, (SELECT MIN(id) FROM llm_model), 'chat', 600, 900, 1500, 1, NOW(); INSERT INTO llm_call (user_id, session_id, agent_id, model_id, model_name, provider, scene, prompt_tokens, completion_tokens, total_tokens, success, duration_ms, created_at) SELECT 1, 1, 1, m.id, m.name, m.provider, 'chat', 800, 1200, 2000, 1, 900, NOW() FROM llm_model m WHERE m.id = (SELECT MIN(id) FROM llm_model); INSERT INTO llm_call (user_id, session_id, agent_id, model_id, model_name, provider, scene, prompt_tokens, completion_tokens, total_tokens, success, duration_ms, created_at) SELECT 1, 2, 2, m.id, m.name, m.provider, 'chat', 600, 900, 1500, 1, 1100, NOW() FROM llm_model m WHERE m.id = (SELECT MIN(id) FROM llm_model); SQL echo " 完成:agent/session/message/llm_usage/llm_call" fi # 分组重命名 E2E 依赖:一条带 workspace 的 LOCAL 会话(分组名 demo-project,可改名)。 # 放在 if/else 之外:老 e2e 库(已有 session)也能补上这行种子(自带 NOT EXISTS 幂等守卫)。 MYSQL_E2E <<'SQL' INSERT INTO session (user_id, agent_id, title, status, phase, execution_mode, workspace, created_at, last_activity_at) SELECT 1, 1, '本地空间任务', 'ACTIVE', 'COMPLETED', 'LOCAL', '/home/mao-e2e/demo-project', NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM session WHERE workspace = '/home/mao-e2e/demo-project'); SQL # 分析页(用量分析/趋势图)只统计时间窗内数据;种子库复用后 created_at 会过期, # 每次 setup 都把业务时间戳刷新为当前时间,保证「今日」窗口恒有数据。 MYSQL_E2E -e " UPDATE session SET created_at = NOW(), last_activity_at = NOW(); UPDATE message SET created_at = NOW(); UPDATE llm_usage SET created_at = NOW(); UPDATE llm_call SET created_at = NOW(); " # 分组重命名 E2E 断言「初始显示推导名 demo-project」:清掉历史运行留下的别名, # 避免上次成功重命名的结果被下次运行误判为「分组不存在」。 MYSQL_E2E -e " UPDATE user_task_panel_preference SET group_aliases = JSON_REMOVE(group_aliases, '$.\"LOCAL:/home/mao-e2e/demo-project\"') WHERE JSON_CONTAINS_PATH(group_aliases, 'one', '$.\"LOCAL:/home/mao-e2e/demo-project\"'); " echo " 已刷新业务时间戳并清理分组别名(分析页窗口有数据、重命名测试可重放)" fi # 集成配置 E2E 依赖:飞书密钥与 OSS region 需有值(隔离环境无真实凭证, # 种子提供「已设置」态与可往返的 region;测试连接接口由用例自行 mock)。 # 放在 session 守卫之外:老 e2e 库也能补上;ON DUPLICATE 保证每次 setup 重置, # 避免上次运行中途失败留下的脏值影响后续断言。 MYSQL_E2E <<'SQL' INSERT INTO system_setting (setting_key, value, category, description, editable, is_secret) VALUES ('auth.feishu.appId', 'e2e-app-id', '集成配置', '飞书应用 App ID', 1, 0), ('auth.feishu.appSecret', 'e2e-app-secret', '集成配置', '飞书应用 App Secret', 1, 1), ('oss.region', 'oss-cn-e2e', '集成配置', 'OSS Region', 1, 0) ON DUPLICATE KEY UPDATE value = VALUES(value); SQL echo " 已写入集成配置种子(飞书 appId/appSecret、oss.region)" echo "==> 生成 backend-ts/.env.e2e" if [[ ! -f "$ROOT_DIR/backend-ts/.env.e2e" ]] || ! grep -q "MAO_RUNTIME_DIR" "$ROOT_DIR/backend-ts/.env.e2e"; then cat > "$ROOT_DIR/backend-ts/.env.e2e" </dev/null || head -c 32 /dev/urandom | base64) # 运行期目录全部指向 e2e 临时目录,严禁触及生产 /opt/mao-data: # runtimeDir 默认值是 /opt/mao-data/runtime,后端启动即跑一轮清理调度器, # 会删除生产会话的超龄 shell 输出与 skills 副本,必须重定向。 # shareDir 默认值是 /opt/mao-data/share,后端启动即 mkdir(0.0.325 共享目录), # /opt 不可写的开发机上会 EACCES 退出,同样必须重定向。 MAO_RUNTIME_DIR=$ROOT_DIR/backend-ts/.e2e-data/runtime MAO_USER_HOME_DIR=$ROOT_DIR/backend-ts/.e2e-data/users WORKSPACE_ROOT=$ROOT_DIR/backend-ts/.e2e-data/workspace FILE_UPLOAD_DIR=$ROOT_DIR/backend-ts/.e2e-data/uploads MAO_SHARE_DIR=$ROOT_DIR/backend-ts/.e2e-data/share EOF else echo " 已存在,保留" # 老版本生成的 .env.e2e 缺 MAO_SHARE_DIR(0.0.325 新增):补一行重定向, # 否则默认 /opt/mao-data/share 在 /opt 不可写的开发机上启动即 EACCES if ! grep -q "MAO_SHARE_DIR" "$ROOT_DIR/backend-ts/.env.e2e"; then echo "MAO_SHARE_DIR=$ROOT_DIR/backend-ts/.e2e-data/share" >> "$ROOT_DIR/backend-ts/.env.e2e" echo " 已补 MAO_SHARE_DIR 重定向" fi fi # .env.e2e 必须不入 git if ! grep -qE '^\.env\.e2e$' "$ROOT_DIR/backend-ts/.gitignore" 2>/dev/null; then echo ".env.e2e" >> "$ROOT_DIR/backend-ts/.gitignore" echo "==> 已将 .env.e2e 加入 backend-ts/.gitignore" fi echo "" echo "E2E 环境就绪:" echo " 数据库 $E2E_DB@$E2E_MYSQL_HOST:$E2E_MYSQL_PORT" echo " 管理员 admin / admin123" echo " 下一步 cd $(pwd) && npm test"