mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-26 01:34:51 +08:00
fix(settings): serialize tool lifecycle writes and isolate Windows scripts (#7725)
Run each Windows tool lifecycle script from its own temp directory so concurrent calls can no longer overwrite or delete each other's .bat file. Add a backend ToolLifecycleCoordinator: a global execution lock serializes all install/update writes (pnpm/bun share one global manifest, and concurrent 'add -g' silently drops one side's entry), and a per-tool try-lock rejects duplicate submissions even after the About page remounts. Locks move into the blocking task so a cancelled caller cannot release them while the installer is still running. The About page now locks per tool instead of globally, queues multi-install confirmation plans, ignores double clicks on the confirm dialog, and shows the full error text in a tooltip.
This commit is contained in:
1 parent
74f4614340
commit
482f57b335
4 files changed
+905
-185
No files matched your search
+224
-12
@@ -8,6 +8,7 @@ use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use tauri::State;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
@@ -177,6 +178,63 @@ pub async fn get_tool_versions(
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
// 不同工具仍可能共用 pnpm/npm 的全局目录。先保守地串行化所有安装写入,
|
||||
// 并在排队前锁定工具,防止页面重挂或另一 IPC 调用重复提交同一工具。
|
||||
struct ToolLifecycleCoordinator {
|
||||
tools: HashMap<&'static str, Arc<tokio::sync::Mutex<()>>>,
|
||||
execution: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl Default for ToolLifecycleCoordinator {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
tools: VALID_TOOLS
|
||||
.iter()
|
||||
.map(|&tool| (tool, Arc::new(tokio::sync::Mutex::new(()))))
|
||||
.collect(),
|
||||
execution: Arc::new(tokio::sync::Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolLifecycleCoordinator {
|
||||
async fn run<F>(&self, tools: Vec<&'static str>, operation: F) -> Result<(), String>
|
||||
where
|
||||
F: FnOnce(&[&str]) -> Result<(), String> + Send + 'static,
|
||||
{
|
||||
let tool_guards = tools
|
||||
.iter()
|
||||
.map(|tool| {
|
||||
self.tools
|
||||
.get(tool)
|
||||
.ok_or_else(|| format!("Unsupported tool action target: {tool}"))?
|
||||
.clone()
|
||||
.try_lock_owned()
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"{} already has an installation or update in progress",
|
||||
tool_display_name(tool)
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let execution_guard = self.execution.clone().lock_owned().await;
|
||||
|
||||
// 必须把锁移进 blocking 任务:即使等待它的 IPC future 被取消,
|
||||
// 子进程仍会继续运行,直到它真正退出前都不能允许下一次写入。
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _tool_guards = tool_guards;
|
||||
let _execution_guard = execution_guard;
|
||||
operation(&tools)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("tool lifecycle task join error: {e}"))?
|
||||
}
|
||||
}
|
||||
|
||||
static TOOL_LIFECYCLE: Lazy<ToolLifecycleCoordinator> =
|
||||
Lazy::new(ToolLifecycleCoordinator::default);
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn run_tool_lifecycle_action(
|
||||
tools: Vec<String>,
|
||||
@@ -194,15 +252,14 @@ pub async fn run_tool_lifecycle_action(
|
||||
ToolLifecycleAction::Update => "tool_update",
|
||||
};
|
||||
|
||||
// build 阶段含锚定探测(对每个工具跑 `--version` 定位命令行实际命中那处),
|
||||
// 与执行一并放进 blocking 线程,避免阻塞 async runtime。
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let command_line =
|
||||
build_tool_lifecycle_command(&requested, action, wsl_shell_by_tool.as_ref())?;
|
||||
run_tool_lifecycle_silently(&command_line, label)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("tool lifecycle task join error: {e}"))?
|
||||
// 排队结束后再探测安装目标,与执行一起持锁,避免读到另一升级的中间状态。
|
||||
TOOL_LIFECYCLE
|
||||
.run(requested, move |tools| {
|
||||
let command_line =
|
||||
build_tool_lifecycle_command(tools, action, wsl_shell_by_tool.as_ref())?;
|
||||
run_tool_lifecycle_silently(&command_line, label)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 静默执行工具安装/更新脚本:直接捕获子进程输出并阻塞到命令真正结束,
|
||||
@@ -234,8 +291,12 @@ fn run_tool_lifecycle_silently(command_line: &str, label: &str) -> Result<(), St
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
|
||||
let bat_file =
|
||||
std::env::temp_dir().join(format!("cc_switch_{}_{}.bat", label, std::process::id()));
|
||||
// 每次调用使用独立目录,避免同进程并发升级时覆盖或删除另一工具的脚本。
|
||||
let script_dir = tempfile::Builder::new()
|
||||
.prefix("cc_switch_lifecycle_")
|
||||
.tempdir()
|
||||
.map_err(|e| format!("创建批处理目录失败: {e}"))?;
|
||||
let bat_file = script_dir.path().join(format!("{label}.bat"));
|
||||
std::fs::write(&bat_file, command_line).map_err(|e| format!("写入批处理文件失败: {e}"))?;
|
||||
|
||||
let output = Command::new("cmd")
|
||||
@@ -243,7 +304,6 @@ fn run_tool_lifecycle_silently(command_line: &str, label: &str) -> Result<(), St
|
||||
.arg(&bat_file)
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
let _ = std::fs::remove_file(&bat_file);
|
||||
|
||||
finish_lifecycle_output(&output.map_err(|e| format!("启动安装进程失败: {e}"))?)
|
||||
}
|
||||
@@ -5251,6 +5311,158 @@ mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_coordinator_serializes_writes_and_rejects_duplicate_tools() {
|
||||
let coordinator = Arc::new(ToolLifecycleCoordinator::default());
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
let output = output_dir.path().join("installed.txt");
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
|
||||
let first = {
|
||||
let coordinator = coordinator.clone();
|
||||
let output = output.clone();
|
||||
tokio::spawn(async move {
|
||||
coordinator
|
||||
.run(vec!["codex"], move |_| {
|
||||
started_tx.send(()).unwrap();
|
||||
finish_rx.recv().unwrap();
|
||||
std::fs::write(output, "codex").unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
started_rx.await.unwrap();
|
||||
|
||||
// 独立调用者(例如重挂后的页面)不能重复启动正在执行的工具。
|
||||
assert!(coordinator
|
||||
.run(vec!["codex"], |_| panic!("duplicate must not execute"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("in progress"));
|
||||
// 批次部分取锁失败时,已取得的其他工具锁也必须释放。
|
||||
assert!(coordinator
|
||||
.run(vec!["claude", "codex"], |_| panic!(
|
||||
"batch must not execute"
|
||||
))
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
let second_output = output.clone();
|
||||
let second = coordinator.run(vec!["claude"], move |_| {
|
||||
assert_eq!(std::fs::read_to_string(&second_output).unwrap(), "codex");
|
||||
std::fs::write(second_output, "codex,claude").unwrap();
|
||||
Ok(())
|
||||
});
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(second.as_mut()).is_pending());
|
||||
assert!(!output.exists(), "first write has not finished yet");
|
||||
assert!(coordinator
|
||||
.run(vec!["claude"], |_| panic!(
|
||||
"queued duplicate must not execute"
|
||||
))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("in progress"));
|
||||
|
||||
finish_tx.send(()).unwrap();
|
||||
first.await.unwrap().unwrap();
|
||||
second.await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(output).unwrap(), "codex,claude");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_coordinator_keeps_running_locks_when_caller_is_cancelled() {
|
||||
let coordinator = Arc::new(ToolLifecycleCoordinator::default());
|
||||
let (started_tx, started_rx) = tokio::sync::oneshot::channel();
|
||||
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
|
||||
let first = {
|
||||
let coordinator = coordinator.clone();
|
||||
tokio::spawn(async move {
|
||||
coordinator
|
||||
.run(vec!["codex"], move |_| {
|
||||
started_tx.send(()).unwrap();
|
||||
finish_rx.recv().unwrap();
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
started_rx.await.unwrap();
|
||||
first.abort();
|
||||
assert!(first.await.unwrap_err().is_cancelled());
|
||||
assert!(coordinator
|
||||
.run(vec!["codex"], |_| panic!(
|
||||
"running duplicate must not execute"
|
||||
))
|
||||
.await
|
||||
.is_err());
|
||||
|
||||
{
|
||||
let cancelled = coordinator.run(vec!["claude"], |_| {
|
||||
panic!("cancelled queued operation must not execute")
|
||||
});
|
||||
tokio::pin!(cancelled);
|
||||
assert!(futures::poll!(cancelled.as_mut()).is_pending());
|
||||
}
|
||||
// 尚未开始的排队请求取消后可以重试,但仍须等待正在运行的子任务退出。
|
||||
let retry = coordinator.run(vec!["claude"], |_| Ok(()));
|
||||
tokio::pin!(retry);
|
||||
assert!(futures::poll!(retry.as_mut()).is_pending());
|
||||
finish_tx.send(()).unwrap();
|
||||
retry.await.unwrap();
|
||||
coordinator.run(vec!["codex"], |_| Ok(())).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lifecycle_coordinator_releases_locks_after_error_or_panic() {
|
||||
let coordinator = ToolLifecycleCoordinator::default();
|
||||
assert_eq!(
|
||||
coordinator
|
||||
.run(vec!["claude"], |_| Err("installer failed".to_string()))
|
||||
.await,
|
||||
Err("installer failed".to_string())
|
||||
);
|
||||
coordinator.run(vec!["claude"], |_| Ok(())).await.unwrap();
|
||||
assert!(coordinator
|
||||
.run(vec!["claude"], |_| panic!("installer panicked"))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.contains("tool lifecycle task join error"));
|
||||
coordinator.run(vec!["claude"], |_| Ok(())).await.unwrap();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn concurrent_lifecycle_runs_use_independent_scripts() {
|
||||
let output_dir = tempfile::tempdir().unwrap();
|
||||
let outputs = [
|
||||
output_dir.path().join("first.txt"),
|
||||
output_dir.path().join("second.txt"),
|
||||
];
|
||||
let barrier = std::sync::Barrier::new(2);
|
||||
std::thread::scope(|scope| {
|
||||
for output in &outputs {
|
||||
let barrier = &barrier;
|
||||
scope.spawn(move || {
|
||||
let command = format!("@echo off\r\n>\"{}\" echo %~f0\r\n", output.display());
|
||||
barrier.wait();
|
||||
run_tool_lifecycle_silently(&command, "tool_update").unwrap();
|
||||
});
|
||||
}
|
||||
});
|
||||
let script_paths =
|
||||
outputs.map(|output| PathBuf::from(std::fs::read_to_string(output).unwrap().trim()));
|
||||
assert_ne!(script_paths[0], script_paths[1]);
|
||||
for script_path in script_paths {
|
||||
assert!(!script_path.exists(), "temporary script must be cleaned up");
|
||||
assert!(
|
||||
!script_path.parent().unwrap().exists(),
|
||||
"temporary directory must be cleaned up"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测 helper 正常路径:spawn(含 pre_exec setsid)能启动、输出能捕获。
|
||||
/// `/bin/echo --version` 在 macOS/Linux 均即刻成功退出。
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
Copy,
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Portal as TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -73,6 +80,12 @@ const TOOL_NAMES = [
|
||||
type ToolName = (typeof TOOL_NAMES)[number];
|
||||
type ToolLifecycleAction = "install" | "update";
|
||||
|
||||
interface PendingUpgrade {
|
||||
toolNames: ToolName[];
|
||||
plans: ToolInstallationReport[];
|
||||
fromBatchEntry: boolean;
|
||||
}
|
||||
|
||||
type WslShellPreference = {
|
||||
wslShell?: string | null;
|
||||
wslShellFlag?: string | null;
|
||||
@@ -253,9 +266,6 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
const [isLoadingTools, setIsLoadingTools] = useState(
|
||||
() => toolVersionsCache === null,
|
||||
);
|
||||
const [toolActions, setToolActions] = useState<
|
||||
Partial<Record<ToolName, ToolLifecycleAction>>
|
||||
>({});
|
||||
const [batchAction, setBatchAction] = useState<ToolLifecycleAction | null>(
|
||||
null,
|
||||
);
|
||||
@@ -274,25 +284,19 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
Partial<Record<ToolName, ToolInstallation[]>>
|
||||
>({});
|
||||
const [isDiagnosingAll, setIsDiagnosingAll] = useState(false);
|
||||
// 升级前探测到「多处安装需确认」时暂存:toolNames=本次要升级的全部工具,
|
||||
// plans=其中需要确认的(≥2 处)那些。用户确认后对 toolNames 整体执行升级。
|
||||
// fromBatchEntry=是否来自「全部升级」入口:确认后执行期需据此补 batchAction
|
||||
// (executeRun 按数量判 isBatch,「全部升级(1)」会漏掉顶部按钮的 spinner)。
|
||||
const [pendingUpgrade, setPendingUpgrade] = useState<{
|
||||
toolNames: ToolName[];
|
||||
plans: ToolInstallationReport[];
|
||||
fromBatchEntry: boolean;
|
||||
} | null>(null);
|
||||
// 升级 preflight(probe 阶段)的 in-flight 工具集合。
|
||||
// probeToolInstallations 是个 1-3 秒级别的跨进程探测(对每个工具跑 --version + canonicalize),
|
||||
// 在它返回之前 toolActions / batchAction 都还没被置位 → 按钮不会 disabled → 用户快速双击
|
||||
// 会并发开两轮 probe,各自再触发 executeRun(并发的 `npm i -g` / 官方 installer,写冲突)。
|
||||
// 把 probe 期间的工具登记在这里、纳入 isAnyBusy 派生,关掉这个并发窗口。
|
||||
// 用 Set 而非 boolean:单卡片升级 & 批量升级可能在不同工具上独立 preflight,
|
||||
// 精确反映到各自卡片按钮的 disabled。
|
||||
const [preflightTools, setPreflightTools] = useState<Set<ToolName>>(
|
||||
() => new Set(),
|
||||
// 并发探测可能同时要求确认,按到达顺序排队,避免覆盖尚未处理的升级计划。
|
||||
const pendingUpgrades = useRef<PendingUpgrade[]>([]);
|
||||
const [pendingUpgrade, setPendingUpgrade] = useState<PendingUpgrade | null>(
|
||||
null,
|
||||
);
|
||||
// 每个工具独立锁定,覆盖预检、等待确认、执行与版本刷新。
|
||||
// ref 同步加锁,避免 React 提交 state 前的重复点击启动同一工具两次。
|
||||
const busyToolsRef = useRef(new Set<ToolName>());
|
||||
const [busyTools, setBusyTools] = useState<Set<ToolName>>(() => new Set());
|
||||
const releaseTools = useCallback((toolNames: ToolName[]) => {
|
||||
toolNames.forEach((name) => busyToolsRef.current.delete(name));
|
||||
setBusyTools(new Set(busyToolsRef.current));
|
||||
}, []);
|
||||
|
||||
const toolVersionByName = useMemo(() => {
|
||||
return new Map(toolVersions.map((tool) => [tool.name, tool]));
|
||||
@@ -302,9 +306,13 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
() =>
|
||||
TOOL_NAMES.filter((toolName) => {
|
||||
const tool = toolVersionByName.get(toolName);
|
||||
return isUpdateAvailable(tool?.version, tool?.latest_version);
|
||||
return (
|
||||
!busyTools.has(toolName) &&
|
||||
!loadingTools[toolName] &&
|
||||
isUpdateAvailable(tool?.version, tool?.latest_version)
|
||||
);
|
||||
}),
|
||||
[toolVersionByName],
|
||||
[toolVersionByName, busyTools, loadingTools],
|
||||
);
|
||||
|
||||
const refreshToolVersions = useCallback(
|
||||
@@ -596,16 +604,12 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// 实际执行安装/升级的串行循环(已通过任何必要的确认后才调用)。
|
||||
// 已通过必要的确认后,各工具独立提交、刷新和解锁;安装写入由后端串行调度。
|
||||
const executeRun = useCallback(
|
||||
async (toolNames: ToolName[], action: ToolLifecycleAction) => {
|
||||
const isBatch = toolNames.length > 1;
|
||||
if (isBatch) {
|
||||
setBatchAction(action);
|
||||
}
|
||||
|
||||
// 逐工具串行执行:每个工具独立成败、独立刷新版本,一个失败不会连坐
|
||||
// 后续工具(后端把整批拼成单脚本 + set -e,会在首个失败处中止整批)。
|
||||
// 每个工具独立调用后端,一个失败不会中断其它工具。
|
||||
// soft=true 表示"命令成功执行但结果仍需用户介入"(版本没变/装上却跑不起来),
|
||||
// 与命令本身报错(soft=false)区别对待:前者不算硬失败,toast 降级为 warning。
|
||||
const failures: {
|
||||
@@ -616,86 +620,81 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
}[] = [];
|
||||
let succeeded = 0;
|
||||
|
||||
for (const toolName of toolNames) {
|
||||
setToolActions((prev) => ({ ...prev, [toolName]: action }));
|
||||
try {
|
||||
const previousTool = toolVersionByName.get(toolName);
|
||||
const previousVersion = previousTool?.version ?? null;
|
||||
const previousLatestVersion = previousTool?.latest_version ?? null;
|
||||
await Promise.all(
|
||||
toolNames.map(async (toolName) => {
|
||||
try {
|
||||
const previousTool = toolVersionByName.get(toolName);
|
||||
const previousVersion = previousTool?.version ?? null;
|
||||
const previousLatestVersion = previousTool?.latest_version ?? null;
|
||||
|
||||
await settingsApi.runToolLifecycleAction(
|
||||
[toolName],
|
||||
action,
|
||||
wslShellByTool,
|
||||
);
|
||||
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
|
||||
const refreshed = await refreshToolVersions(
|
||||
[toolName],
|
||||
wslShellByTool,
|
||||
);
|
||||
const tool = refreshed.find((t) => t.name === toolName);
|
||||
if (tool?.version) {
|
||||
const latestVersion = tool.latest_version ?? previousLatestVersion;
|
||||
const versionUnchangedAfterUpdate =
|
||||
action === "update" &&
|
||||
Boolean(previousVersion) &&
|
||||
tool.version === previousVersion &&
|
||||
isUpdateAvailable(tool.version, latestVersion);
|
||||
await settingsApi.runToolLifecycleAction(
|
||||
[toolName],
|
||||
action,
|
||||
wslShellByTool,
|
||||
);
|
||||
// 静默执行真正结束后刷新该工具版本,卡片立即反映结果。
|
||||
const refreshed = await refreshToolVersions(
|
||||
[toolName],
|
||||
wslShellByTool,
|
||||
);
|
||||
const tool = refreshed.find((t) => t.name === toolName);
|
||||
if (tool?.version) {
|
||||
const latestVersion =
|
||||
tool.latest_version ?? previousLatestVersion;
|
||||
const versionUnchangedAfterUpdate =
|
||||
action === "update" &&
|
||||
Boolean(previousVersion) &&
|
||||
tool.version === previousVersion &&
|
||||
isUpdateAvailable(tool.version, latestVersion);
|
||||
|
||||
if (versionUnchangedAfterUpdate) {
|
||||
// 有些上游 updater 会在未实际改动版本时仍返回 0。这里用刷新后的
|
||||
// 当前版本 + latest_version 再确认一次,避免给用户误报升级成功。
|
||||
if (versionUnchangedAfterUpdate) {
|
||||
// 有些上游 updater 会在未实际改动版本时仍返回 0。这里用刷新后的
|
||||
// 当前版本 + latest_version 再确认一次,避免给用户误报升级成功。
|
||||
failures.push({
|
||||
toolName,
|
||||
detail: t("settings.toolActionVersionUnchanged", {
|
||||
version: tool.version,
|
||||
latest: latestVersion ?? t("common.unknown"),
|
||||
}),
|
||||
soft: true,
|
||||
kind: "versionUnchanged",
|
||||
});
|
||||
void diagnoseToolSilently(toolName);
|
||||
} else {
|
||||
succeeded += 1;
|
||||
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
|
||||
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
|
||||
if (action === "update") {
|
||||
void diagnoseToolSilently(toolName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
|
||||
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
|
||||
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
|
||||
const detail =
|
||||
tool?.error?.trim() || t("settings.toolNotRunnable");
|
||||
failures.push({
|
||||
toolName,
|
||||
detail: t("settings.toolActionVersionUnchanged", {
|
||||
version: tool.version,
|
||||
latest: latestVersion ?? t("common.unknown"),
|
||||
}),
|
||||
detail,
|
||||
soft: true,
|
||||
kind: "versionUnchanged",
|
||||
kind: "notRunnable",
|
||||
});
|
||||
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
|
||||
void diagnoseToolSilently(toolName);
|
||||
} else {
|
||||
succeeded += 1;
|
||||
// 升级成功后无条件补诊:版本没变多半被另一处遮蔽,版本变了另一处也可能仍在,
|
||||
// 两种都要刷新冲突展示(diagnoseToolSilently 无冲突时会自动清旧)。
|
||||
if (action === "update") {
|
||||
void diagnoseToolSilently(toolName);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 命令退出码为 0、但刷新后仍探不到版本:多半是"装上了却跑不起来"
|
||||
// (如 openclaw 要求更高的 Node 版本)。refreshToolVersions 的 merge 已把
|
||||
// version 置空并写入后端 error,这里只需归类为软失败并展示原因。
|
||||
const detail = tool?.error?.trim() || t("settings.toolNotRunnable");
|
||||
failures.push({
|
||||
toolName,
|
||||
detail,
|
||||
soft: true,
|
||||
kind: "notRunnable",
|
||||
});
|
||||
// 装了却跑不起来同样可能源于多处安装,自动诊断帮用户定位。
|
||||
void diagnoseToolSilently(toolName);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[AboutSection] Failed to run tool action for ${toolName}`,
|
||||
error,
|
||||
);
|
||||
const detail = extractErrorMessage(error) || String(error);
|
||||
failures.push({ toolName, detail, soft: false });
|
||||
} finally {
|
||||
releaseTools([toolName]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[AboutSection] Failed to run tool action for ${toolName}`,
|
||||
error,
|
||||
);
|
||||
const detail = extractErrorMessage(error) || String(error);
|
||||
failures.push({ toolName, detail, soft: false });
|
||||
} finally {
|
||||
setToolActions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[toolName];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isBatch) {
|
||||
setBatchAction(null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const actionLabel =
|
||||
action === "install"
|
||||
@@ -766,49 +765,33 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
toolVersionByName,
|
||||
refreshToolVersions,
|
||||
diagnoseToolSilently,
|
||||
releaseTools,
|
||||
],
|
||||
);
|
||||
|
||||
// 升级/安装的统一入口锁。所有动作在入口处先登记 preflight、出口处解锁;
|
||||
// 这层锁覆盖三个否则会漏掉的窗口:
|
||||
// ① update 的 probeToolInstallations 阶段(1-3 秒跨进程,executeRun 之前);
|
||||
// ② executeRun 内部 setToolActions 落到 React commit 前的几个 microtask;
|
||||
// ③ install 直接进 executeRun 的同一段 microtask 窗口。
|
||||
// 早退检查避免同一工具在 toolActions / preflight 已登记时被重复入栈——批量场景里
|
||||
// 只要有一个工具被锁,整批不开新一轮,因为后端 set -e 串行的语义假设是「一次性
|
||||
// 单脚本」,跨两次 IPC 并发会破坏它。
|
||||
// 单独升级与全部升级共用按工具加锁的入口,自动跳过已在处理的工具。
|
||||
const handleRunToolAction = useCallback(
|
||||
async (
|
||||
toolNames: ToolName[],
|
||||
requestedTools: ToolName[],
|
||||
action: ToolLifecycleAction,
|
||||
options?: { fromBatchEntry?: boolean },
|
||||
) => {
|
||||
const toolNames = requestedTools.filter(
|
||||
(name) => !busyToolsRef.current.has(name),
|
||||
);
|
||||
if (toolNames.length === 0) return;
|
||||
if (
|
||||
toolNames.some(
|
||||
(name) => preflightTools.has(name) || toolActions[name] !== undefined,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// 入栈 preflight,按钮立刻 disabled。new Set(prev) 是不可变更新(直接 mutate
|
||||
// 原 Set 会让 React 复用引用、跳过 re-render);finally 块负责异常路径解锁。
|
||||
setPreflightTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
toolNames.forEach((name) => next.add(name));
|
||||
return next;
|
||||
});
|
||||
// 「全部升级」入口在 probe 阶段就点亮 spinner:batchAction 若只由 executeRun
|
||||
// 置位,预检的数秒(探测超时兜底时更久)里按钮只 disabled 不转圈,观感如
|
||||
// "点了没反应"。按入口来源而非 toolNames.length 判定——「全部升级(1)」
|
||||
// 传入长度 1,按数量判会漏。executeRun 内部会再置位/清除,重复 set 无害;
|
||||
// 弹确认框路径由 finally 清掉,对话框期间不转圈。
|
||||
toolNames.forEach((name) => busyToolsRef.current.add(name));
|
||||
setBusyTools(new Set(busyToolsRef.current));
|
||||
// 锁移交给 executeRun 或确认队列后,由其负责释放;入口只释放未移交的工具。
|
||||
let toolsToRelease = toolNames;
|
||||
// 全部升级即使只剩一个工具,也在预检和执行期间显示进度。
|
||||
const fromBatchEntry = options?.fromBatchEntry ?? false;
|
||||
if (fromBatchEntry) {
|
||||
setBatchAction(action);
|
||||
}
|
||||
try {
|
||||
if (action === "install") {
|
||||
toolsToRelease = [];
|
||||
await executeRun(toolNames, action);
|
||||
return;
|
||||
}
|
||||
@@ -818,6 +801,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
} catch (error) {
|
||||
// 探测失败不应阻断升级:退回直接执行(等同旧行为)。
|
||||
console.error("[AboutSection] probeToolInstallations failed", error);
|
||||
toolsToRelease = [];
|
||||
await executeRun(toolNames, action);
|
||||
return;
|
||||
}
|
||||
@@ -843,6 +827,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
(name) => !unmanaged.some((r) => r.tool === name),
|
||||
);
|
||||
if (runnableTools.length === 0) return;
|
||||
toolsToRelease = toolNames.filter(
|
||||
(name) => !runnableTools.includes(name),
|
||||
);
|
||||
const needConfirm = reports.filter(
|
||||
(r) => r.needs_confirmation && !r.unmanaged,
|
||||
);
|
||||
@@ -850,54 +837,50 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
await executeRun(runnableTools, action);
|
||||
return;
|
||||
}
|
||||
setPendingUpgrade({
|
||||
pendingUpgrades.current.push({
|
||||
toolNames: runnableTools,
|
||||
plans: needConfirm,
|
||||
fromBatchEntry,
|
||||
});
|
||||
setPendingUpgrade(pendingUpgrades.current[0]);
|
||||
} finally {
|
||||
if (fromBatchEntry) {
|
||||
setBatchAction(null);
|
||||
}
|
||||
setPreflightTools((prev) => {
|
||||
const next = new Set(prev);
|
||||
toolNames.forEach((name) => next.delete(name));
|
||||
return next;
|
||||
});
|
||||
releaseTools(toolsToRelease);
|
||||
}
|
||||
},
|
||||
[executeRun, preflightTools, toolActions, t],
|
||||
[executeRun, releaseTools, t],
|
||||
);
|
||||
|
||||
const handleConfirmUpgrade = useCallback(() => {
|
||||
if (pendingUpgrade) {
|
||||
const { toolNames, fromBatchEntry } = pendingUpgrade;
|
||||
// executeRun 按数量判 isBatch,「全部升级(1)」确认后执行期会漏置 batchAction,
|
||||
// 由入口来源在这里补上;对 >1 的场景 executeRun 会重复置位/清除,无害。
|
||||
if (fromBatchEntry) {
|
||||
setBatchAction("update");
|
||||
}
|
||||
void executeRun(toolNames, "update").finally(() => {
|
||||
if (fromBatchEntry) {
|
||||
setBatchAction(null);
|
||||
}
|
||||
});
|
||||
if (!pendingUpgrade || pendingUpgrades.current[0] !== pendingUpgrade)
|
||||
return;
|
||||
pendingUpgrades.current.shift();
|
||||
setPendingUpgrade(pendingUpgrades.current[0] ?? null);
|
||||
const { toolNames, fromBatchEntry } = pendingUpgrade;
|
||||
if (fromBatchEntry) {
|
||||
setBatchAction("update");
|
||||
}
|
||||
setPendingUpgrade(null);
|
||||
void executeRun(toolNames, "update").finally(() => {
|
||||
if (fromBatchEntry) {
|
||||
setBatchAction(null);
|
||||
}
|
||||
});
|
||||
}, [pendingUpgrade, executeRun]);
|
||||
|
||||
const handleCancelUpgrade = useCallback(() => setPendingUpgrade(null), []);
|
||||
const handleCancelUpgrade = useCallback(() => {
|
||||
if (!pendingUpgrade || pendingUpgrades.current[0] !== pendingUpgrade)
|
||||
return;
|
||||
pendingUpgrades.current.shift();
|
||||
setPendingUpgrade(pendingUpgrades.current[0] ?? null);
|
||||
releaseTools(pendingUpgrade.toolNames);
|
||||
}, [pendingUpgrade, releaseTools]);
|
||||
|
||||
const displayVersion = version ?? t("common.unknown");
|
||||
|
||||
// 任一安装/升级进行中(批量或单工具)即视为忙碌:用于禁用所有操作按钮,
|
||||
// 避免并发触发多个 npm/pip 全局写入造成冲突。
|
||||
// preflightTools 覆盖升级前的 probe 阶段——那段在 executeRun 之前、toolActions
|
||||
// 还没置位,如果不算进 busy 会留出 1-3 秒的并发触发窗口。
|
||||
const isAnyBusy =
|
||||
Boolean(batchAction) ||
|
||||
Object.keys(toolActions).length > 0 ||
|
||||
preflightTools.size > 0;
|
||||
// 全量刷新和诊断仍等待升级结束;各工具的操作只受自身忙碌状态影响。
|
||||
const isAnyBusy = busyTools.size > 0;
|
||||
|
||||
return (
|
||||
<motion.section
|
||||
@@ -1099,7 +1082,9 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
isLoadingTools || isAnyBusy || updatableToolNames.length === 0
|
||||
isLoadingTools ||
|
||||
Boolean(batchAction) ||
|
||||
updatableToolNames.length === 0
|
||||
}
|
||||
>
|
||||
{batchAction === "update" ? (
|
||||
@@ -1143,7 +1128,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
: isOutdated
|
||||
? "update"
|
||||
: null;
|
||||
const runningAction = toolActions[toolName];
|
||||
const isToolBusy = busyTools.has(toolName);
|
||||
const title = tool?.version || tool?.error || t("common.unknown");
|
||||
const conflicts = toolDiagnostics[toolName];
|
||||
|
||||
@@ -1218,9 +1203,27 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
</span>
|
||||
</div>
|
||||
{!isToolVersionLoading && !tool?.version && tool?.error && (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{tool.error}
|
||||
</div>
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
tabIndex={0}
|
||||
className="cursor-help truncate text-[11px] text-muted-foreground"
|
||||
>
|
||||
{tool.error}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[min(24rem,var(--radix-tooltip-content-available-height))] max-w-[min(36rem,calc(100vw_-_2rem))] select-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere]"
|
||||
>
|
||||
{tool.error}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1229,7 +1232,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
<Select
|
||||
value={wslShellByTool[toolName]?.wslShell || "auto"}
|
||||
onValueChange={(v) => handleToolShellChange(toolName, v)}
|
||||
disabled={isToolVersionLoading || isAnyBusy}
|
||||
disabled={isToolVersionLoading || isToolBusy}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[82px] text-xs">
|
||||
<SelectValue />
|
||||
@@ -1248,7 +1251,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
onValueChange={(v) =>
|
||||
handleToolShellFlagChange(toolName, v)
|
||||
}
|
||||
disabled={isToolVersionLoading || isAnyBusy}
|
||||
disabled={isToolVersionLoading || isToolBusy}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-[82px] text-xs">
|
||||
<SelectValue />
|
||||
@@ -1300,11 +1303,10 @@ export function AboutSection({ isPortable }: AboutSectionProps) {
|
||||
variant={action === "install" ? "outline" : "default"}
|
||||
className="h-7 gap-1.5 text-xs"
|
||||
onClick={() => handleRunToolAction([toolName], action)}
|
||||
disabled={isToolVersionLoading || isAnyBusy}
|
||||
disabled={isToolVersionLoading || isToolBusy}
|
||||
>
|
||||
{/* preflight(升级前冲突探测)阶段也转圈:此时 toolActions
|
||||
尚未置位,只 disabled 不转圈会让探测的数秒像"点了没反应"。 */}
|
||||
{runningAction || preflightTools.has(toolName) ? (
|
||||
{/* 预检、等待确认与执行期间都显示该工具的进度。 */}
|
||||
{isToolBusy ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : action === "install" ? (
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -33,6 +34,12 @@ export function ToolUpgradeConfirmDialog({
|
||||
onCancel,
|
||||
}: ToolUpgradeConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
|
||||
// 队首切换后移开操作按钮的焦点,按住 Enter/Space 不能继续处理下一项。
|
||||
useEffect(() => {
|
||||
if (isOpen) titleRef.current?.focus();
|
||||
}, [isOpen, plans]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -41,9 +48,20 @@ export function ToolUpgradeConfirmDialog({
|
||||
if (!open) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md" zIndex="alert">
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
zIndex="alert"
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
titleRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<DialogHeader className="space-y-2 border-b-0 bg-transparent pb-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-base font-semibold">
|
||||
<DialogTitle
|
||||
ref={titleRef}
|
||||
tabIndex={-1}
|
||||
className="flex items-center gap-2 text-base font-semibold"
|
||||
>
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-500" />
|
||||
{t("settings.toolUpgradeConfirmTitle")}
|
||||
</DialogTitle>
|
||||
@@ -89,10 +107,20 @@ export function ToolUpgradeConfirmDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2 border-t-0 bg-transparent pt-2 sm:justify-end">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={(event) => {
|
||||
if (event.detail <= 1) onCancel();
|
||||
}}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={onConfirm}>
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
// 第二次 click 可能已经面对下一项计划,不能把双击视作两次授权。
|
||||
if (event.detail <= 1) onConfirm();
|
||||
}}
|
||||
>
|
||||
{t("settings.toolUpgradeConfirmBtn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ToolInstallationReport } from "@/lib/api/settings";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getToolVersions: vi.fn(),
|
||||
probeToolInstallations: vi.fn(),
|
||||
runToolLifecycleAction: vi.fn(),
|
||||
success: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ settingsApi: mocks }));
|
||||
vi.mock("@tauri-apps/api/app", () => ({ getVersion: async () => "3.20.4" }));
|
||||
vi.mock("@/contexts/UpdateContext", () => ({
|
||||
useUpdate: () => ({ hasUpdate: false, isChecking: false }),
|
||||
}));
|
||||
vi.mock("@/config/appConfig", () => ({ APP_ICON_MAP: {} }));
|
||||
vi.mock("sonner", () => ({ toast: mocks }));
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function report(
|
||||
tool: string,
|
||||
overrides: Partial<ToolInstallationReport> = {},
|
||||
): ToolInstallationReport {
|
||||
return {
|
||||
tool,
|
||||
installs: [],
|
||||
is_conflict: false,
|
||||
needs_confirmation: false,
|
||||
command: `${tool} update`,
|
||||
anchored: true,
|
||||
unmanaged: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const upgraded = new Set<string>();
|
||||
const outdated = new Set<string>();
|
||||
const missing = new Set<string>();
|
||||
|
||||
function card(name: string) {
|
||||
return within(screen.getByText(name).closest(".rounded-xl") as HTMLElement);
|
||||
}
|
||||
|
||||
function updateButton(name: string) {
|
||||
return card(name).getByRole("button", { name: "settings.toolUpdate" });
|
||||
}
|
||||
|
||||
async function renderAbout() {
|
||||
// AboutSection caches version results at module scope between mounts.
|
||||
const { AboutSection } = await import("@/components/settings/AboutSection");
|
||||
render(<AboutSection isPortable={false} />);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: "common.refresh" }),
|
||||
).toBeEnabled(),
|
||||
);
|
||||
}
|
||||
|
||||
describe("AboutSection concurrent CLI upgrades", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
upgraded.clear();
|
||||
outdated.clear();
|
||||
missing.clear();
|
||||
outdated.add("claude").add("codex").add("gemini");
|
||||
mocks.getToolVersions
|
||||
.mockReset()
|
||||
.mockImplementation(async (tools: string[]) =>
|
||||
tools.map((name) => ({
|
||||
name,
|
||||
version: missing.has(name)
|
||||
? null
|
||||
: upgraded.has(name) || !outdated.has(name)
|
||||
? "2.0.0"
|
||||
: "1.0.0",
|
||||
latest_version: "2.0.0",
|
||||
error: null,
|
||||
installed_but_broken: false,
|
||||
env_type: "windows",
|
||||
wsl_distro: null,
|
||||
})),
|
||||
);
|
||||
mocks.probeToolInstallations
|
||||
.mockReset()
|
||||
.mockImplementation(async (tools: string[]) =>
|
||||
tools.map((tool) => report(tool)),
|
||||
);
|
||||
mocks.runToolLifecycleAction
|
||||
.mockReset()
|
||||
.mockImplementation(async ([tool]: string[]) => {
|
||||
upgraded.add(tool);
|
||||
missing.delete(tool);
|
||||
});
|
||||
});
|
||||
|
||||
it("lets different tools preflight and submit together while blocking duplicate clicks", async () => {
|
||||
const claudeProbe = deferred<ToolInstallationReport[]>();
|
||||
const codexProbe = deferred<ToolInstallationReport[]>();
|
||||
const claudeRun = deferred<void>();
|
||||
const codexRun = deferred<void>();
|
||||
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
|
||||
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
|
||||
);
|
||||
mocks.runToolLifecycleAction.mockImplementation(
|
||||
async ([tool]: string[]) => {
|
||||
await (tool === "claude" ? claudeRun.promise : codexRun.promise);
|
||||
upgraded.add(tool);
|
||||
},
|
||||
);
|
||||
await renderAbout();
|
||||
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
expect(updateButton("Codex")).toBeEnabled();
|
||||
fireEvent.click(updateButton("Codex"));
|
||||
expect(mocks.probeToolInstallations.mock.calls).toEqual([
|
||||
[["claude"]],
|
||||
[["codex"]],
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
claudeProbe.resolve([report("claude")]);
|
||||
codexProbe.resolve([report("codex")]);
|
||||
});
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(2);
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
expect(updateButton("Codex")).toBeDisabled();
|
||||
expect(updateButton("Gemini CLI")).toBeEnabled();
|
||||
|
||||
await act(async () => codexRun.resolve());
|
||||
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument();
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
await act(async () => claudeRun.resolve());
|
||||
expect(
|
||||
card("Claude Code").getByText("settings.toolReady"),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.success).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("submits the remaining tools while a single upgrade is already running", async () => {
|
||||
const runs = new Map(
|
||||
["claude", "codex", "gemini"].map((name) => [name, deferred<void>()]),
|
||||
);
|
||||
mocks.runToolLifecycleAction.mockImplementation(
|
||||
async ([tool]: string[]) => {
|
||||
await runs.get(tool)!.promise;
|
||||
upgraded.add(tool);
|
||||
},
|
||||
);
|
||||
await renderAbout();
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
await waitFor(() =>
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const updateAll = screen.getByRole("button", {
|
||||
name: "settings.updateAllTools",
|
||||
});
|
||||
expect(updateAll).toBeEnabled();
|
||||
fireEvent.click(updateAll);
|
||||
await waitFor(() =>
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
expect(mocks.probeToolInstallations).toHaveBeenNthCalledWith(2, [
|
||||
"codex",
|
||||
"gemini",
|
||||
]);
|
||||
expect(
|
||||
mocks.runToolLifecycleAction.mock.calls.map(([tools]) => tools),
|
||||
).toEqual([["claude"], ["codex"], ["gemini"]]);
|
||||
|
||||
await act(async () => {
|
||||
runs.get("gemini")!.resolve();
|
||||
runs.get("codex")!.resolve();
|
||||
});
|
||||
expect(
|
||||
card("Gemini CLI").getByText("settings.toolReady"),
|
||||
).toBeInTheDocument();
|
||||
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument();
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
await act(async () => runs.get("claude")!.resolve());
|
||||
});
|
||||
|
||||
it("releases a failed tool for retry without unlocking another running tool", async () => {
|
||||
const firstRun = deferred<void>();
|
||||
const retryRun = deferred<void>();
|
||||
const codexRun = deferred<void>();
|
||||
const geminiRun = deferred<void>();
|
||||
let claudeAttempts = 0;
|
||||
mocks.runToolLifecycleAction.mockImplementation(
|
||||
async ([tool]: string[]) => {
|
||||
const run =
|
||||
tool === "claude"
|
||||
? ++claudeAttempts === 1
|
||||
? firstRun
|
||||
: retryRun
|
||||
: tool === "codex"
|
||||
? codexRun
|
||||
: geminiRun;
|
||||
await run.promise;
|
||||
upgraded.add(tool);
|
||||
},
|
||||
);
|
||||
const errorLog = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
await renderAbout();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.updateAllTools" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
await act(async () => firstRun.reject(new Error("upgrade failed")));
|
||||
expect(updateButton("Claude Code")).toBeEnabled();
|
||||
expect(updateButton("Codex")).toBeDisabled();
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
await waitFor(() =>
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(4),
|
||||
);
|
||||
await act(async () => {
|
||||
codexRun.resolve();
|
||||
geminiRun.resolve();
|
||||
});
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
expect(mocks.warning).toHaveBeenCalledWith(
|
||||
"settings.toolActionPartial",
|
||||
expect.objectContaining({ description: "Claude Code: upgrade failed" }),
|
||||
);
|
||||
await act(async () => retryRun.resolve());
|
||||
expect(
|
||||
card("Claude Code").getByText("settings.toolReady"),
|
||||
).toBeInTheDocument();
|
||||
errorLog.mockRestore();
|
||||
});
|
||||
|
||||
it("queues concurrent confirmation requests and releases only the cancelled tool", async () => {
|
||||
const claudeProbe = deferred<ToolInstallationReport[]>();
|
||||
const codexProbe = deferred<ToolInstallationReport[]>();
|
||||
const claudeRun = deferred<void>();
|
||||
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
|
||||
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
|
||||
);
|
||||
mocks.runToolLifecycleAction.mockImplementation(
|
||||
async ([tool]: string[]) => {
|
||||
await claudeRun.promise;
|
||||
upgraded.add(tool);
|
||||
},
|
||||
);
|
||||
await renderAbout();
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
fireEvent.click(updateButton("Codex"));
|
||||
await act(async () => {
|
||||
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
|
||||
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
|
||||
});
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText("Claude Code"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).queryByText("Codex"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.toolUpgradeConfirmBtn" }),
|
||||
);
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText("Codex"),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
|
||||
["claude"],
|
||||
"update",
|
||||
{},
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.cancel" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(updateButton("Codex")).toBeEnabled();
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
await act(async () => claudeRun.resolve());
|
||||
});
|
||||
|
||||
it.each(["double click", "held Enter", "held Space"])(
|
||||
"does not confirm the next queued plan with a %s",
|
||||
async (gesture) => {
|
||||
const claudeProbe = deferred<ToolInstallationReport[]>();
|
||||
const codexProbe = deferred<ToolInstallationReport[]>();
|
||||
const run = deferred<void>();
|
||||
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
|
||||
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
|
||||
);
|
||||
mocks.runToolLifecycleAction.mockImplementation(() => run.promise);
|
||||
const user = userEvent.setup();
|
||||
await renderAbout();
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
fireEvent.click(updateButton("Codex"));
|
||||
await act(async () => {
|
||||
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
|
||||
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
|
||||
});
|
||||
const confirm = screen.getByRole("button", {
|
||||
name: "settings.toolUpgradeConfirmBtn",
|
||||
});
|
||||
if (gesture === "double click") {
|
||||
await user.dblClick(confirm);
|
||||
} else {
|
||||
confirm.focus();
|
||||
await user.keyboard(
|
||||
gesture === "held Enter" ? "{Enter>3/}" : "[Space>3/]",
|
||||
);
|
||||
}
|
||||
expect(mocks.runToolLifecycleAction.mock.calls).toEqual([
|
||||
[["claude"], "update", {}],
|
||||
]);
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText("Codex"),
|
||||
).toBeInTheDocument();
|
||||
if (gesture !== "double click") {
|
||||
expect(
|
||||
screen.getByRole("heading", {
|
||||
name: "settings.toolUpgradeConfirmTitle",
|
||||
}),
|
||||
).toHaveFocus();
|
||||
}
|
||||
// 第二项仍可通过一次新的、明确的操作正常确认。
|
||||
await user.click(confirm);
|
||||
expect(mocks.runToolLifecycleAction.mock.calls).toEqual([
|
||||
[["claude"], "update", {}],
|
||||
[["codex"], "update", {}],
|
||||
]);
|
||||
await act(async () => run.resolve());
|
||||
},
|
||||
);
|
||||
|
||||
it("does not cancel the next queued plan with a double click", async () => {
|
||||
const claudeProbe = deferred<ToolInstallationReport[]>();
|
||||
const codexProbe = deferred<ToolInstallationReport[]>();
|
||||
mocks.probeToolInstallations.mockImplementation(([tool]: string[]) =>
|
||||
tool === "claude" ? claudeProbe.promise : codexProbe.promise,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
await renderAbout();
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
fireEvent.click(updateButton("Codex"));
|
||||
await act(async () => {
|
||||
claudeProbe.resolve([report("claude", { needs_confirmation: true })]);
|
||||
codexProbe.resolve([report("codex", { needs_confirmation: true })]);
|
||||
});
|
||||
await user.dblClick(screen.getByRole("button", { name: "common.cancel" }));
|
||||
expect(
|
||||
within(screen.getByRole("dialog")).getByText("Codex"),
|
||||
).toBeInTheDocument();
|
||||
expect(mocks.runToolLifecycleAction).not.toHaveBeenCalled();
|
||||
await user.click(screen.getByRole("button", { name: "common.cancel" }));
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(updateButton("Claude Code")).toBeEnabled();
|
||||
expect(updateButton("Codex")).toBeEnabled();
|
||||
});
|
||||
|
||||
it("does not block another installation submission while an upgrade is running", async () => {
|
||||
const claudeRun = deferred<void>();
|
||||
missing.add("codex");
|
||||
mocks.runToolLifecycleAction.mockImplementation(
|
||||
async ([tool]: string[]) => {
|
||||
if (tool === "claude") await claudeRun.promise;
|
||||
upgraded.add(tool);
|
||||
missing.delete(tool);
|
||||
},
|
||||
);
|
||||
await renderAbout();
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
await waitFor(() =>
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
const install = card("Codex").getByRole("button", {
|
||||
name: "settings.toolInstall",
|
||||
});
|
||||
expect(install).toBeEnabled();
|
||||
fireEvent.click(install);
|
||||
await waitFor(() =>
|
||||
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument(),
|
||||
);
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledWith(
|
||||
["codex"],
|
||||
"install",
|
||||
{},
|
||||
);
|
||||
expect(updateButton("Claude Code")).toBeDisabled();
|
||||
await act(async () => claudeRun.resolve());
|
||||
});
|
||||
|
||||
it("keeps a tool locked until its version refresh finishes", async () => {
|
||||
const refreshed =
|
||||
deferred<Awaited<ReturnType<typeof mocks.getToolVersions>>>();
|
||||
await renderAbout();
|
||||
mocks.getToolVersions.mockImplementationOnce(() => refreshed.promise);
|
||||
fireEvent.click(updateButton("Claude Code"));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
card("Claude Code").getAllByText("common.loading").length,
|
||||
).toBeGreaterThan(0),
|
||||
);
|
||||
expect(card("Claude Code").queryByRole("button")).not.toBeInTheDocument();
|
||||
expect(updateButton("Codex")).toBeEnabled();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.updateAllTools" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mocks.runToolLifecycleAction).toHaveBeenCalledTimes(3),
|
||||
);
|
||||
expect(
|
||||
mocks.runToolLifecycleAction.mock.calls.filter(([tools]) =>
|
||||
tools.includes("claude"),
|
||||
),
|
||||
).toHaveLength(1);
|
||||
await act(async () =>
|
||||
refreshed.resolve([
|
||||
{
|
||||
name: "claude",
|
||||
version: "2.0.0",
|
||||
latest_version: "2.0.0",
|
||||
error: null,
|
||||
installed_but_broken: false,
|
||||
env_type: "windows",
|
||||
wsl_distro: null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
card("Claude Code").getByText("settings.toolReady"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("releases skipped unmanaged tools and runs the remaining batch", async () => {
|
||||
mocks.probeToolInstallations.mockImplementation(async (tools: string[]) =>
|
||||
tools.map((tool) => report(tool, { unmanaged: tool === "claude" })),
|
||||
);
|
||||
await renderAbout();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "settings.updateAllTools" }),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(card("Codex").getByText("settings.toolReady")).toBeInTheDocument(),
|
||||
);
|
||||
expect(
|
||||
card("Gemini CLI").getByText("settings.toolReady"),
|
||||
).toBeInTheDocument();
|
||||
expect(updateButton("Claude Code")).toBeEnabled();
|
||||
expect(
|
||||
mocks.runToolLifecycleAction.mock.calls.map(([tools]) => tools),
|
||||
).toEqual([["codex"], ["gemini"]]);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "common.refresh" }),
|
||||
).toBeEnabled();
|
||||
expect(mocks.warning).toHaveBeenCalledWith(
|
||||
"settings.toolUpgradeUnmanagedTitle",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user