Compare commits
No commits in common. "fc53fa2e58446aa42251c4a498410cef34614e7c" and "bf114820f32e5eafff7e2308099fa0227144aca1" have entirely different histories.
fc53fa2e58
...
bf114820f3
18
.env.example
18
.env.example
|
|
@ -1,18 +0,0 @@
|
|||
# ===== EternalAI 环境变量配置 =====
|
||||
# 复制此文件为 .env 并填写真实值
|
||||
# cp .env.example .env
|
||||
|
||||
# 数据库连接字符串
|
||||
# 格式: postgresql://用户名:密码@主机:端口/数据库名
|
||||
DATABASE_URL="postgresql://eternalai:YOUR_PASSWORD@localhost:5432/eternalai"
|
||||
|
||||
# JWT 密钥 — 生产环境必须修改为随机长字符串(至少 32 字节)
|
||||
# 生成方法: openssl rand -base64 48
|
||||
JWT_SECRET="PLEASE_CHANGE_THIS_TO_A_RANDOM_SECRET"
|
||||
|
||||
# 服务端口
|
||||
PORT=3001
|
||||
|
||||
# CORS 允许的来源(逗号分隔,生产环境应限制为实际域名)
|
||||
# 留空则允许所有来源(不推荐)
|
||||
ALLOWED_ORIGINS=""
|
||||
|
|
@ -1,72 +1,23 @@
|
|||
name: Deploy EternalAI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '18'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: npx prisma generate
|
||||
|
||||
- name: Push database schema
|
||||
run: npx prisma db push
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
|
||||
- name: Create logs directory
|
||||
run: mkdir -p logs
|
||||
|
||||
- name: Restart PM2 process
|
||||
run: |
|
||||
if pm2 describe eternalai &>/dev/null; then
|
||||
pm2 reload ecosystem.config.js --update-env
|
||||
echo "PM2 process reloaded"
|
||||
else
|
||||
pm2 start ecosystem.config.js
|
||||
echo "PM2 process started"
|
||||
fi
|
||||
pm2 save
|
||||
|
||||
- name: Health check
|
||||
run: |
|
||||
sleep 3
|
||||
for i in $(seq 1 15); do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3001 || echo "000")
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
echo "Health check passed (HTTP $HTTP_CODE)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for server... (attempt $i, HTTP $HTTP_CODE)"
|
||||
sleep 2
|
||||
done
|
||||
echo "Health check failed"
|
||||
pm2 logs eternalai --lines 30 --nostream
|
||||
exit 1
|
||||
|
||||
- name: Reload Nginx
|
||||
run: sudo nginx -t && sudo nginx -s reload || echo "Nginx reload skipped"
|
||||
|
||||
- name: Deploy summary
|
||||
run: |
|
||||
echo "============================================"
|
||||
echo " Deploy completed: $(git rev-parse --short HEAD)"
|
||||
echo " $(git log -1 --format='%s')"
|
||||
echo "============================================"
|
||||
- name: Start server
|
||||
run: npm start
|
||||
|
|
|
|||
|
|
@ -2,6 +2,3 @@ node_modules/
|
|||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
test-results/
|
||||
playwright-report/
|
||||
logs/
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ===== EternalAI 部署脚本 =====
|
||||
# 每次部署时运行,拉取最新代码、安装依赖、迁移数据库、重启服务
|
||||
# 用法: bash deploy/deploy.sh
|
||||
|
||||
# ---- 颜色输出 ----
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# ---- 配置 ----
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HEALTH_URL="http://localhost:3001"
|
||||
HEALTH_TIMEOUT=30
|
||||
|
||||
cd "$APP_DIR"
|
||||
info "应用目录: $APP_DIR"
|
||||
|
||||
# ---- 前置检查 ----
|
||||
if [[ ! -f .env ]]; then
|
||||
error ".env 文件不存在,请先运行 deploy/setup-server.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v pm2 &>/dev/null; then
|
||||
error "PM2 未安装,请先运行 deploy/setup-server.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 拉取最新代码 ----
|
||||
info "拉取最新代码..."
|
||||
git fetch --all
|
||||
git reset --hard origin/master
|
||||
info "当前版本: $(git rev-parse --short HEAD)"
|
||||
|
||||
# ---- 安装依赖 ----
|
||||
info "安装 npm 依赖..."
|
||||
npm install --production=false
|
||||
|
||||
# ---- 生成 Prisma Client ----
|
||||
info "生成 Prisma Client..."
|
||||
npx prisma generate
|
||||
|
||||
# ---- 数据库迁移 ----
|
||||
info "推送数据库 Schema..."
|
||||
npx prisma db push
|
||||
|
||||
# ---- 创建日志目录 ----
|
||||
mkdir -p logs
|
||||
|
||||
# ---- 重启 PM2 进程 ----
|
||||
info "重启 PM2 进程..."
|
||||
if pm2 describe eternalai &>/dev/null; then
|
||||
pm2 reload ecosystem.config.js --update-env
|
||||
info "PM2 进程已 reload"
|
||||
else
|
||||
pm2 start ecosystem.config.js
|
||||
info "PM2 进程已启动"
|
||||
fi
|
||||
pm2 save
|
||||
|
||||
# ---- 健康检查 ----
|
||||
info "健康检查 (${HEALTH_TIMEOUT}s 超时)..."
|
||||
ELAPSED=0
|
||||
while [[ $ELAPSED -lt $HEALTH_TIMEOUT ]]; do
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL" 2>/dev/null || echo "000")
|
||||
if [[ "$HTTP_CODE" == "200" ]]; then
|
||||
info "健康检查通过 (HTTP $HTTP_CODE)"
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
ELAPSED=$((ELAPSED + 2))
|
||||
echo -n "."
|
||||
done
|
||||
echo ""
|
||||
|
||||
if [[ "$HTTP_CODE" != "200" ]]; then
|
||||
error "健康检查失败 (HTTP $HTTP_CODE),请检查日志: pm2 logs eternalai"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 完成 ----
|
||||
echo ""
|
||||
info "============================================"
|
||||
info " 部署完成!"
|
||||
info "============================================"
|
||||
echo ""
|
||||
echo "版本: $(git rev-parse --short HEAD)"
|
||||
echo "分支: $(git branch --show-current)"
|
||||
echo "状态: pm2 status"
|
||||
echo "日志: pm2 logs eternalai"
|
||||
echo ""
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
# ===== EternalAI Nginx 反向代理配置 =====
|
||||
#
|
||||
# 使用方法:
|
||||
# 1. 复制到 Nginx 配置目录:
|
||||
# sudo cp deploy/nginx.conf /etc/nginx/sites-available/eternalai
|
||||
# sudo ln -s /etc/nginx/sites-available/eternalai /etc/nginx/sites-enabled/
|
||||
# 2. 替换 YOUR_DOMAIN 为实际域名
|
||||
# 3. 测试配置: sudo nginx -t
|
||||
# 4. 重载: sudo nginx -s reload
|
||||
#
|
||||
# HTTPS 配置(推荐):
|
||||
# sudo certbot --nginx -d YOUR_DOMAIN
|
||||
|
||||
# 替换 YOUR_DOMAIN 为你的实际域名
|
||||
upstream eternalai_backend {
|
||||
server 127.0.0.1:3001;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name YOUR_DOMAIN;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# 请求体大小限制
|
||||
client_max_body_size 10m;
|
||||
|
||||
# API 请求代理到 Node.js
|
||||
location /api/ {
|
||||
proxy_pass http://eternalai_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
}
|
||||
|
||||
# 静态文件缓存
|
||||
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
proxy_pass http://eternalai_backend;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
expires 1d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 主页和其他路由
|
||||
location / {
|
||||
proxy_pass http://eternalai_backend;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 禁止访问敏感文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
}
|
||||
location ~ /\.(env|git) {
|
||||
deny all;
|
||||
}
|
||||
location ~ /node_modules/ {
|
||||
deny all;
|
||||
}
|
||||
location ~ /prisma/ {
|
||||
deny all;
|
||||
}
|
||||
location ~ /e2e/ {
|
||||
deny all;
|
||||
}
|
||||
location ~ /deploy/ {
|
||||
deny all;
|
||||
}
|
||||
|
||||
access_log /var/log/nginx/eternalai_access.log;
|
||||
error_log /var/log/nginx/eternalai_error.log;
|
||||
}
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ===== EternalAI 服务器初始化脚本 =====
|
||||
# 首次部署时在服务器上运行,自动检测并安装所有依赖
|
||||
# 用法: bash deploy/setup-server.sh
|
||||
#
|
||||
# 可通过环境变量自定义:
|
||||
# DB_NAME=eternalai 数据库名
|
||||
# DB_USER=eternalai 数据库用户
|
||||
# DB_PASS=xxx 数据库密码(必填,否则自动生成)
|
||||
|
||||
# ---- 颜色输出 ----
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# ---- 配置 ----
|
||||
DB_NAME="${DB_NAME:-eternalai}"
|
||||
DB_USER="${DB_USER:-eternalai}"
|
||||
DB_PASS="${DB_PASS:-$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)}"
|
||||
APP_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
NODE_VERSION="20"
|
||||
|
||||
info "应用目录: $APP_DIR"
|
||||
info "数据库: $DB_NAME (用户: $DB_USER)"
|
||||
|
||||
# ---- 检测 OS ----
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
. /etc/os-release
|
||||
OS_ID="$ID"
|
||||
info "检测到操作系统: $PRETTY_NAME"
|
||||
else
|
||||
error "无法检测操作系统,仅支持 Ubuntu/Debian/CentOS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- 安装 Node.js ----
|
||||
if ! command -v node &>/dev/null; then
|
||||
info "安装 Node.js $NODE_VERSION LTS..."
|
||||
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||
curl -fsSL "https://deb.nodesource.com/setup_${NODE_VERSION}.x" | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_VERSION}.x" | sudo -E bash -
|
||||
sudo yum install -y nodejs
|
||||
else
|
||||
error "不支持的操作系统: $OS_ID,请手动安装 Node.js $NODE_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
info "Node.js 安装完成: $(node -v)"
|
||||
else
|
||||
info "Node.js 已安装: $(node -v)"
|
||||
NODE_MAJOR=$(node -v | sed 's/v\([0-9]*\).*/\1/')
|
||||
if [[ "$NODE_MAJOR" -lt 18 ]]; then
|
||||
warn "Node.js 版本过低 (当前 $(node -v)),建议升级到 $NODE_VERSION LTS"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 安装 PM2 ----
|
||||
if ! command -v pm2 &>/dev/null; then
|
||||
info "安装 PM2..."
|
||||
sudo npm install -g pm2
|
||||
info "PM2 安装完成: $(pm2 -v)"
|
||||
else
|
||||
info "PM2 已安装: $(pm2 -v)"
|
||||
fi
|
||||
|
||||
# ---- 安装 PostgreSQL ----
|
||||
if ! command -v psql &>/dev/null; then
|
||||
info "安装 PostgreSQL 15..."
|
||||
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||
sudo apt-get install -y ca-certificates curl gnupg lsb-release
|
||||
sudo install -m 0755 -d /etc/apt/keyrings
|
||||
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg
|
||||
echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" | sudo tee /etc/apt/sources.list.d/pgdg.list
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-15
|
||||
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||
sudo yum install -y "https://download.postgresql.org/pub/repos/yum/reporpms/EL-$(rpm -E %{rhel})-x86_64/pgdg-redhat-repo-latest.noarch.rpm"
|
||||
sudo yum install -y postgresql15-server postgresql15
|
||||
sudo /usr/pgsql-15/bin/postgresql-15-setup initdb
|
||||
else
|
||||
error "不支持的操作系统: $OS_ID,请手动安装 PostgreSQL 15"
|
||||
exit 1
|
||||
fi
|
||||
info "PostgreSQL 安装完成"
|
||||
else
|
||||
info "PostgreSQL 已安装: $(psql --version)"
|
||||
fi
|
||||
|
||||
# ---- 启动 PostgreSQL ----
|
||||
if ! sudo systemctl is-active --quiet postgresql; then
|
||||
info "启动 PostgreSQL 服务..."
|
||||
sudo systemctl enable postgresql
|
||||
sudo systemctl start postgresql
|
||||
fi
|
||||
|
||||
# ---- 创建数据库和用户 ----
|
||||
info "创建数据库 $DB_NAME 和用户 $DB_USER..."
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_roles WHERE rolname='$DB_USER'" | grep -q 1 || {
|
||||
sudo -u postgres psql -c "CREATE USER $DB_USER WITH PASSWORD '$DB_PASS';"
|
||||
info "数据库用户 $DB_USER 已创建"
|
||||
}
|
||||
sudo -u postgres psql -tc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 || {
|
||||
sudo -u postgres psql -c "CREATE DATABASE $DB_NAME OWNER $DB_USER;"
|
||||
info "数据库 $DB_NAME 已创建"
|
||||
}
|
||||
sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE $DB_NAME TO $DB_USER;" >/dev/null
|
||||
|
||||
# 确保 PostgreSQL 对 pg_hba.conf 允许密码认证
|
||||
PG_HBA=$(sudo -u postgres psql -t -c "SHOW hba_file" | xargs)
|
||||
if [[ -f "$PG_HBA" ]]; then
|
||||
if ! sudo grep -q "$DB_USER" "$PG_HBA" 2>/dev/null; then
|
||||
warn "如果连接数据库失败,请检查 $PG_HBA 是否允许 md5/scram-sha-256 认证"
|
||||
warn "添加: host $DB_NAME $DB_USER 127.0.0.1/32 scram-sha-256"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---- 安装 Nginx ----
|
||||
if ! command -v nginx &>/dev/null; then
|
||||
info "安装 Nginx..."
|
||||
if [[ "$OS_ID" == "ubuntu" || "$OS_ID" == "debian" ]]; then
|
||||
sudo apt-get install -y nginx
|
||||
elif [[ "$OS_ID" == "centos" || "$OS_ID" == "rhel" || "$OS_ID" == "rocky" ]]; then
|
||||
sudo yum install -y nginx
|
||||
fi
|
||||
info "Nginx 安装完成: $(nginx -v 2>&1)"
|
||||
else
|
||||
info "Nginx 已安装: $(nginx -v 2>&1)"
|
||||
fi
|
||||
|
||||
if ! sudo systemctl is-active --quiet nginx; then
|
||||
sudo systemctl enable nginx
|
||||
sudo systemctl start nginx
|
||||
fi
|
||||
|
||||
# ---- 创建 .env 文件 ----
|
||||
ENV_FILE="$APP_DIR/.env"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
info "创建 .env 文件..."
|
||||
JWT_SECRET=$(openssl rand -base64 48)
|
||||
cat > "$ENV_FILE" <<EOF
|
||||
DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
|
||||
JWT_SECRET="$JWT_SECRET"
|
||||
PORT=3001
|
||||
EOF
|
||||
info ".env 文件已创建(请妥善保管数据库密码和 JWT 密钥)"
|
||||
echo ""
|
||||
echo "=============================="
|
||||
echo " 数据库密码: $DB_PASS"
|
||||
echo " JWT 密钥已自动生成"
|
||||
echo "=============================="
|
||||
echo ""
|
||||
else
|
||||
info ".env 文件已存在,跳过创建"
|
||||
fi
|
||||
|
||||
# ---- 创建日志目录 ----
|
||||
mkdir -p "$APP_DIR/logs"
|
||||
|
||||
# ---- 安装项目依赖 ----
|
||||
info "安装项目依赖..."
|
||||
cd "$APP_DIR"
|
||||
npm install
|
||||
|
||||
# ---- 生成 Prisma Client ----
|
||||
info "生成 Prisma Client..."
|
||||
npx prisma generate
|
||||
|
||||
# ---- 推送数据库 Schema ----
|
||||
info "推送数据库 Schema..."
|
||||
npx prisma db push
|
||||
|
||||
# ---- 配置 PM2 开机自启 ----
|
||||
info "配置 PM2 开机自启..."
|
||||
pm2 start ecosystem.config.js || true
|
||||
pm2 save
|
||||
pm2 startup systemd -u "$(whoami)" --hp "$HOME" 2>/dev/null | grep "sudo" | bash || true
|
||||
|
||||
# ---- 完成 ----
|
||||
echo ""
|
||||
info "============================================"
|
||||
info " 服务器初始化完成!"
|
||||
info "============================================"
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 配置 Nginx 反向代理(参考 deploy/nginx.conf)"
|
||||
echo " 2. 检查应用状态: pm2 status"
|
||||
echo " 3. 查看日志: pm2 logs eternalai"
|
||||
echo " 4. 访问应用: http://localhost:3001"
|
||||
echo ""
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,332 +0,0 @@
|
|||
# EternalAI 部署指南
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
用户浏览器 → Nginx (80/443) → Node.js Express (3001) → PostgreSQL (5432)
|
||||
↓
|
||||
PM2 进程管理
|
||||
```
|
||||
|
||||
## 文件清单
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `ecosystem.config.js` | PM2 进程配置 |
|
||||
| `.env.example` | 环境变量模板 |
|
||||
| `deploy/setup-server.sh` | 首次服务器初始化脚本 |
|
||||
| `deploy/deploy.sh` | 每次部署脚本 |
|
||||
| `deploy/nginx.conf` | Nginx 反向代理配置模板 |
|
||||
| `.gitea/workflows/deploy.yml` | Gitea Actions CI/CD 工作流 |
|
||||
|
||||
---
|
||||
|
||||
## 一、首次部署(全量部署)
|
||||
|
||||
### 1.1 准备服务器
|
||||
|
||||
在目标服务器上执行以下操作。
|
||||
|
||||
```bash
|
||||
# 克隆代码仓库
|
||||
cd /opt # 或你选择的部署目录
|
||||
git clone http://gitea.fischerai.cn/chigulong/eternalai.git
|
||||
cd eternalai
|
||||
```
|
||||
|
||||
### 1.2 运行服务器初始化脚本
|
||||
|
||||
```bash
|
||||
bash deploy/setup-server.sh
|
||||
```
|
||||
|
||||
此脚本会自动完成:
|
||||
- 检测并安装 Node.js 20 LTS(如缺失)
|
||||
- 检测并安装 PostgreSQL 15(如缺失)
|
||||
- 检测并安装 PM2(如缺失)
|
||||
- 检测并安装 Nginx(如缺失)
|
||||
- 创建数据库 `eternalai` 和用户
|
||||
- 自动生成 `.env` 文件(含随机 JWT 密钥和数据库密码)
|
||||
- 安装 npm 依赖
|
||||
- 生成 Prisma Client
|
||||
- 推送数据库 Schema
|
||||
- 启动 PM2 进程并配置开机自启
|
||||
|
||||
**自定义数据库配置**(可选):
|
||||
```bash
|
||||
DB_NAME=mydb DB_USER=myuser DB_PASS=mypassword bash deploy/setup-server.sh
|
||||
```
|
||||
|
||||
脚本完成后会输出数据库密码,请妥善保存。
|
||||
|
||||
### 1.3 配置 Nginx 反向代理
|
||||
|
||||
```bash
|
||||
# 复制 Nginx 配置
|
||||
sudo cp deploy/nginx.conf /etc/nginx/sites-available/eternalai
|
||||
sudo ln -s /etc/nginx/sites-available/eternalai /etc/nginx/sites-enabled/
|
||||
|
||||
# 编辑配置,替换 YOUR_DOMAIN 为实际域名
|
||||
sudo nano /etc/nginx/sites-enabled/eternalai
|
||||
# 将所有 YOUR_DOMAIN 替换为你的域名,如 eternalai.example.com
|
||||
|
||||
# 测试配置
|
||||
sudo nginx -t
|
||||
|
||||
# 重载 Nginx
|
||||
sudo nginx -s reload
|
||||
```
|
||||
|
||||
### 1.4 配置 HTTPS(推荐)
|
||||
|
||||
```bash
|
||||
# 安装 Certbot
|
||||
sudo apt-get install -y certbot python3-certbot-nginx # Ubuntu/Debian
|
||||
# 或
|
||||
sudo yum install -y certbot python3-certbot-nginx # CentOS/RHEL
|
||||
|
||||
# 自动获取并配置 SSL 证书
|
||||
sudo certbot --nginx -d YOUR_DOMAIN
|
||||
|
||||
# 证书自动续期(Certbot 会自动配置 cron)
|
||||
sudo certbot renew --dry-run
|
||||
```
|
||||
|
||||
### 1.5 验证部署
|
||||
|
||||
```bash
|
||||
# 检查 PM2 进程状态
|
||||
pm2 status
|
||||
|
||||
# 查看应用日志
|
||||
pm2 logs eternalai
|
||||
|
||||
# 测试本地访问
|
||||
curl http://localhost:3001
|
||||
|
||||
# 测试 Nginx 代理访问
|
||||
curl http://YOUR_DOMAIN
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、CI/CD 自动部署(推送即部署)
|
||||
|
||||
### 2.1 配置 Gitea Secrets
|
||||
|
||||
在 Gitea 仓库设置中添加 Secret:
|
||||
|
||||
1. 进入仓库 → Settings → Actions → Secrets
|
||||
2. 添加 `DATABASE_URL`,值为 `.env` 文件中的 `DATABASE_URL`
|
||||
|
||||
### 2.2 配置 Gitea Runner
|
||||
|
||||
确保已注册 self-hosted runner:
|
||||
|
||||
```bash
|
||||
# 在服务器上注册 runner(如尚未注册)
|
||||
# 参考 Gitea 官方文档: https://docs.gitea.com/usage/actions/quickstart
|
||||
```
|
||||
|
||||
### 2.3 自动部署流程
|
||||
|
||||
每次推送代码到 `master` 分支时,Gitea Actions 会自动:
|
||||
|
||||
1. 拉取最新代码
|
||||
2. 安装 npm 依赖
|
||||
3. 生成 Prisma Client
|
||||
4. 推送数据库 Schema(`prisma db push`)
|
||||
5. 重启 PM2 进程(`pm2 reload`)
|
||||
6. 健康检查(等待 HTTP 200)
|
||||
7. 重载 Nginx
|
||||
|
||||
```bash
|
||||
# 推送代码触发自动部署
|
||||
git push origin master
|
||||
```
|
||||
|
||||
### 2.4 手动触发部署
|
||||
|
||||
在 Gitea 仓库 → Actions 页面,可手动触发 `Deploy EternalAI` 工作流。
|
||||
|
||||
---
|
||||
|
||||
## 三、手动部署(不使用 CI/CD)
|
||||
|
||||
```bash
|
||||
# SSH 登录服务器
|
||||
ssh user@your-server
|
||||
|
||||
# 进入应用目录
|
||||
cd /opt/eternalai
|
||||
|
||||
# 运行部署脚本
|
||||
bash deploy/deploy.sh
|
||||
```
|
||||
|
||||
部署脚本会自动完成:拉取代码 → 安装依赖 → 数据库迁移 → 重启 PM2 → 健康检查。
|
||||
|
||||
---
|
||||
|
||||
## 四、常用运维命令
|
||||
|
||||
### PM2 进程管理
|
||||
|
||||
```bash
|
||||
pm2 status # 查看进程状态
|
||||
pm2 logs eternalai # 实时查看日志
|
||||
pm2 logs eternalai --lines 100 # 查看最近 100 行日志
|
||||
pm2 restart eternalai # 重启进程
|
||||
pm2 reload eternalai # 零停机重载
|
||||
pm2 stop eternalai # 停止进程
|
||||
pm2 delete eternalai # 删除进程
|
||||
pm2 monit # 监控面板
|
||||
```
|
||||
|
||||
### 数据库操作
|
||||
|
||||
```bash
|
||||
# 推送 Schema 变更
|
||||
npx prisma db push
|
||||
|
||||
# 打开 Prisma Studio(数据库可视化管理)
|
||||
npx prisma studio
|
||||
|
||||
# 连接 PostgreSQL
|
||||
psql -U eternalai -d eternalai -h localhost
|
||||
```
|
||||
|
||||
### Nginx 操作
|
||||
|
||||
```bash
|
||||
sudo nginx -t # 测试配置
|
||||
sudo nginx -s reload # 重载配置
|
||||
sudo systemctl status nginx # 查看状态
|
||||
sudo tail -f /var/log/nginx/eternalai_error.log # 查看错误日志
|
||||
```
|
||||
|
||||
### 日志查看
|
||||
|
||||
```bash
|
||||
# 应用日志
|
||||
tail -f logs/out.log # 标准输出
|
||||
tail -f logs/err.log # 错误输出
|
||||
|
||||
# Nginx 日志
|
||||
sudo tail -f /var/log/nginx/eternalai_access.log
|
||||
sudo tail -f /var/log/nginx/eternalai_error.log
|
||||
|
||||
# PostgreSQL 日志
|
||||
sudo tail -f /var/log/postgresql/postgresql-15-main.log
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、更新部署
|
||||
|
||||
### 5.1 日常更新(代码变更)
|
||||
|
||||
```bash
|
||||
# 方法一:推送代码触发 CI/CD(推荐)
|
||||
git push origin master
|
||||
|
||||
# 方法二:手动部署
|
||||
bash deploy/deploy.sh
|
||||
```
|
||||
|
||||
### 5.2 数据库 Schema 变更
|
||||
|
||||
修改 `prisma/schema.prisma` 后:
|
||||
|
||||
```bash
|
||||
# 推送到 master,CI/CD 会自动执行 prisma db push
|
||||
git add prisma/schema.prisma
|
||||
git commit -m "feat: update schema"
|
||||
git push origin master
|
||||
|
||||
# 或手动执行
|
||||
npx prisma db push
|
||||
pm2 reload eternalai
|
||||
```
|
||||
|
||||
### 5.3 环境变量变更
|
||||
|
||||
```bash
|
||||
# 编辑 .env 文件
|
||||
nano .env
|
||||
|
||||
# 重启应用使配置生效
|
||||
pm2 reload eternalai --update-env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、回滚
|
||||
|
||||
```bash
|
||||
# 查看部署历史
|
||||
git log --oneline -10
|
||||
|
||||
# 回滚到指定版本
|
||||
git checkout <commit-hash>
|
||||
npm install
|
||||
npx prisma db push
|
||||
pm2 reload eternalai
|
||||
|
||||
# 或回滚到上一个版本
|
||||
git checkout HEAD~1
|
||||
npm install
|
||||
npx prisma db push
|
||||
pm2 reload eternalai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、故障排查
|
||||
|
||||
### 应用无法启动
|
||||
|
||||
```bash
|
||||
# 查看错误日志
|
||||
pm2 logs eternalai --err --lines 50
|
||||
|
||||
# 常见原因:
|
||||
# 1. .env 文件缺失 → 运行 deploy/setup-server.sh
|
||||
# 2. 数据库连接失败 → 检查 DATABASE_URL 和 PostgreSQL 服务
|
||||
# 3. 端口被占用 → 检查 PORT 环境变量
|
||||
```
|
||||
|
||||
### 数据库连接失败
|
||||
|
||||
```bash
|
||||
# 检查 PostgreSQL 服务
|
||||
sudo systemctl status postgresql
|
||||
|
||||
# 测试连接
|
||||
psql -U eternalai -d eternalai -h localhost -W
|
||||
|
||||
# 检查 pg_hba.conf 认证配置
|
||||
sudo cat /etc/postgresql/15/main/pg_hba.conf | grep -v '^#'
|
||||
```
|
||||
|
||||
### Nginx 502 Bad Gateway
|
||||
|
||||
```bash
|
||||
# 检查 Node.js 进程是否运行
|
||||
pm2 status
|
||||
|
||||
# 检查端口
|
||||
curl http://localhost:3001
|
||||
|
||||
# 检查 Nginx 错误日志
|
||||
sudo tail -f /var/log/nginx/eternalai_error.log
|
||||
```
|
||||
|
||||
### PM2 进程未开机自启
|
||||
|
||||
```bash
|
||||
# 重新配置开机自启
|
||||
pm2 startup systemd
|
||||
# 执行输出的 sudo 命令
|
||||
pm2 save
|
||||
```
|
||||
160
e2e/auth.spec.js
160
e2e/auth.spec.js
|
|
@ -1,160 +0,0 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
const testData = require('./fixtures/test-data');
|
||||
const { cleanDatabase, seedExistingUser, disconnect } = require('./fixtures/database');
|
||||
|
||||
test.describe('认证流程', () => {
|
||||
test.beforeAll(async () => {
|
||||
await cleanDatabase();
|
||||
await seedExistingUser();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanDatabase();
|
||||
await disconnect();
|
||||
});
|
||||
|
||||
test('注册新用户成功', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// 点击「我的 XXX」卡片进入 auth 视图(未登录时)
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
|
||||
// 切换到注册 tab
|
||||
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||
// 等待注册表单的 fadeIn 动画完成
|
||||
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
// 填写注册表单
|
||||
await page.fill('[data-form="register"] [name="account"]', testData.users.newUser.account);
|
||||
await page.fill('[data-form="register"] [name="password"]', testData.users.newUser.password);
|
||||
await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.newUser.password);
|
||||
|
||||
// 提交
|
||||
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||
|
||||
// 注册成功后自动成为创作者,应跳转到创作者中心
|
||||
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('重复注册同一账号失败', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||
// 等待注册表单的 fadeIn 动画完成
|
||||
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
await page.fill('[data-form="register"] [name="account"]', testData.users.existing.account);
|
||||
await page.fill('[data-form="register"] [name="password"]', testData.users.existing.password);
|
||||
await page.fill('[data-form="register"] [name="confirmPassword"]', testData.users.existing.password);
|
||||
|
||||
// 监听 dialog(错误信息通过 alert 弹出)
|
||||
const dialogPromise = page.waitForEvent('dialog');
|
||||
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||
const dialog = await dialogPromise;
|
||||
expect(dialog.message()).toContain('已注册');
|
||||
await dialog.accept();
|
||||
|
||||
// 应仍停留在 auth 视图
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
});
|
||||
|
||||
test('登录已有用户成功', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
|
||||
// 默认在登录 tab
|
||||
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||
|
||||
// 登录成功后(existing 是创作者)应跳转到创作者中心
|
||||
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('错误密码登录失败', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
|
||||
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||
await page.fill('[data-form="login"] [name="password"]', 'wrongpassword');
|
||||
|
||||
const dialogPromise = page.waitForEvent('dialog');
|
||||
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||
const dialog = await dialogPromise;
|
||||
expect(dialog.message()).toContain('账号或密码错误');
|
||||
await dialog.accept();
|
||||
|
||||
// 应仍停留在 auth 视图
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
});
|
||||
|
||||
test('注册时密码不一致提示', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
await page.locator('.auth-tabs [data-tab="register"]').click();
|
||||
// 等待注册表单的 fadeIn 动画完成
|
||||
await expect(page.locator('[data-form="register"]')).toBeVisible();
|
||||
await page.waitForTimeout(400);
|
||||
|
||||
await page.fill('[data-form="register"] [name="account"]', 'mismatch_user');
|
||||
await page.fill('[data-form="register"] [name="password"]', 'password1');
|
||||
await page.fill('[data-form="register"] [name="confirmPassword"]', 'password2');
|
||||
|
||||
// 密码不一致通过原生 setCustomValidity + reportValidity 提示(非 alert)
|
||||
await page.locator('[data-form="register"] button[type="submit"]').click();
|
||||
|
||||
// 验证 confirmPassword 字段的 validationMessage 包含「不一致」
|
||||
const validationMessage = await page
|
||||
.locator('[data-form="register"] [name="confirmPassword"]')
|
||||
.evaluate((el) => el.validationMessage);
|
||||
expect(validationMessage).toContain('不一致');
|
||||
|
||||
// 应仍停留在 auth 视图
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
});
|
||||
|
||||
test('登出后回到首页', async ({ page }) => {
|
||||
// 先登录
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 切换到设置 tab(登出按钮在设置面板)
|
||||
await page.locator('[data-center-tab="settings"]').click();
|
||||
await expect(page.locator('#center-settings')).toBeVisible();
|
||||
|
||||
// 点击登出
|
||||
await page.locator('[data-action="logout"]').click();
|
||||
|
||||
// 应回到首页
|
||||
await expect(page.locator('#landing')).toBeVisible();
|
||||
});
|
||||
|
||||
test('登录态持久化(刷新页面后仍登录)', async ({ page }) => {
|
||||
// 先登录
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 刷新页面
|
||||
await page.reload();
|
||||
|
||||
// 首页应可见,且卡片按钮文案应变化(不再是「登录 / 注册」)
|
||||
await expect(page.locator('#landing')).toBeVisible();
|
||||
await expect(page.locator('#characters-btn')).not.toHaveText(/登录/);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,240 +0,0 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
const testData = require('./fixtures/test-data');
|
||||
const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database');
|
||||
|
||||
test.describe('创作者中心与角色发布/编辑', () => {
|
||||
let existingUser;
|
||||
let existingRole;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await cleanDatabase();
|
||||
await seedExistingUser();
|
||||
existingUser = await prisma.user.findUnique({
|
||||
where: { account: testData.users.existing.account },
|
||||
});
|
||||
|
||||
existingRole = await prisma.role.create({
|
||||
data: {
|
||||
creatorId: existingUser.id,
|
||||
displayName: '已有角色',
|
||||
gender: 'female',
|
||||
personality: '温柔体贴',
|
||||
background: '来自南方',
|
||||
speechStyle: '轻声细语',
|
||||
greeting: '你好~',
|
||||
desc: '温柔体贴的角色',
|
||||
price: 19.9,
|
||||
status: 'running',
|
||||
temperature: 0.8,
|
||||
maxTokens: 2048,
|
||||
enableMemory: true,
|
||||
enableTools: false,
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanDatabase();
|
||||
await disconnect();
|
||||
});
|
||||
|
||||
async function loginAsExisting(page) {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
await page.fill('[data-form="login"] [name="account"]', testData.users.existing.account);
|
||||
await page.fill('[data-form="login"] [name="password"]', testData.users.existing.password);
|
||||
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||
await expect(page.locator('#creator-center')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
test('创作者中心显示三个 tab', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
await expect(page.locator('[data-center-tab="roles"]')).toBeVisible();
|
||||
await expect(page.locator('[data-center-tab="income"]')).toBeVisible();
|
||||
await expect(page.locator('[data-center-tab="settings"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('角色 tab 显示已有角色', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
// 默认在 roles tab
|
||||
await expect(page.locator('#creator-role-list .role-card')).toHaveCount(1, { timeout: 5000 });
|
||||
await expect(page.locator('#creator-role-list .role-card__name')).toHaveText('已有角色');
|
||||
});
|
||||
|
||||
test('收入 tab 显示余额和流水', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
await page.locator('[data-center-tab="income"]').click();
|
||||
// income 面板可见(#center-income 带 active class)
|
||||
await expect(page.locator('#center-income')).toBeVisible();
|
||||
// 余额元素可见
|
||||
await expect(page.locator('#income-balance')).toBeVisible({ timeout: 5000 });
|
||||
// 流水列表容器存在
|
||||
await expect(page.locator('#income-list')).toBeVisible();
|
||||
});
|
||||
|
||||
test('设置 tab 可保存昵称和库名', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
await page.locator('[data-center-tab="settings"]').click();
|
||||
await expect(page.locator('#center-settings')).toBeVisible();
|
||||
|
||||
// 修改设置
|
||||
await page.fill('#settings-form [name="creatorName"]', '新昵称');
|
||||
await page.fill('#settings-form [name="libraryName"]', '新库名');
|
||||
|
||||
// 保存成功会弹 alert「设置已保存」
|
||||
const dialogPromise = page.waitForEvent('dialog');
|
||||
await page.locator('#settings-form button[type="submit"]').click();
|
||||
const dialog = await dialogPromise;
|
||||
expect(dialog.message()).toContain('保存');
|
||||
await dialog.accept();
|
||||
});
|
||||
|
||||
test('新建角色完整流程(4步表单)', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
|
||||
// 点击新建角色
|
||||
await page.locator('[data-action="new-role"]').click();
|
||||
await expect(page.locator('#creator')).toBeVisible();
|
||||
|
||||
// Step 1: 基础身份(agentId 必填,pattern [a-zA-Z0-9_]+;displayName 必填)
|
||||
await page.fill('#character-form [name="agentId"]', 'e2e_test_role');
|
||||
await page.fill('#character-form [name="displayName"]', testData.role.displayName);
|
||||
await page.selectOption('#character-form [name="gender"]', testData.role.gender);
|
||||
await page.fill('#character-form [name="age"]', testData.role.age);
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// Step 2: 灵魂设定(background / personality / speechStyle 必填)
|
||||
await page.fill('#character-form [name="background"]', testData.role.background);
|
||||
await page.fill('#character-form [name="personality"]', testData.role.personality);
|
||||
await page.fill('#character-form [name="speechStyle"]', testData.role.speechStyle);
|
||||
await page.fill('#character-form [name="likes"]', testData.role.likes);
|
||||
await page.fill('#character-form [name="dislikes"]', testData.role.dislikes);
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// Step 3: 关系与记忆(greeting 必填)
|
||||
await page.fill('#character-form [name="relationship"]', testData.role.relationship);
|
||||
await page.fill('#character-form [name="memories"]', testData.role.memories);
|
||||
await page.fill('#character-form [name="secrets"]', testData.role.secrets);
|
||||
await page.fill('#character-form [name="greeting"]', testData.role.greeting);
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// Step 4: 运行配置(model/temperature/maxTokens 有默认值;无 price 字段)
|
||||
// 捕获可能的错误弹窗
|
||||
let dialogMessage = '';
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.locator('.form-step.active [data-action="publish"]').click();
|
||||
|
||||
// 应显示生成结果面板
|
||||
await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 验证 Soul.md 内容(单个 #preview-code 元素,默认显示 soul)
|
||||
await expect(page.locator('#preview-code')).toBeVisible();
|
||||
const soulContent = await page.locator('#preview-code').textContent();
|
||||
expect(soulContent).toContain(testData.role.displayName);
|
||||
// generateSoulMd 会把 personality 按 [,,] 拆分成 " | " 连接的标签
|
||||
const personalityTag = testData.role.personality.split(/[,,]/)[0].trim();
|
||||
expect(soulContent).toContain(personalityTag);
|
||||
|
||||
// 切换到 config.yaml 预览(preview-tabs 内的 [data-tab="config"])
|
||||
await page.locator('.preview-tabs [data-tab="config"]').click();
|
||||
const configContent = await page.locator('#preview-code').textContent();
|
||||
expect(configContent).toContain('model:');
|
||||
expect(configContent).toContain('temperature:');
|
||||
});
|
||||
|
||||
test('新建角色后数据库有记录', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
await page.locator('[data-action="new-role"]').click();
|
||||
await expect(page.locator('#creator')).toBeVisible();
|
||||
|
||||
// Step 1: 填写最小必填字段
|
||||
await page.fill('#character-form [name="agentId"]', 'e2e_db_role');
|
||||
await page.fill('#character-form [name="displayName"]', 'DB验证角色');
|
||||
await page.selectOption('#character-form [name="gender"]', 'female');
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// Step 2
|
||||
await page.fill('#character-form [name="personality"]', '测试性格');
|
||||
await page.fill('#character-form [name="background"]', '测试背景');
|
||||
await page.fill('#character-form [name="speechStyle"]', '测试风格');
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// Step 3
|
||||
await page.fill('#character-form [name="greeting"]', '测试问候');
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// Step 4: 直接发布(无 price 字段,使用默认值)
|
||||
let dialogMessage = '';
|
||||
page.on('dialog', async (dialog) => {
|
||||
dialogMessage = dialog.message();
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.locator('.form-step.active [data-action="publish"]').click();
|
||||
await expect(page.locator('#result-panel')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// 验证数据库
|
||||
const dbRole = await prisma.role.findFirst({
|
||||
where: { displayName: 'DB验证角色' },
|
||||
});
|
||||
expect(dbRole).not.toBeNull();
|
||||
expect(dbRole.personality).toBe('测试性格');
|
||||
expect(dbRole.greeting).toBe('测试问候');
|
||||
expect(dbRole.soulMd).not.toBeNull();
|
||||
expect(dbRole.configYaml).not.toBeNull();
|
||||
});
|
||||
|
||||
test('编辑角色加载已有数据', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
|
||||
// 等待角色列表加载,找到已 seeded 的角色(其他测试可能已创建额外角色)
|
||||
await expect(page.locator('#creator-role-list .role-card')).not.toHaveCount(0, { timeout: 5000 });
|
||||
// 定位到"已有角色"那张卡片的编辑按钮
|
||||
const seededCard = page.locator('#creator-role-list .role-card', { hasText: '已有角色' }).first();
|
||||
await expect(seededCard).toBeVisible({ timeout: 5000 });
|
||||
await seededCard.locator('[data-action="edit-role"]').click();
|
||||
await expect(page.locator('#creator')).toBeVisible();
|
||||
|
||||
// 验证表单已预填(loadRoleForEdit 会拉取 /roles/:id/full 填充表单)
|
||||
await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('已有角色', { timeout: 5000 });
|
||||
await expect(page.locator('#character-form [name="personality"]')).toHaveValue('温柔体贴');
|
||||
});
|
||||
|
||||
test('表单验证 - 必填字段为空时阻止提交', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
await page.locator('[data-action="new-role"]').click();
|
||||
await expect(page.locator('#creator')).toBeVisible();
|
||||
|
||||
// 不填任何字段直接点下一步
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// 应仍停留在 step 0(agentId / displayName 必填,验证失败)
|
||||
// step 0 仍 active => agentId 字段仍可见
|
||||
await expect(page.locator('#character-form [name="agentId"]')).toBeVisible();
|
||||
await expect(page.locator('#character-form [name="displayName"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('步骤导航 - 上一步按钮', async ({ page }) => {
|
||||
await loginAsExisting(page);
|
||||
await page.locator('[data-action="new-role"]').click();
|
||||
await expect(page.locator('#creator')).toBeVisible();
|
||||
|
||||
// 填写 step 1 并前进
|
||||
await page.fill('#character-form [name="agentId"]', 'e2e_nav_test');
|
||||
await page.fill('#character-form [name="displayName"]', '导航测试');
|
||||
await page.selectOption('#character-form [name="gender"]', 'female');
|
||||
await page.locator('.form-step.active [data-action="next"]').click();
|
||||
|
||||
// 进入 step 2 后点上一步(data-action="prev",非 prev-step)
|
||||
await expect(page.locator('#character-form [name="background"]')).toBeVisible();
|
||||
await page.locator('.form-step.active [data-action="prev"]').click();
|
||||
|
||||
// 应回到 step 1,且数据保留
|
||||
await expect(page.locator('#character-form [name="displayName"]')).toBeVisible();
|
||||
await expect(page.locator('#character-form [name="displayName"]')).toHaveValue('导航测试');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
// 数据库清理 helper
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function cleanDatabase() {
|
||||
await prisma.order.deleteMany();
|
||||
await prisma.role.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
}
|
||||
|
||||
async function seedExistingUser() {
|
||||
const bcrypt = require('bcryptjs');
|
||||
const testData = require('./test-data');
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
account: testData.users.existing.account,
|
||||
password: bcrypt.hashSync(testData.users.existing.password, 10),
|
||||
isCreator: true,
|
||||
creatorName: testData.users.existing.account,
|
||||
libraryName: 'E2E测试库',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function disconnect() {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
module.exports = { cleanDatabase, seedExistingUser, disconnect, prisma };
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
// 测试数据 fixtures
|
||||
const testData = {
|
||||
users: {
|
||||
newUser: {
|
||||
account: `e2e_${Date.now()}`,
|
||||
password: 'Test123456',
|
||||
},
|
||||
existing: {
|
||||
account: 'e2e_existing',
|
||||
password: 'Test123456',
|
||||
},
|
||||
},
|
||||
role: {
|
||||
displayName: 'E2E测试角色',
|
||||
gender: 'female',
|
||||
age: '25',
|
||||
relationship: '女友',
|
||||
personality: '温柔,体贴,善解人意',
|
||||
background: '来自江南水乡的女孩,喜欢读书和绘画',
|
||||
speechStyle: '轻声细语,偶尔带点小俏皮',
|
||||
likes: '读书,绘画,猫咪',
|
||||
dislikes: '吵闹,谎言',
|
||||
memories: '一起看过的樱花,第一次约会',
|
||||
secrets: '其实很怕打雷',
|
||||
greeting: '你回来啦~今天过得怎么样?',
|
||||
model: 'gpt-4o',
|
||||
temperature: '0.8',
|
||||
maxTokens: '2048',
|
||||
price: '29.9',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = testData;
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { cleanDatabase, disconnect, prisma } = require('./fixtures/database');
|
||||
|
||||
test.describe('导航与可访问性', () => {
|
||||
test.beforeAll(async () => {
|
||||
await cleanDatabase();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanDatabase();
|
||||
await disconnect();
|
||||
});
|
||||
|
||||
test('首页显示两张卡片', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('#landing')).toBeVisible();
|
||||
// 两张卡片:open-characters 和 open-distill
|
||||
const cards = page.locator('[data-action="open-characters"], [data-action="open-distill"]');
|
||||
await expect(cards).toHaveCount(2, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('底部 tabBar 三个 tab 可见', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.tab-bar')).toBeVisible();
|
||||
await expect(page.locator('[data-tab-action="tab-home"]')).toBeVisible();
|
||||
await expect(page.locator('[data-tab-action="tab-distill"]')).toBeVisible();
|
||||
await expect(page.locator('[data-tab-action="tab-mine"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('tabBar 切换视图', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// 点击蒸馏前任 tab
|
||||
await page.locator('[data-tab-action="tab-distill"]').click();
|
||||
await expect(page.locator('#distill')).toBeVisible();
|
||||
|
||||
// 点击首页 tab
|
||||
await page.locator('[data-tab-action="tab-home"]').click();
|
||||
await expect(page.locator('#landing')).toBeVisible();
|
||||
|
||||
// 点击我的 tab(未登录应跳转到 auth)
|
||||
await page.locator('[data-tab-action="tab-mine"]').click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
});
|
||||
|
||||
test('首页卡片点击进入对应视图', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// 点击蒸馏前任卡片
|
||||
await page.locator('[data-action="open-distill"]').first().click();
|
||||
await expect(page.locator('#distill')).toBeVisible();
|
||||
|
||||
// 返回首页
|
||||
await page.locator('[data-tab-action="tab-home"]').click();
|
||||
await expect(page.locator('#landing')).toBeVisible();
|
||||
|
||||
// 点击「我的 XXX」卡片(未登录进入 auth)
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
});
|
||||
|
||||
test('关于页面可访问', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-about"]').first().click();
|
||||
await expect(page.locator('#about')).toBeVisible();
|
||||
});
|
||||
|
||||
test('创作者入驻页面可访问', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-onboarding"]').first().click();
|
||||
await expect(page.locator('#onboarding')).toBeVisible();
|
||||
});
|
||||
|
||||
test('FAQ 折叠展开', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-about"]').first().click();
|
||||
await expect(page.locator('#about')).toBeVisible();
|
||||
|
||||
// 点击第一个 FAQ 按钮(.faq-q,非 .faq-item__question)
|
||||
const faqButton = page.locator('.faq-q').first();
|
||||
await faqButton.click();
|
||||
|
||||
// 验证 aria-expanded 变为 true
|
||||
await expect(faqButton).toHaveAttribute('aria-expanded', 'true');
|
||||
|
||||
// 再次点击折叠
|
||||
await faqButton.click();
|
||||
await expect(faqButton).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
test('返回按钮基于历史记录导航', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// 导航路径: landing → about → 返回
|
||||
await page.locator('[data-action="open-about"]').first().click();
|
||||
await expect(page.locator('#about')).toBeVisible();
|
||||
|
||||
// about 视图使用 data-action="back"
|
||||
await page.locator('#about [data-action="back"]').click();
|
||||
await expect(page.locator('#landing')).toBeVisible();
|
||||
});
|
||||
|
||||
test('跳过链接(a11y)存在且可用', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const skipLink = page.locator('.skip-link');
|
||||
// 跳过链接应存在
|
||||
await expect(skipLink).toHaveCount(1);
|
||||
});
|
||||
|
||||
test('tabBar 有正确的 ARIA 角色', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
const tabbar = page.locator('.tab-bar');
|
||||
await expect(tabbar).toHaveAttribute('role', 'tablist');
|
||||
|
||||
// tab-bar 内的按钮应有 role="tab"
|
||||
const tabs = page.locator('.tab-bar [data-tab-action]');
|
||||
const count = await tabs.count();
|
||||
expect(count).toBe(3);
|
||||
for (let i = 0; i < count; i++) {
|
||||
await expect(tabs.nth(i)).toHaveAttribute('role', 'tab');
|
||||
}
|
||||
});
|
||||
|
||||
test('视图切换时 aria-live 播报', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// #sr-announce 是 aria-live 区域,role="status" aria-live="polite"
|
||||
const liveRegion = page.locator('#sr-announce');
|
||||
await expect(liveRegion).toHaveAttribute('aria-live', 'polite');
|
||||
await expect(liveRegion).toHaveAttribute('role', 'status');
|
||||
|
||||
// 切换视图后 live region 应有内容
|
||||
await page.locator('[data-action="open-about"]').first().click();
|
||||
await expect(liveRegion).not.toHaveText('');
|
||||
const liveText = await liveRegion.textContent();
|
||||
expect(liveText.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('角色卡片支持键盘操作', async ({ page }) => {
|
||||
// 创建非创作者用户 + 角色
|
||||
const bcrypt = require('bcryptjs');
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
account: 'e2e_keyboard_user',
|
||||
password: bcrypt.hashSync('Test123456', 10),
|
||||
isCreator: false,
|
||||
libraryName: '键盘测试库',
|
||||
},
|
||||
});
|
||||
await prisma.role.create({
|
||||
data: {
|
||||
creatorId: user.id,
|
||||
displayName: '键盘测试角色',
|
||||
gender: 'female',
|
||||
personality: '测试',
|
||||
background: '测试',
|
||||
speechStyle: '测试',
|
||||
greeting: '测试',
|
||||
desc: '键盘测试',
|
||||
price: 9.9,
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
// 登录非创作者(登录后自动进入角色库)
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
await page.fill('[data-form="login"] [name="account"]', 'e2e_keyboard_user');
|
||||
await page.fill('[data-form="login"] [name="password"]', 'Test123456');
|
||||
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||
await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 聚焦角色卡片(role-card 有 tabindex="0")
|
||||
const roleCard = page.locator('#role-list .role-card').first();
|
||||
await roleCard.focus();
|
||||
|
||||
// 按 Enter 应进入详情
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('#role-detail')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 清理
|
||||
await prisma.role.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
});
|
||||
|
||||
test('表单 label 与 input 关联', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
|
||||
// 检查登录表单的 input 存在
|
||||
const accountInput = page.locator('[data-form="login"] [name="account"]');
|
||||
await expect(accountInput).toBeVisible();
|
||||
|
||||
// 检查是否有对应的 label(包裹式 label 包含账号相关文案)
|
||||
const labelText = await page.locator('label').filter({ hasText: /账号|用户名|Account/i }).count();
|
||||
expect(labelText).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('focus-visible 样式存在', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
// Tab 到第一个可聚焦元素
|
||||
await page.keyboard.press('Tab');
|
||||
// 聚焦元素应有可见的焦点样式(:focus-visible 使用 outline)
|
||||
const focused = await page.evaluate(() => {
|
||||
const el = document.activeElement;
|
||||
if (!el) return false;
|
||||
const styles = window.getComputedStyle(el);
|
||||
return styles.outlineStyle !== 'none' || styles.boxShadow !== 'none';
|
||||
});
|
||||
expect(focused).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
const { test, expect } = require('@playwright/test');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const testData = require('./fixtures/test-data');
|
||||
const { cleanDatabase, seedExistingUser, disconnect, prisma } = require('./fixtures/database');
|
||||
|
||||
// 非创作者用户(用于角色库测试)
|
||||
const NON_CREATOR = {
|
||||
account: 'e2e_noncreator',
|
||||
password: 'Test123456',
|
||||
};
|
||||
|
||||
test.describe('角色库与角色详情', () => {
|
||||
let existingUser;
|
||||
let testRole;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
await cleanDatabase();
|
||||
await seedExistingUser();
|
||||
existingUser = await prisma.user.findUnique({
|
||||
where: { account: testData.users.existing.account },
|
||||
});
|
||||
|
||||
// 创建非创作者用户(open-characters 对非创作者进入 role-library)
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
account: NON_CREATOR.account,
|
||||
password: bcrypt.hashSync(NON_CREATOR.password, 10),
|
||||
isCreator: false,
|
||||
libraryName: 'E2E测试库',
|
||||
},
|
||||
});
|
||||
|
||||
// 创建一个测试角色(status=running 才会在角色库展示)
|
||||
testRole = await prisma.role.create({
|
||||
data: {
|
||||
creatorId: existingUser.id,
|
||||
displayName: '测试角色A',
|
||||
gender: 'female',
|
||||
age: '24',
|
||||
relationship: '女友',
|
||||
personality: '温柔可爱,善解人意',
|
||||
background: '来自海边的小镇女孩',
|
||||
speechStyle: '轻柔细腻',
|
||||
greeting: '你好呀~',
|
||||
desc: '温柔可爱的女友角色',
|
||||
price: 29.9,
|
||||
status: 'running',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await cleanDatabase();
|
||||
await disconnect();
|
||||
});
|
||||
|
||||
// 以非创作者身份登录,登录后自动进入角色库
|
||||
async function loginAsNonCreator(page) {
|
||||
await page.goto('/');
|
||||
await page.locator('[data-action="open-characters"]').first().click();
|
||||
await expect(page.locator('#auth')).toBeVisible();
|
||||
// 确保登录 tab 激活
|
||||
await page.locator('.auth-tabs [data-tab="login"]').click();
|
||||
await page.locator('[data-form="login"]').waitFor();
|
||||
await page.fill('[data-form="login"] [name="account"]', NON_CREATOR.account);
|
||||
await page.fill('[data-form="login"] [name="password"]', NON_CREATOR.password);
|
||||
await page.locator('[data-form="login"] button[type="submit"]').click();
|
||||
await expect(page.locator('#role-library')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
test('角色库显示已上架角色', async ({ page }) => {
|
||||
await loginAsNonCreator(page);
|
||||
|
||||
// 应显示测试角色
|
||||
await expect(page.locator('#role-list .role-card')).toHaveCount(1, { timeout: 5000 });
|
||||
await expect(page.locator('#role-list .role-card__name')).toHaveText('测试角色A');
|
||||
});
|
||||
|
||||
test('空角色库显示空状态', async ({ page }) => {
|
||||
// 清空角色
|
||||
await prisma.role.deleteMany();
|
||||
|
||||
await loginAsNonCreator(page);
|
||||
|
||||
// 应显示空状态
|
||||
await expect(page.locator('#library-empty')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('点击角色卡片进入详情页', async ({ page }) => {
|
||||
// 重新创建角色
|
||||
testRole = await prisma.role.create({
|
||||
data: {
|
||||
creatorId: existingUser.id,
|
||||
displayName: '详情测试角色',
|
||||
gender: 'male',
|
||||
personality: '阳光开朗',
|
||||
background: '运动少年',
|
||||
speechStyle: '热情直接',
|
||||
greeting: '嘿!你好!',
|
||||
desc: '阳光开朗的男孩',
|
||||
price: 19.9,
|
||||
status: 'running',
|
||||
},
|
||||
});
|
||||
|
||||
await loginAsNonCreator(page);
|
||||
|
||||
// 点击角色卡片
|
||||
await page.locator('#role-list .role-card').first().click();
|
||||
await expect(page.locator('#role-detail')).toBeVisible();
|
||||
|
||||
// 验证详情内容
|
||||
await expect(page.locator('#detail-name')).toHaveText('详情测试角色');
|
||||
await expect(page.locator('#detail-role-name')).toHaveText('详情测试角色');
|
||||
await expect(page.locator('#detail-price')).toContainText('19.9');
|
||||
});
|
||||
|
||||
test('角色详情页返回按钮', async ({ page }) => {
|
||||
await loginAsNonCreator(page);
|
||||
|
||||
await page.locator('#role-list .role-card').first().click();
|
||||
await expect(page.locator('#role-detail')).toBeVisible();
|
||||
|
||||
// 点击返回(role-detail 使用 back-to-library)
|
||||
await page.locator('[data-action="back-to-library"]').click();
|
||||
await expect(page.locator('#role-library')).toBeVisible();
|
||||
});
|
||||
|
||||
test('角色详情付款流程', async ({ page }) => {
|
||||
await loginAsNonCreator(page);
|
||||
|
||||
await page.locator('#role-list .role-card').first().click();
|
||||
await expect(page.locator('#role-detail')).toBeVisible();
|
||||
|
||||
// 付款前:pre-pay 按钮区可见,paid 区隐藏
|
||||
await expect(page.locator('#detail-actions-pre')).toBeVisible();
|
||||
await expect(page.locator('#detail-paid')).toBeHidden();
|
||||
|
||||
// 点击付款按钮(data-action="pay",非 pay-role)
|
||||
await page.locator('[data-action="pay"]').click();
|
||||
|
||||
// 应显示已付款区域(含二维码 / 头像下载)
|
||||
await expect(page.locator('#detail-paid')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.locator('#detail-actions-pre')).toBeHidden();
|
||||
await expect(page.locator('[data-action="download-avatar"]')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'eternalai',
|
||||
script: 'server.js',
|
||||
cwd: __dirname,
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
restart_delay: 3000,
|
||||
max_memory_restart: '512M',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
},
|
||||
error_file: './logs/err.log',
|
||||
out_file: './logs/out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
time: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -509,7 +509,7 @@
|
|||
</label>
|
||||
<label class="field field--half">
|
||||
<span class="field__label">Temperature</span>
|
||||
<input class="field__input" name="temperature" type="number" step="0.05" min="0" max="2" value="0.85" />
|
||||
<input class="field__input" name="temperature" type="number" step="0.1" min="0" max="2" value="0.85" />
|
||||
</label>
|
||||
<label class="field field--half">
|
||||
<span class="field__label">Max Tokens</span>
|
||||
|
|
|
|||
|
|
@ -10,31 +10,12 @@
|
|||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.0",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"prisma": "^5.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.54.2",
|
||||
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.54.2.tgz",
|
||||
"integrity": "sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.54.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
|
|
@ -832,53 +813,6 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.54.2",
|
||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.54.2.tgz",
|
||||
"integrity": "sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.54.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.54.2",
|
||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.54.2.tgz",
|
||||
"integrity": "sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "5.22.0",
|
||||
"resolved": "https://registry.npmmirror.com/prisma/-/prisma-5.22.0.tgz",
|
||||
|
|
|
|||
15
package.json
15
package.json
|
|
@ -7,18 +7,10 @@
|
|||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"db:push": "prisma db push",
|
||||
"db:generate": "prisma generate",
|
||||
"db:studio": "prisma studio",
|
||||
"test": "playwright test",
|
||||
"test:e2e": "playwright test",
|
||||
"deploy": "bash deploy/deploy.sh"
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [
|
||||
"ai",
|
||||
"eternal",
|
||||
"agent",
|
||||
"hermes"
|
||||
],
|
||||
"keywords": ["ai", "eternal", "agent", "hermes"],
|
||||
"author": "chigulong",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
|
@ -33,8 +25,5 @@
|
|||
"express": "^5.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"prisma": "^5.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.54"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false,
|
||||
retries: 1,
|
||||
workers: 1,
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
timeout: 60000,
|
||||
use: {
|
||||
baseURL: 'http://localhost:3001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
actionTimeout: 15000,
|
||||
navigationTimeout: 30000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
channel: 'chrome',
|
||||
browserName: 'chromium',
|
||||
viewport: { width: 1280, height: 1200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'node server.js',
|
||||
url: 'http://localhost:3001',
|
||||
reuseExistingServer: true,
|
||||
timeout: 15000,
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue