Ctrl K
ComunidadGuías…

scripts/e2e-setup.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <<SQL
CREATE DATABASE IF NOT EXISTS \`$E2E_DB\`;
CREATE USER IF NOT EXISTS '$E2E_MYSQL_USER'@'localhost' IDENTIFIED BY '$E2E_MYSQL_PASS';
CREATE USER IF NOT EXISTS '$E2E_MYSQL_USER'@'%' IDENTIFIED BY '$E2E_MYSQL_PASS';
GRANT ALL ON \`$E2E_DB\`.* TO '$E2E_MYSQL_USER'@'localhost';
GRANT ALL ON \`$E2E_DB\`.* TO '$E2E_MYSQL_USER'@'%';
FLUSH PRIVILEGES;
SQL

# 历史表结构与 TS 版 Flyway 一致(src/db/flyway.ts)。
echo "==> 确保 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" <<EOF
# E2E 隔离环境配置(scripts/e2e-setup.sh 生成,勿提交 git)
MAO_TS_PORT=9180
MYSQL_URL='jdbc:mysql://$E2E_MYSQL_HOST:$E2E_MYSQL_PORT/$E2E_DB?useSSL=false&serverTimezone=Asia/Shanghai&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=utf-8'
MYSQL_USERNAME=$E2E_MYSQL_USER
MYSQL_PASSWORD='$E2E_MYSQL_PASS'
JWT_SECRET=e2e-jwt-secret-not-for-prod
APP_GIT_CREDENTIAL_SECRET=$(openssl rand -base64 32 2>/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"