This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new a3ed0a03 chore(deploy): fix maven container ownership, npm registry
and deploy docs (#2316)
a3ed0a03 is described below
commit a3ed0a03b93678bcca827d2f7ccdf75c173288a6
Author: lizhimins <[email protected]>
AuthorDate: Mon Aug 17 19:03:22 2026 +0800
chore(deploy): fix maven container ownership, npm registry and deploy docs
(#2316)
- run the maven build container as the invoking user so build artifacts
do not end up owned by root
- pin npm ci to the npmmirror registry in web/Dockerfile
- document the local maven wrapper image workaround in the deploy skill
---
.claude/skills/deploy-rocketmq/SKILL.md | 15 +-
deploy/deploy.sh | 248 ++++++++-------------
web/Dockerfile | 2 +-
.../settings/__tests__/AiAssistantTab.test.tsx | 151 +++++++++++++
4 files changed, 256 insertions(+), 160 deletions(-)
diff --git a/.claude/skills/deploy-rocketmq/SKILL.md
b/.claude/skills/deploy-rocketmq/SKILL.md
index 865b182f..4c524e82 100644
--- a/.claude/skills/deploy-rocketmq/SKILL.md
+++ b/.claude/skills/deploy-rocketmq/SKILL.md
@@ -17,7 +17,7 @@ description: 部署 RocketMQ Studio 与开源 RocketMQ。当用户说 部署 stu
| B. 单机部署开源 RocketMQ | 在目标机器部署纯净开源集群(nameserver + 双 broker + proxy),不带测试挂具 |
| C. 单机部署 RocketMQ 测试客户端 | 在目标机器部署模式 B 集群 + producer/consumer 1 TPS 轨迹挂具,供
Studio 调试 |
-> ⚠️ `deploy/deploy.sh` 是旧的 podman 时代脚本,与当前 docker compose 流程不一致,**不要使用**。
+> ⚠️ 模式 A 可用 `deploy/deploy.sh` 一键执行;模式 B/C 无脚本,按手动步骤执行。
## 通用前置条件(目标机器)
@@ -38,6 +38,10 @@ description: 部署 RocketMQ Studio 与开源 RocketMQ。当用户说 部署 stu
## 模式 A:部署 Studio 到目标机器
+> 🚀 **一键脚本**:`deploy/deploy.sh [all|server|web]` 已实现本模式全流程
+> (打包 → 传输 → 目标机构建 → compose 启动 → actuator/health 验证),
+> `deploy/.env` 配置 `REMOTE_HOST` 等。后续部署优先用脚本;以下手动步骤用于首次初始化与排障。
+
### 核心原则
- **后端编译在目标机器上做**:复用目标机宿主机 `~/.m2/repository` 缓存,增量构建快。
@@ -344,6 +348,15 @@ docker compose exec nameserver sh bin/mqadmin
queryMsgByKey \
export PATH=/opt/apache-maven-3.9.16/bin:$PATH
mvn -B -ntp -s /maven-cache/settings.xml
-Dmaven.repo.local=/maven-cache/repository package -DskipTests'
```
+ - 也可基于本地已有 dragonwell 镜像 + 宿主机 Maven 分发包自制包装镜像
+ (`FROM alibabadragonwell/dragonwell:21` + `COPY apache-maven-* /opt/maven`
+
+ symlink `mvn`),再在 `deploy/.env` 配 `MAVEN_IMAGE=<包装镜像>` 供 deploy.sh 使用。
+- **Maven 容器产物属主为 root**:maven 容器默认 root 运行,写出的 `server/target/`
+ 文件归 root,后续 deploy.sh 的 `rm -rf` 会 Permission denied。deploy.sh 已加
+ `--user $(id -u):$(id -g)` 规避;手动跑 maven 容器时也要带该参数。
+- **web 构建 `npm ci` 卡死 / `tsc: not found`**:国内机器连不上
+ `registry.npmjs.org`(实测 HTTP 000,npmmirror 可达)。web Dockerfile 已改为
+ `npm ci --registry=https://registry.npmmirror.com`;旧版 Dockerfile 手动补该参数。
- **Maven 分发包下载慢**:`archive.apache.org` 国内常只有几 KB/s;改从
`https://mirrors.aliyun.com/apache/maven/maven-3/<版本>/binaries/apache-maven-<版本>-bin.tar.gz`
下载(阿里源实测 10MB/s+;如 3.9.16 即
`maven-3/3.9.16/binaries/apache-maven-3.9.16-bin.tar.gz`)。
diff --git a/deploy/deploy.sh b/deploy/deploy.sh
index 0ceb6469..e804a9cc 100755
--- a/deploy/deploy.sh
+++ b/deploy/deploy.sh
@@ -1,28 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
-# ─── RocketMQ Studio 一键部署脚本 ───
+# ─── RocketMQ Studio 一键部署脚本(模式 A:部署 Studio 到目标机器)───
+# 主线: 本地打包源码 → 复制到目标机 → 目标机构建镜像(复用 ~/.m2 与 docker 缓存)→ docker compose up
# 用法:
# ./deploy/deploy.sh # 部署 server + web
# ./deploy/deploy.sh server # 仅部署 server
# ./deploy/deploy.sh web # 仅部署 web
+# 配置: deploy/.env 中设置 REMOTE_HOST / REMOTE_USER / REMOTE_PATH / PUBLIC_PORT
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
-# ─── 颜色输出 ───
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-CYAN='\033[0;36m'
-NC='\033[0m'
+log() { echo -e "✅ $*"; }
+info() { echo -e "🚀 $*"; }
+warn() { echo -e "⚠️ $*"; }
+err() { echo -e "❌ $*" >&2; exit 1; }
-log() { echo -e "${GREEN}[✓]${NC} $*"; }
-info() { echo -e "${CYAN}[→]${NC} $*"; }
-warn() { echo -e "${YELLOW}[!]${NC} $*"; }
-err() { echo -e "${RED}[✗]${NC} $*"; exit 1; }
-
-# 加载配置
if [[ -f "$SCRIPT_DIR/.env" ]]; then
set -a
# shellcheck disable=SC1091
@@ -33,184 +27,122 @@ fi
[[ -n "${REMOTE_HOST:-}" ]] || err "REMOTE_HOST 未配置,请在 deploy/.env 中设置"
REMOTE="${REMOTE_USER:-root}@$REMOTE_HOST"
REMOTE_PATH="${REMOTE_PATH:-/opt/rocketmq-studio}"
-NETWORK="${PODMAN_NETWORK:-rocketmq-studio}"
-TMP_DIR="/tmp/rocketmq-studio-deploy"
-MAVEN_IMAGE="${MAVEN_IMAGE:-maven:3.9.9-eclipse-temurin-21}"
-MAVEN_CACHE_DIR="${MAVEN_CACHE_DIR:-$HOME/.m2}"
+PUBLIC_PORT="${PUBLIC_PORT:-6789}"
+MAVEN_IMAGE="${MAVEN_IMAGE:-maven:3.9.16-eclipse-temurin-21}"
+SRC_TAR="/tmp/rocketmq-studio-src.tar.gz"
TARGET="${1:-all}" # all | server | web
+case "$TARGET" in
+ all|server|web) ;;
+ *) err "用法: $0 [all|server|web]" ;;
+esac
+
+run_remote() { ssh "$REMOTE" "$@"; }
-# ─── 前置检查 ───
check_prereqs() {
- info "检查前置条件..."
- command -v docker >/dev/null 2>&1 || err "docker 未安装"
- command -v ssh >/dev/null 2>&1 || err "ssh 未安装"
- command -v scp >/dev/null 2>&1 || err "scp 未安装"
+ info "🔎 检查前置条件..."
+ for c in tar scp ssh; do command -v "$c" >/dev/null 2>&1 || err "本地缺少 $c";
done
ssh -o ConnectTimeout=5 -o BatchMode=yes "$REMOTE" "echo ok" >/dev/null 2>&1
\
|| err "无法 SSH 连接到 $REMOTE(请确认免密登录已配置)"
+ run_remote 'command -v docker >/dev/null 2>&1 && docker compose version
>/dev/null 2>&1' \
+ || err "目标机缺少 docker 或 docker compose"
+ run_remote "test -f $REMOTE_PATH/.env" \
+ || err "目标机缺少 $REMOTE_PATH/.env(首次部署请按 SKILL.md 模式 A 步骤 1 初始化)"
log "前置条件通过"
}
-# ─── 构建镜像 ───
-build_server() {
- info "在 Maven 容器中构建 server(复用宿主机缓存: $MAVEN_CACHE_DIR)..."
- mkdir -p "$MAVEN_CACHE_DIR"
+package_source() {
+ info "📦 本地打包源码..."
+ tar czf "$SRC_TAR" -C "$PROJECT_DIR" \
+ --exclude='web/node_modules' --exclude='web/dist'
--exclude='server/target' \
+ server web deploy
+ log "打包完成 ($(du -h "$SRC_TAR" | cut -f1))"
+}
- local maven_settings=()
- if [[ -f "$MAVEN_CACHE_DIR/settings.xml" ]]; then
- maven_settings=(-s /maven-cache/settings.xml)
- fi
+upload_source() {
+ info "📤 传输源码到 $REMOTE:$REMOTE_PATH ..."
+ run_remote "mkdir -p $REMOTE_PATH"
+ scp -q "$SRC_TAR" "$REMOTE:$REMOTE_PATH/"
+ run_remote "cd $REMOTE_PATH && rm -rf server web deploy && tar xzf
$(basename "$SRC_TAR") && rm $(basename "$SRC_TAR")"
+ run_remote 'docker network inspect rocketmq_net >/dev/null 2>&1 || docker
network create rocketmq_net'
+ log "源码就位,rocketmq_net 网络就绪"
+}
- docker run --rm \
- --user "$(id -u):$(id -g)" \
- -e HOME=/tmp \
- -v "$PROJECT_DIR/server:/app" \
- -v "$MAVEN_CACHE_DIR:/maven-cache" \
- -w /app \
- "$MAVEN_IMAGE" \
- mvn -B -ntp "${maven_settings[@]}" \
- -Dmaven.repo.local=/maven-cache/repository \
- package -DskipTests
-
- info "构建 rocketmq-server 镜像..."
- docker build \
- --target runtime-prebuilt \
- -t rocketmq-server:latest \
- "$PROJECT_DIR/server"
+build_server() {
+ info "🏗️ 目标机编译后端 JAR(复用 ~/.m2 缓存)..."
+ local settings_flag=""
+ if run_remote 'test -f ~/.m2/settings.xml'; then
+ settings_flag="-s /maven-cache/settings.xml"
+ else
+ warn "目标机无 ~/.m2/settings.xml(国内机器请先配置 Maven 阿里源,见 SKILL.md 步骤 2)"
+ fi
+ run_remote "cd $REMOTE_PATH && docker run --rm -e HOME=/tmp \
+ --user \$(id -u):\$(id -g) \
+ -v \$PWD/server:/app -v \$HOME/.m2:/maven-cache -w /app \
+ $MAVEN_IMAGE \
+ mvn -B -ntp $settings_flag -Dmaven.repo.local=/maven-cache/repository
package -DskipTests"
+ info "🏗️ 构建 rocketmq-server 镜像(runtime-prebuilt)..."
+ run_remote "cd $REMOTE_PATH && docker build --target runtime-prebuilt -t
rocketmq-server:latest server/"
log "rocketmq-server 镜像构建完成"
}
build_web() {
- info "构建 rocketmq-web 镜像..."
- docker build -t rocketmq-web:latest "$PROJECT_DIR/web"
+ local commit
+ commit="$(git -C "$PROJECT_DIR" rev-parse --short HEAD 2>/dev/null || echo
dev)"
+ info "🏗️ 构建 rocketmq-web 镜像(VITE_GIT_COMMIT=$commit)..."
+ run_remote "cd $REMOTE_PATH && docker build --build-arg
VITE_GIT_COMMIT=$commit -t rocketmq-web:latest web/"
log "rocketmq-web 镜像构建完成"
}
-# ─── 导出 & 传输 ───
-transfer_images() {
- mkdir -p "$TMP_DIR"
- local images=()
-
- if [[ "$TARGET" == "all" || "$TARGET" == "server" ]]; then
- images+=("rocketmq-server:latest")
- fi
- if [[ "$TARGET" == "all" || "$TARGET" == "web" ]]; then
- images+=("rocketmq-web:latest")
- fi
-
- local tarball="$TMP_DIR/rocketmq-studio-images.tar.gz"
- info "导出镜像: ${images[*]}"
- docker save "${images[@]}" | gzip > "$tarball"
- log "镜像导出完成 ($(du -h "$tarball" | cut -f1))"
-
- info "传输到 $REMOTE:$REMOTE_PATH ..."
- ssh "$REMOTE" "mkdir -p $REMOTE_PATH"
- scp "$tarball" "$REMOTE:$REMOTE_PATH/"
- log "传输完成"
-
- # 同步部署配置文件
- info "同步部署配置..."
- scp "$SCRIPT_DIR/docker-compose.yml" "$REMOTE:$REMOTE_PATH/"
- scp "$SCRIPT_DIR/nginx.conf" "$REMOTE:$REMOTE_PATH/"
- log "配置同步完成"
+start_services() {
+ local services=()
+ [[ "$TARGET" == "all" || "$TARGET" == "server" ]] &&
services+=("rocketmq-server")
+ [[ "$TARGET" == "all" || "$TARGET" == "web" ]] && services+=("rocketmq-web")
+ info "▶️ 启动容器: ${services[*]} ..."
+ run_remote "cd $REMOTE_PATH && docker compose --env-file .env -f
deploy/docker-compose.yml up -d ${services[*]}"
+ log "容器已启动"
}
-# ─── 远程加载 & 启动 ───
-deploy_remote() {
- local tarball="$REMOTE_PATH/rocketmq-studio-images.tar.gz"
-
- info "远程加载镜像..."
- ssh "$REMOTE" "gunzip -c $tarball | podman load"
- log "镜像加载完成"
-
- # 确保网络存在
- ssh "$REMOTE" "podman network exists $NETWORK 2>/dev/null || podman network
create $NETWORK"
-
+verify() {
+ info "🔍 验证部署..."
if [[ "$TARGET" == "all" || "$TARGET" == "server" ]]; then
- info "重启 rocketmq-server..."
- # Backend port is only exposed inside the podman network; the edge nginx
proxies /api/.
- ssh "$REMOTE" "
- podman rm -f rocketmq-server 2>/dev/null || true
- podman run -d \
- --name rocketmq-server \
- --network $NETWORK \
- --restart unless-stopped \
- -e STUDIO_AUTH_LOGIN_REQUIRED=\"${STUDIO_AUTH_LOGIN_REQUIRED:-true}\" \
- -e STUDIO_AUTH_ADMIN_USERNAME=\"${STUDIO_AUTH_ADMIN_USERNAME:-}\" \
- -e STUDIO_AUTH_ADMIN_PASSWORD=\"${STUDIO_AUTH_ADMIN_PASSWORD:-}\" \
- -e
STUDIO_METRICS_PROMETHEUS_BASE_URL=\"${STUDIO_METRICS_PROMETHEUS_BASE_URL:-}\" \
- -e
STUDIO_METRICS_PROMETHEUS_USERNAME=\"${STUDIO_METRICS_PROMETHEUS_USERNAME:-}\" \
- -e
STUDIO_METRICS_PROMETHEUS_PASSWORD=\"${STUDIO_METRICS_PROMETHEUS_PASSWORD:-}\" \
- -e
STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN=\"${STUDIO_METRICS_PROMETHEUS_BEARER_TOKEN:-}\"
\
- rocketmq-server:latest
- "
- log "rocketmq-server 已启动"
+ local i ok=""
+ for i in $(seq 1 30); do
+ if run_remote 'docker exec rocketmq-server curl -fsS
http://localhost:8888/actuator/health' 2>/dev/null | grep -q '"UP"'; then
+ ok="yes"; break
+ fi
+ sleep 5
+ done
+ [[ -n "$ok" ]] || err "server 健康检查未通过(docker logs rocketmq-server 查看原因)"
+ log "后端 actuator/health = UP"
fi
-
if [[ "$TARGET" == "all" || "$TARGET" == "web" ]]; then
- info "重启 rocketmq-web..."
- ssh "$REMOTE" "
- podman rm -f rocketmq-web 2>/dev/null || true
- podman run -d \
- --name rocketmq-web \
- --network $NETWORK \
- --restart unless-stopped \
- -p ${PUBLIC_PORT:-6789}:80 \
- rocketmq-web:latest
- "
- log "rocketmq-web 已启动"
- fi
-}
-
-# ─── 验证 ───
-verify() {
- info "验证部署..."
- sleep 3
-
- local status
- status=$(ssh "$REMOTE" "podman ps --format 'table
{{.Names}}\t{{.Status}}\t{{.Ports}}'" 2>&1)
- echo "$status"
-
- echo ""
- local http_code
- http_code=$(ssh "$REMOTE" "curl -sf -o /dev/null -w '%{http_code}'
--max-time 5 http://localhost:${PUBLIC_PORT:-6789}" 2>/dev/null || echo "000")
- if [[ "$http_code" == "200" ]]; then
- log "前端响应正常 (HTTP $http_code)"
- else
- warn "前端返回 HTTP $http_code"
+ local i code=""
+ for i in $(seq 1 12); do
+ code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10
"http://$REMOTE_HOST:$PUBLIC_PORT/" 2>/dev/null || true)
+ [[ "$code" == "200" ]] && break
+ sleep 5
+ done
+ [[ "$code" == "200" ]] && log "前端响应正常 (HTTP $code)" || warn "前端返回 HTTP
$code"
fi
-
- echo ""
- log "部署完成 → http://${REMOTE_HOST}:${PUBLIC_PORT:-6789}"
+ log "🎉 部署完成 → http://$REMOTE_HOST:$PUBLIC_PORT/"
}
-# ─── 清理临时文件 ───
-cleanup() {
- rm -rf "$TMP_DIR"
-}
+cleanup() { rm -f "$SRC_TAR"; }
+trap cleanup EXIT
-# ─── 主流程 ───
main() {
echo "═══════════════════════════════════════════"
- echo " RocketMQ Studio 部署"
+ echo " 🚢 RocketMQ Studio 部署"
echo " 目标: $TARGET | 远程: $REMOTE:$REMOTE_PATH"
echo "═══════════════════════════════════════════"
- echo ""
-
check_prereqs
-
- case "$TARGET" in
- server) build_server ;;
- web) build_web ;;
- all) build_server && build_web ;;
- *) err "用法: $0 [all|server|web]" ;;
- esac
-
- transfer_images
- deploy_remote
+ package_source
+ upload_source
+ [[ "$TARGET" == "all" || "$TARGET" == "server" ]] && build_server
+ [[ "$TARGET" == "all" || "$TARGET" == "web" ]] && build_web
+ start_services
verify
- cleanup
}
-trap cleanup EXIT
main
diff --git a/web/Dockerfile b/web/Dockerfile
index 4385020a..a42e7bd7 100644
--- a/web/Dockerfile
+++ b/web/Dockerfile
@@ -4,7 +4,7 @@ ARG VITE_GIT_COMMIT
ENV VITE_GIT_COMMIT=${VITE_GIT_COMMIT}
WORKDIR /app
COPY package*.json ./
-RUN npm ci
+RUN npm ci --registry=https://registry.npmmirror.com
COPY . .
RUN npm run build
diff --git a/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
b/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
new file mode 100644
index 00000000..79ce408c
--- /dev/null
+++ b/web/src/pages/settings/__tests__/AiAssistantTab.test.tsx
@@ -0,0 +1,151 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { App } from 'antd';
+import { act, render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import { LangProvider } from '../../../i18n/LangContext';
+import { AiAssistantTab } from '../AiAssistantTab';
+
+const llmApiMocks = vi.hoisted(() => ({
+ getLlmConfig: vi.fn(),
+ saveLlmConfig: vi.fn(),
+ testLlmConnection: vi.fn(),
+ getLlmModels: vi.fn(),
+}));
+
+vi.mock('../../../api/llm', () => llmApiMocks);
+
+beforeAll(() => {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+});
+
+const renderPage = () =>
+ render(
+ <App>
+ <LangProvider>
+ <AiAssistantTab />
+ </LangProvider>
+ </App>,
+ );
+
+describe('AiAssistantTab', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ llmApiMocks.getLlmConfig.mockResolvedValue({
+ provider: 'tongyi',
+ apiBase: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
+ model: 'qwen3.8-max',
+ maxTokens: 4096,
+ temperature: 0.7,
+ enabled: true,
+ apiKeyConfigured: true,
+ });
+ llmApiMocks.getLlmModels.mockResolvedValue({
+ status: 0,
+ data: [{ id: 'qwen3.8-max' }, { id: 'qwen-max' }],
+ });
+ llmApiMocks.saveLlmConfig.mockResolvedValue({ status: 0 });
+ llmApiMocks.testLlmConnection.mockResolvedValue({ status: 0, msg: 'ok' });
+ });
+
+ it('loads the saved config and shows the configured-key badge', async () => {
+ renderPage();
+
+ expect(await screen.findByText('密钥已配置')).toBeInTheDocument();
+ expect(screen.getByText('qwen3.8-max')).toBeInTheDocument();
+ });
+
+ it('saves without sending an apiKey when the input stays empty', async () =>
{
+ const user = userEvent.setup();
+ renderPage();
+
+ await screen.findByText('密钥已配置');
+ await user.click(screen.getByRole('button', { name: /保\s*存/ }));
+
+ await waitFor(() =>
expect(llmApiMocks.saveLlmConfig).toHaveBeenCalledTimes(1));
+ const payload = llmApiMocks.saveLlmConfig.mock.calls[0][0];
+ expect(payload).toMatchObject({ provider: 'tongyi', model: 'qwen3.8-max'
});
+ expect(payload.apiKey).toBeUndefined();
+ });
+
+ it('submits the Azure deployment fields required by the backend', async ()
=> {
+ const user = userEvent.setup();
+ const { container } = renderPage();
+
+ await waitFor(() =>
expect(llmApiMocks.getLlmConfig).toHaveBeenCalledTimes(1));
+ await user.click(screen.getAllByRole('combobox')[1]);
+ await user.click(
+ await screen.findByText('Azure OpenAI', { selector:
'.ant-select-item-option-content' }),
+ );
+
+ const apiBase = await screen.findByLabelText('API Base URL');
+ await user.type(apiBase, 'https://example.openai.azure.com/openai');
+ await user.type(screen.getByLabelText('Azure Deployment Name'),
'production-gpt');
+ expect(screen.getByLabelText('Azure API
Version')).toHaveValue('2024-02-15-preview');
+ const saveButton = container.querySelector('.ant-btn-primary');
+ expect(saveButton).not.toBeNull();
+ await user.click(saveButton as HTMLButtonElement);
+
+ await waitFor(() =>
expect(llmApiMocks.saveLlmConfig).toHaveBeenCalledTimes(1));
+ expect(llmApiMocks.saveLlmConfig.mock.calls[0][0]).toMatchObject({
+ provider: 'azure',
+ apiBase: 'https://example.openai.azure.com/openai',
+ deploymentName: 'production-gpt',
+ apiVersion: '2024-02-15-preview',
+ });
+
expect(llmApiMocks.saveLlmConfig.mock.calls[0][0].awsRegion).toBeUndefined();
+ });
+
+ it('ignores a connection result after the tested configuration changes',
async () => {
+ let resolveTest!: (result: { status: number; msg: string }) => void;
+ llmApiMocks.testLlmConnection.mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ resolveTest = resolve;
+ }),
+ );
+ const user = userEvent.setup();
+ renderPage();
+
+ await screen.findByText('密钥已配置');
+ await user.click(screen.getByRole('button', { name: /测试连接/ }));
+ await waitFor(() =>
expect(llmApiMocks.testLlmConnection).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getAllByRole('combobox')[1]);
+ await user.click(
+ await screen.findByText('OpenAI', { selector:
'.ant-select-item-option-content' }),
+ );
+ await act(async () => resolveTest({ status: 0, msg: 'old provider
succeeded' }));
+
+ expect(screen.queryByText('old provider
succeeded')).not.toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /测试连接/
})).not.toHaveClass('ant-btn-loading');
+ });
+});