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');
+  });
+});

Reply via email to