fix(ci): run backend checks on Windows/macOS and repair platform-gated tests (#5138)

* fix(ci): run backend checks on Windows/macOS and repair platform-gated tests

The backend CI job ran only on ubuntu-22.04, so every #[cfg(target_os =
"windows")] / macOS path — tests and clippy lints alike — was never
compiled or run. As a result main shipped 8 failing Windows unit tests and
a hard `cargo clippy -- -D warnings` failure on Windows, all invisible to
CI because it only builds and tests Linux.

Changes:

- ci: run the backend job on a {ubuntu-22.04, windows-latest, macos-latest}
  matrix (fail-fast: false); gate the apt install step on runner.os ==
  'Linux' and use `shell: bash` for the dist placeholder so it works on all
  three runners.

- misc.rs: fix 6 stale `anchored_upgrade_windows` tests. Commit 5092fe51
  removed codex from `prefers_official_update`, so codex now anchors to the
  package manager with no `codex update ||` prefix; update the five
  package-manager expectations accordingly, and retarget the no-sibling
  "official-update-without-fallback" test to `claude` (still in the list) so
  that branch stays covered instead of being silently dropped.

- codex_state_db.rs / codex_history_migration.rs: the two sqlite_home tests
  built TOML basic strings from Windows paths, whose backslashes are invalid
  escape sequences and made the override fail to parse; switch to TOML
  literal (single-quoted) strings so the path is taken verbatim.

- clippy (13 Windows-only warnings): move `use std::io::Write` into the
  #[cfg(unix)] block that actually uses it; convert 3 unneeded `return`s in
  Windows-gated tail positions to expressions; mark 9 POSIX-shell helpers
  #[cfg_attr(windows, allow(dead_code))] since they are used only by the
  macOS/Linux terminal launchers.

Verified locally (Windows 11, Rust 1.95.0):
  cargo test --lib                        → 1748 passed; 0 failed (was 1740/8)
  cargo clippy -- -D warnings             → clean (was 13 errors)
  cargo fmt --check                       → clean

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(skills): resolve home via get_home_dir so tests isolate on Windows

services/skill.rs called dirs::home_dir() directly in five places. On
Windows dirs::home_dir() resolves through the Known Folder API and
ignores HOME/USERPROFILE, so the CC_SWITCH_TEST_HOME override set by the
test harness never applied: the skill_sync integration tests scanned the
runner's real user profile, found no skills, and failed (the first panic
then poisoned the shared test mutex, cascading to all seven). On Unix
the harness also sets HOME, which dirs::home_dir() honors, so the suite
passed there by accident.

Route all five call sites through crate::config::get_home_dir(), the
crate-wide home resolver that prefers CC_SWITCH_TEST_HOME. The three
now-unreachable GET_HOME_DIR_FAILED error branches are dropped:
get_home_dir() is infallible, matching every other config-dir resolver.
Production behavior is unchanged (CC_SWITCH_TEST_HOME is unset outside
tests, so get_home_dir falls through to dirs::home_dir).

Add a regression test that discriminates on every platform by pointing
CC_SWITCH_TEST_HOME at a temp dir different from $HOME; it is marked
#[serial_test::serial] to stay mutually exclusive with the other tests
mutating the same process-global variable. Verified the test fails
against the old code on macOS with the same failure mode as Windows CI.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Jason <farion1231@gmail.com>
This commit is contained in:
zayoka
2026-07-15 10:33:22 +08:00
committed by GitHub
parent 1cc52c7e73
commit f6e37ed994
7 changed files with 83 additions and 48 deletions
+7 -1
View File
@@ -55,7 +55,11 @@ jobs:
backend:
name: Backend Checks
runs-on: ubuntu-22.04
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, windows-latest, macos-latest]
steps:
- name: Checkout
uses: actions/checkout@v6
@@ -66,6 +70,7 @@ jobs:
components: rustfmt, clippy
- name: Install Linux system deps
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
@@ -87,6 +92,7 @@ jobs:
restore-keys: ${{ runner.os }}-cargo-
- name: Create frontend dist placeholder
shell: bash
run: mkdir -p dist
- name: Check Rust formatting
+2 -1
View File
@@ -2137,7 +2137,8 @@ base_url = "https://proxy.example/v1"
let env_sqlite_home = dir.path().join("env-sqlite-home");
let config_sqlite_home = dir.path().join("config-sqlite-home");
let _guard = EnvVarGuard::set("CODEX_SQLITE_HOME", &env_sqlite_home);
let config_text = format!("sqlite_home = \"{}\"\n", config_sqlite_home.display());
// TOML 字面量字符串(单引号)Windows 路径含反斜杠,basic string 会解析失败。
let config_text = format!("sqlite_home = '{}'\n", config_sqlite_home.display());
let paths = codex_state_db_paths(&codex_dir, &config_text);
+3 -1
View File
@@ -83,7 +83,9 @@ mod tests {
fn includes_config_sqlite_home() {
let temp = tempdir().expect("tempdir");
let sqlite_home = temp.path().join("sqlite-home");
let config_text = format!("sqlite_home = \"{}\"\n", sqlite_home.display());
// 用 TOML 字面量字符串(单引号)承载路径:Windows 路径含反斜杠,basic string(双引号)
// 会把 `\U`/`\s` 等当作非法转义导致解析失败。
let config_text = format!("sqlite_home = '{}'\n", sqlite_home.display());
let paths = codex_state_db_paths(temp.path(), &config_text);
+28 -19
View File
@@ -638,7 +638,7 @@ fn build_tool_action_line(
// (npm/pnpm)或 .exe(volta),静态命令头部是 `npm`(也是 .cmd)、`py` 等——
// 全部加 `call ` 前缀,风格统一且语义正确。含空格的头部已被 `win_quote_path_for_batch`
// 加上双引号,call 对带引号的路径解析正常。
return Ok(format!("call {command}"));
Ok(format!("call {command}"))
}
#[cfg(not(target_os = "windows"))]
@@ -1069,6 +1069,8 @@ fn default_flag_for_shell(shell: &str) -> &'static str {
}
}
// 以下 shell 解析辅助函数仅被 macOS/Linux 的终端启动逻辑使用;Windows 非 test 编译下为死代码。
#[cfg_attr(windows, allow(dead_code))]
fn fallback_user_shell() -> &'static str {
if cfg!(target_os = "macos") {
"/bin/zsh"
@@ -1077,6 +1079,7 @@ fn fallback_user_shell() -> &'static str {
}
}
#[cfg_attr(windows, allow(dead_code))]
fn valid_user_shell_path(shell: &str) -> bool {
if shell.is_empty()
|| !shell.starts_with('/')
@@ -1100,11 +1103,13 @@ fn is_executable_file(path: &std::path::Path) -> bool {
}
#[cfg(not(unix))]
#[cfg_attr(windows, allow(dead_code))]
fn is_executable_file(path: &std::path::Path) -> bool {
path.is_file()
}
/// 获取用户默认 shell 的完整路径;异常或被污染的 SHELL 回退到平台默认值。
#[cfg_attr(windows, allow(dead_code))]
fn get_user_shell() -> String {
std::env::var("SHELL")
.ok()
@@ -1113,6 +1118,7 @@ fn get_user_shell() -> String {
}
/// 构建 exec 行:引号保护 shell 路径,交还用户 shell 让其按默认规则加载 rc 配置。
#[cfg_attr(windows, allow(dead_code))]
fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
let quoted_shell = shell_single_quote(shell);
@@ -1132,6 +1138,7 @@ fn build_exec_line(shell: &str, cwd: Option<&Path>) -> String {
}
/// 构建 provider 命令行:通过用户 shell 的交互模式执行,确保 GUI 启动的终端也加载用户 PATH。
#[cfg_attr(windows, allow(dead_code))]
fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path>) -> String {
let claude_command = format!("claude --settings {}", shell_single_quote(config_path));
let command = cwd
@@ -1152,6 +1159,7 @@ fn build_provider_command_line(shell: &str, config_path: &str, cwd: Option<&Path
)
}
#[cfg_attr(windows, allow(dead_code))]
fn provider_command_flag_for_shell(shell: &str) -> &'static str {
match shell.rsplit('/').next().unwrap_or(shell) {
"dash" | "sh" => "-c",
@@ -1160,6 +1168,7 @@ fn provider_command_flag_for_shell(shell: &str) -> &'static str {
}
}
#[cfg_attr(windows, allow(dead_code))]
fn build_final_shell_cd_command(shell: &str, cwd: Option<&Path>) -> String {
if matches!(shell.rsplit('/').next().unwrap_or(shell), "zsh") {
return String::new();
@@ -2731,7 +2740,7 @@ fn launch_terminal_with_env(
#[cfg(target_os = "windows")]
{
launch_windows_terminal(&temp_dir, &config_file, cwd)?;
return Ok(());
Ok(())
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
@@ -3252,6 +3261,7 @@ del \"%~f0\" >nul 2>&1
result
}
#[cfg_attr(windows, allow(dead_code))]
fn shell_single_quote(value: &str) -> String {
format!("'{}'", value.replace('\'', "'\"'\"'"))
}
@@ -3838,11 +3848,8 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("Volta", "codex.cmd", &["volta.exe"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let volta_full = format!("{}\\volta.exe", sub.to_string_lossy());
let expected = format!(
"{} update || call {} install @openai/codex",
expect_quoted_path(&bin_path),
expect_quoted_path(&volta_full)
);
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!("{} install @openai/codex", expect_quoted_path(&volta_full));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
@@ -3854,9 +3861,9 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("pnpm", "codex.cmd", &["pnpm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let pnpm_full = format!("{}\\pnpm.cmd", sub.to_string_lossy());
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!(
"{} update || call {} add -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} add -g @openai/codex@latest",
expect_quoted_path(&pnpm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -3888,9 +3895,9 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("v22.0.0", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 自 5092fe51 起不在 prefers_official_update:直接锚定包管理器,无 `codex update ||` 前缀。
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -3898,10 +3905,11 @@ mod tests {
#[test]
fn windows_no_sibling_uses_cli_update_without_package_fallback() {
// sibling npm.cmd 不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
// 只是没有包管理器 fallback。
let (_dir, _sub, bin_path) = setup_sibling("", "codex.cmd", &[]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
// sibling 包管理器不存在(纯独立二进制)时,仍可锚定到 CLI 自身跑官方 update。
// 只是没有包管理器 fallback。用 claude —— codex 自 5092fe51 起一律走 npm 锚定,
// 已不再有官方 self-update 分支,故改用仍在 prefers_official_update 的 claude 覆盖此路径。
let (_dir, _sub, bin_path) = setup_sibling("", "claude.cmd", &[]);
let cmd = anchored_command_from_paths("claude", &bin_path, &bin_path);
let expected = format!("{} update", expect_quoted_path(&bin_path));
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
}
@@ -3977,9 +3985,9 @@ mod tests {
let (_dir, sub, bin_path) = setup_sibling("Program Files", "codex.cmd", &["npm.cmd"]);
let cmd = anchored_command_from_paths("codex", &bin_path, &bin_path);
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 走 npm 锚定(5092fe51),含空格的 npm 全路径仍必须被双引号包裹。
let expected = format!(
"{} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"{} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(cmd.as_deref(), Some(expected.as_str()));
@@ -4001,9 +4009,10 @@ mod tests {
// 含空格的环境**(否则 sub 本身含空格 + 子目录 `path%foo%` 触发 4 倍 `%` 转义
// 会让 expected 漏引号、假失败)。
let npm_full = format!("{}\\npm.cmd", sub.to_string_lossy());
// codex 走 npm 锚定(5092fe51)batch 行 = `call <npm 全路径> i -g ...`
// 含字面 `%` 的 npm 路径仍须 4 倍转义。
let expected = format!(
"call {} update || call {} i -g @openai/codex@latest",
expect_quoted_path(&bin_path),
"call {} i -g @openai/codex@latest",
expect_quoted_path(&npm_full)
);
assert_eq!(batch_line, expected);
+1 -1
View File
@@ -247,7 +247,7 @@ pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String>
"Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。"
)
})?;
return Ok(true);
Ok(true)
}
#[cfg(not(target_os = "windows"))]
+41 -24
View File
@@ -374,20 +374,15 @@ fn parse_branch_from_source_url(source_url: Option<&str>) -> Option<String> {
/// 获取 `~/.agents/skills/` 目录(存在时返回)
fn get_agents_skills_dir() -> Option<PathBuf> {
dirs::home_dir()
.map(|h| h.join(".agents").join("skills"))
.filter(|p| p.exists())
let dir = crate::config::get_home_dir().join(".agents").join("skills");
dir.exists().then_some(dir)
}
/// 解析 `~/.agents/.skill-lock.json`,返回 skill_name -> 仓库信息
fn parse_agents_lock() -> HashMap<String, LockRepoInfo> {
let path = match dirs::home_dir() {
Some(h) => h.join(".agents").join(".skill-lock.json"),
None => {
log::warn!("无法获取 HOME 目录,跳过解析 agents lock 文件");
return HashMap::new();
}
};
let path = crate::config::get_home_dir()
.join(".agents")
.join(".skill-lock.json");
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
@@ -482,12 +477,7 @@ impl SkillService {
let dir = match location {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
home.join(".agents").join("skills")
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&dir)?;
@@ -538,12 +528,10 @@ impl SkillService {
}
}
// 默认路径:回退到用户主目录下的标准位置
let home = dirs::home_dir().context(format_skill_error(
"GET_HOME_DIR_FAILED",
&[],
Some("checkPermission"),
))?;
// 默认路径:回退到用户主目录下的标准位置
// 必须走 get_home_dir()(可被 CC_SWITCH_TEST_HOME 覆盖):Windows 上 dirs::home_dir()
// 走 Known Folder API,测试无法隔离真实用户目录。
let home = crate::config::get_home_dir();
Ok(match app {
AppType::Claude => home.join(".claude").join("skills"),
@@ -1167,8 +1155,7 @@ impl SkillService {
let new_dir = match target {
SkillStorageLocation::CcSwitch => get_app_config_dir().join("skills"),
SkillStorageLocation::Unified => {
let home = dirs::home_dir().context("Cannot determine home directory")?;
home.join(".agents").join("skills")
crate::config::get_home_dir().join(".agents").join("skills")
}
};
fs::create_dir_all(&new_dir)?;
@@ -3069,6 +3056,36 @@ mod tests {
.expect("write SKILL.md");
}
#[test]
// serial:与 backup/s3_sync/deeplink 等同样读写进程级 CC_SWITCH_TEST_HOME 的测试互斥,
// EnvGuard 只负责恢复不提供互斥。
#[serial_test::serial]
fn get_app_skills_dir_honors_test_home_override() {
// 回归:曾直呼 dirs::home_dir() 绕过 CC_SWITCH_TEST_HOME——Unix 上碰巧跟 $HOME
// 一致所以测试能过,Windows 上 dirs 走 Known Folder API,测试隔离整体失效
// tests/skill_sync.rs 扫到 runner 真实用户目录)。
struct EnvGuard(Option<std::ffi::OsString>);
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.0.take() {
Some(value) => std::env::set_var("CC_SWITCH_TEST_HOME", value),
None => std::env::remove_var("CC_SWITCH_TEST_HOME"),
}
}
}
let temp = tempdir().expect("tempdir");
let _guard = EnvGuard(std::env::var_os("CC_SWITCH_TEST_HOME"));
std::env::set_var("CC_SWITCH_TEST_HOME", temp.path());
let dir =
SkillService::get_app_skills_dir(&AppType::Claude).expect("resolve claude skills dir");
assert!(
dir.starts_with(temp.path()),
"skills dir must live under the overridden test home, got {}",
dir.display()
);
}
#[test]
fn resolve_skill_source_dir_returns_repo_root_for_root_level_skill() {
let temp = tempdir().expect("tempdir");
+1 -1
View File
@@ -1,6 +1,5 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
@@ -660,6 +659,7 @@ fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> {
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()