From 53c568026065874c1a17061d859041c68f79b868 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Mon, 12 Oct 2026 13:26:58 +0800 Subject: [PATCH] fix: harden mirrored downloads and first-run upgrades --- README.md | 2 +- config/downloads.json | 7 +- docs/ai-automation.md | 4 +- scripts/bootstrap_python.ps1 | 6 +- scripts/download_config.ps1 | 109 ++++++++++++++++++- scripts/start.ps1 | 149 +++++--------------------- scripts/upgrade.ps1 | 16 ++- scripts/windows_tools.ps1 | 58 ++++++++++ tests/unit/test_download_mirrors.py | 160 ++++++++++++++++++++++++++-- tests/unit/test_windows_upgrade.py | 86 +++++++++++---- 10 files changed, 437 insertions(+), 160 deletions(-) create mode 100644 scripts/windows_tools.ps1 diff --git a/README.md b/README.md index 025267a..c2b9989 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ cd Artemis-Eapil .\start.bat -SkipMcpSync # MCP 配置异常时,仅启动网页 ``` -镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。国内脚本使用山东大学镜像安装 scrcpy,并校验官方 SHA256;也可预先安装或自定义 HTTPS 下载地址。 +镜像与下载超时在 [config/downloads.json](config/downloads.json) 中设置。scrcpy 国内下载源失败时自动尝试备用源,每个安装包均校验官方 SHA256;也可预先安装,或通过 `overrides.scrcpy_url` 和 `scrcpy_fallback_urls` 自定义 HTTPS 地址。 ### macOS / Linux diff --git a/config/downloads.json b/config/downloads.json index 6934d87..656487f 100644 --- a/config/downloads.json +++ b/config/downloads.json @@ -7,7 +7,11 @@ "npm_registry": "https://registry.npmmirror.com", "node_mirror": "https://registry.npmmirror.com/-/binary/node", "adb_url": "https://mirrors.cloud.tencent.com/AndroidSDK/platform-tools-latest-windows.zip", - "scrcpy_url": "https://mirrors.sdu.edu.cn/github-release/Genymobile_scrcpy/v4.1/scrcpy-win64-v4.1.zip" + "scrcpy_url": "https://ghfast.top/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip", + "scrcpy_fallback_urls": [ + "https://gh-proxy.com/https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip", + "https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip" + ] }, "overrides": { "python_index": "", @@ -16,6 +20,7 @@ "node_mirror": "", "adb_url": "", "scrcpy_url": "", + "scrcpy_fallback_urls": null, "uv_installer_url": "" } } diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 4a33ace..3c66d2a 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -34,7 +34,9 @@ Windows 启动入口 `start.bat` / `start-cn.bat` 只检查并提示源码更新,`-SkipUpdate` 跳过提示。独立升级入口为 `update.bat` / `update-cn.bat`:先把升级程序复制到项目外的临时目录,检查本地修改,再终止本项目的 ARTEMIS 服务、MCP 和任务进程;之后仅做 Git 快进更新,并用新版启动程序准备环境,完成后由用户重新启动。其他项目、Codex、模拟器和共享 ADB 不受影响;运行中的任务会被中断,不会作为成功任务处理。1.1.1 及更新版本的发布 ZIP 含 Git 工作树,旧版无 `.git` 的 ZIP 需改用新版包或 Git 克隆。没有 Git、网络/认证失败、有本地修改或分支分叉时升级失败并保留现有源码,不重置或覆盖。 -Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源及包锁指纹决定是否执行 `npm ci` 和构建,成功后才记录指纹。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。 +Python 按 `uv.lock` 同步缺失/变化的依赖;前端依据源码、资源及包锁指纹决定是否执行 `npm ci` 和构建,成功后才记录指纹。未运行过启动脚本的 Git 源码目录也可直接升级:先自动准备最小 Python 环境,快进源码后再安装新版依赖。国内升级入口继续使用镜像配置。升级与启动的环境准备互斥;升级失败不会自动启动服务或回滚用户配置,处理错误后可重试。双击升级失败时会保留原因并等待回车;自动化调用可设置 `ARTEMIS_NO_PAUSE=1`,输入重定向时也不会等待。 + +scrcpy 固定版本的国内下载采用多个来源,失败或 SHA256 不符时自动尝试下一来源,全部失败会显示 HTTP 状态、超时或校验原因。`config/downloads.json` 的 `overrides.scrcpy_url` 指定首选 HTTPS 地址,`overrides.scrcpy_fallback_urls` 指定备用地址数组;`null` 使用默认备用源,`[]` 禁用备用源。国内模式不自动回退到 GitHub 直连。 MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当前用户的 `~/.codex/config.toml`。日志会显示实际路径,以及新装、更新、已是最新、未检测到或自定义跳过的结果。检测到目录但缺少 ARTEMIS 条目时安装当前源码的标准配置;已有标准配置即使指向旧解压目录,也会更新解释器、项目目录、环境路径和工具清单。保留用户额外环境变量、禁用状态及其他服务;自定义启动包装器保持原样并提示。其他客户端的配置和规则文件不读取、不修改,其格式错误不会阻止启动。Codex 配置无法安全解析时停止启动,原文件不被覆盖;可使用 `-SkipMcpSync` 仅启动网页。首次修改保留备份。同步后需在 Codex 中重新加载 MCP 或重启 Codex,脚本无法替宿主重启已加载的 MCP 进程。 diff --git a/scripts/bootstrap_python.ps1 b/scripts/bootstrap_python.ps1 index fe28f3b..9c1ed31 100644 --- a/scripts/bootstrap_python.ps1 +++ b/scripts/bootstrap_python.ps1 @@ -8,7 +8,7 @@ function Get-BootstrapText { } function Initialize-ArtemisPython { - param([Parameter(Mandatory=$true)][string]$ProjectRoot) + param([Parameter(Mandatory=$true)][string]$ProjectRoot, [switch]$RuntimeOnly) $ProjectRoot = (Resolve-Path -LiteralPath $ProjectRoot -ErrorAction Stop).Path if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot 'pyproject.toml'))) { @@ -65,6 +65,10 @@ function Initialize-ArtemisPython { throw (Get-BootstrapText 'The existing .venv is unusable or older than Python 3.12. Rename it as a backup, then rerun start.bat.' '已有 .venv 无法使用或 Python 低于 3.12。先重命名备份,再运行 start-cn.bat。') } + # The frozen source updater uses only Python's standard library. Install + # project dependencies once, from the new lock file AFTER the fast-forward. + if ($RuntimeOnly) { return $pythonExe } + # A cheap check skips installation when the lock and installed packages agree. # --inexact preserves any optional development tools already installed. $lockOption = if ($ArtemisDownloads -and $ArtemisDownloads.python_index) { '--frozen' } else { '--locked' } diff --git a/scripts/download_config.ps1 b/scripts/download_config.ps1 index cad7c9c..df32454 100644 --- a/scripts/download_config.ps1 +++ b/scripts/download_config.ps1 @@ -1,8 +1,95 @@ # Download settings are process-local; never edit global pip/npm/uv settings. # Save as UTF-8 with BOM so Windows PowerShell 5.1 reads Chinese messages correctly. +function Get-DownloadText { + param([string]$English, [string]$Chinese) + if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { return $Chinese } + return $English +} + +function Invoke-DownloadFile { + param( + [Parameter(Mandatory=$true)][string]$Uri, + [Parameter(Mandatory=$true)][string]$OutFile, + [int]$TimeoutSec = $ArtemisDownloads.timeout_seconds + ) + $script:ArtemisDownloadError = '' + Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue + if (Get-Command curl.exe -ErrorAction SilentlyContinue) { + $previousErrors = $ErrorActionPreference + try { + # Native stderr is an ErrorRecord in PowerShell 5.1. Inspect the code, + # and log only HTTP status/exit code, never a URL containing credentials. + $ErrorActionPreference = 'Continue' + $status = & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec --proto '=https' --proto-redir '=https' --write-out '%{http_code}' -o $OutFile $Uri 2>$null + $curlCode = $LASTEXITCODE + if ($curlCode -eq 0 -and (Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) { return $true } + $script:ArtemisDownloadError = if ($curlCode -eq 22) { "HTTP $status" } + elseif ($curlCode -eq 28) { Get-DownloadText "Download timed out after $TimeoutSec seconds" "下载超时($TimeoutSec 秒)" } + else { "curl exit $curlCode" } + # Do not repeat an HTTP rejection or a full timeout with a second client. + if ($curlCode -in @(22, 28)) { return $false } + } catch { + $script:ArtemisDownloadError = 'curl failed' + } finally { + $ErrorActionPreference = $previousErrors + if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue } + } + } + $previousProgress = $ProgressPreference + try { + $ProgressPreference = 'SilentlyContinue' + Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop + if ((Test-Path -LiteralPath $OutFile) -and (Get-Item -LiteralPath $OutFile).Length -gt 0) { + $script:ArtemisDownloadError = '' + return $true + } + $script:ArtemisDownloadError = Get-DownloadText 'Empty download' '下载内容为空' + } catch { + $response = $_.Exception.Response + $script:ArtemisDownloadError = if ($response) { "HTTP $([int]$response.StatusCode)" } + else { Get-DownloadText 'Network/TLS error or download timeout' '网络、TLS 连接失败或下载超时' } + } finally { + $ProgressPreference = $previousProgress + if ($script:ArtemisDownloadError) { Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue } + } + return $false +} + +function Get-ArtemisFileSha256 { + param([Parameter(Mandatory=$true)][string]$Path) + # Do not depend on Get-FileHash module autoloading: PowerShell 7 can pass its + # own module search path to the Windows PowerShell 5.1 launcher. + $hasher = [Security.Cryptography.SHA256]::Create() + $stream = [IO.File]::OpenRead($Path) + try { return [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } + finally { $stream.Dispose(); $hasher.Dispose() } +} + +function Invoke-ArtemisVerifiedDownload { + param( + [Parameter(Mandatory=$true)][string[]]$Urls, + [Parameter(Mandatory=$true)][string]$OutFile, + [Parameter(Mandatory=$true)][string]$Sha256 + ) + $sources = @($Urls | Select-Object -Unique) + for ($index = 0; $index -lt $sources.Count; $index++) { + $sourceHost = ([Uri]$sources[$index]).Host + Write-Host (Get-DownloadText " [DOWNLOAD $($index + 1)/$($sources.Count)] scrcpy: $sourceHost" " [下载 $($index + 1)/$($sources.Count)] scrcpy:$sourceHost") -ForegroundColor Cyan + if (Invoke-DownloadFile -Uri $sources[$index] -OutFile $OutFile) { + $actualHash = Get-ArtemisFileSha256 -Path $OutFile + if ($actualHash -eq $Sha256) { return $true } + $script:ArtemisDownloadError = Get-DownloadText 'Official SHA256 verification failed; archive rejected' '官方 SHA256 校验失败,已拒绝此安装包' + Remove-Item -LiteralPath $OutFile -Force -ErrorAction SilentlyContinue + } + Write-Host (Get-DownloadText " [WARN] $sourceHost`: $script:ArtemisDownloadError" " [提示] $sourceHost 下载失败:$script:ArtemisDownloadError") -ForegroundColor Yellow + } + return $false +} + function Initialize-ArtemisDownloads { param([string]$ProjectRoot, [string]$Mode) + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $path = Join-Path $ProjectRoot 'config\downloads.json' $config = Get-Content -LiteralPath $path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop if (-not $Mode) { $Mode = $config.mode } @@ -28,6 +115,21 @@ function Initialize-ArtemisDownloads { } } } + $fallbacks = @('https://downloads.sourceforge.net/project/scrcpy.mirror/v4.1/scrcpy-win64-v4.1.zip') + if ($Mode -eq 'China' -and $null -ne $config.china.scrcpy_fallback_urls) { $fallbacks = $config.china.scrcpy_fallback_urls } + if ($null -ne $config.overrides.scrcpy_fallback_urls) { $fallbacks = $config.overrides.scrcpy_fallback_urls } + if ($fallbacks -isnot [Array]) { throw 'config/downloads.json: scrcpy_fallback_urls must be an array of HTTPS URLs.' } + foreach ($url in $fallbacks) { + $uri = $null + if ($url -isnot [string] -or -not [Uri]::TryCreate($url, [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne 'https') { + throw 'config/downloads.json: scrcpy_fallback_urls must contain only HTTPS URLs.' + } + } + $values.scrcpy_urls = @(@($values.scrcpy_url) + @($fallbacks) | Select-Object -Unique) + if ($Mode -eq 'China') { + # China mode must never silently fall back to a direct GitHub download. + $values.scrcpy_urls = @($values.scrcpy_urls | Where-Object { ([Uri]$_).Host -notin @('github.com', 'www.github.com') }) + } # Explicit caller environment settings take priority over the selected preset. if ($env:UV_DEFAULT_INDEX) { $values.python_index = $env:UV_DEFAULT_INDEX } elseif ($env:UV_INDEX_URL) { $values.python_index = $env:UV_INDEX_URL } @@ -42,7 +144,7 @@ function Initialize-ArtemisDownloads { # Official scrcpy v4.1 win64 release digest. Verify every mirror/override equally. $values.scrcpy_sha256 = '5b12172b3264b2889f4583ee64752ce832e29bc8b1089dca81093459697165db' $script:ArtemisDownloads = [pscustomobject]$values - if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN') { + if ($env:ARTEMIS_LAUNCH_LANG -eq 'zh-CN' -and $Mode -eq 'China') { Write-Host " [镜像] 使用国内下载源(config/downloads.json)。" -ForegroundColor Cyan } else { Write-Host " [INFO] Download mode: $Mode (config/downloads.json)." -ForegroundColor Cyan @@ -67,10 +169,7 @@ function Install-ArtemisUvFromIndex { if ($url.Scheme -ne 'https') { throw 'The uv download must use HTTPS.' } $zip = Join-Path $work 'uv.zip' if (-not (Invoke-DownloadFile -Uri $url.AbsoluteUri.Split('#')[0] -OutFile $zip)) { throw 'Could not download uv from the selected index.' } - $hasher = [Security.Cryptography.SHA256]::Create() - $stream = [IO.File]::OpenRead($zip) - try { $digest = [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-', '') } - finally { $stream.Dispose(); $hasher.Dispose() } + $digest = Get-ArtemisFileSha256 -Path $zip if ($digest -ne $candidate.Groups['hash'].Value) { throw 'The downloaded uv package failed SHA256 verification.' } diff --git a/scripts/start.ps1 b/scripts/start.ps1 index 1259347..1da4193 100644 --- a/scripts/start.ps1 +++ b/scripts/start.ps1 @@ -83,6 +83,7 @@ if ((-not $SkipUpdate) -and (Test-Path -LiteralPath $existingPython)) { & $existingPython (Join-Path $ScriptDir 'update_installation.py') source-check # Network/authentication failures are warnings, never a reason to prevent startup. } +. (Join-Path $ScriptDir 'windows_tools.ps1') . (Join-Path $ScriptDir 'download_config.ps1') try { Initialize-ArtemisDownloads -ProjectRoot $RootDir -Mode $Mirror @@ -96,55 +97,6 @@ Write-Host (Get-LaunchText " Artemis Autonomous Mobile Agent UI Write-Host "======================================================" -ForegroundColor Cyan Write-Host "" -function Update-EnvironmentPath { - $standardDirs = @( - "$env:LOCALAPPDATA\Microsoft\WinGet\Links", - "$env:LOCALAPPDATA\Programs\node", - "$env:USERPROFILE\.local\share\node", - "$env:LOCALAPPDATA\Programs\scrcpy", - "$env:USERPROFILE\.local\share\scrcpy", - "$env:LOCALAPPDATA\Programs\platform-tools", - "$env:USERPROFILE\.local\share\platform-tools", - "$env:LOCALAPPDATA\Programs\uv", - "$env:LOCALAPPDATA\uv", - "$env:APPDATA\uv", - "$env:USERPROFILE\.cargo\bin", - "$env:USERPROFILE\.local\bin", - "$env:USERPROFILE\scoop\shims", - "C:\ProgramData\chocolatey\bin", - "$env:LOCALAPPDATA\Android\Sdk\platform-tools", - "$env:LOCALAPPDATA\Android\android-sdk\platform-tools", - "$env:ProgramFiles\Android\platform-tools", - "${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools", - "$env:ProgramFiles\nodejs", - "${env:ProgramFiles(x86)}\nodejs", - "$env:APPDATA\npm" - ) - if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs } - if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" } - if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" } - if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs } - if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs } - - $regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") - $currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique - $env:PATH = $currentPaths -join ";" - - # Ensure user-space portable tools take absolute priority over system-level outdated versions - $portableNode = "$env:LOCALAPPDATA\Programs\node" - if (Test-Path "$portableNode\node.exe") { - $env:PATH = "$portableNode;$env:PATH" - } - $portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy" - if (Test-Path "$portableScrcpy\scrcpy.exe") { - $env:PATH = "$portableScrcpy;$env:PATH" - } - $portablePt = "$env:LOCALAPPDATA\Programs\platform-tools" - if (Test-Path "$portablePt\adb.exe") { - $env:PATH = "$portablePt;$env:PATH" - } -} - function Start-LocalAdbServer { if (Test-CommandExists "adb") { $adbErrorPreference = $ErrorActionPreference @@ -167,54 +119,6 @@ function Start-LocalAdbServer { } } -function Test-CommandExists { - param([string]$Command) - $res = Get-Command $Command -ErrorAction SilentlyContinue - return ($null -ne $res) -} - -function Invoke-DownloadFile { - param( - [Parameter(Mandatory=$true)][string]$Uri, - [Parameter(Mandatory=$true)][string]$OutFile, - [int]$TimeoutSec = $ArtemisDownloads.timeout_seconds - ) - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - - # 1. Try Windows built-in curl.exe first (available in Win 10 1803+ and Win 11) - if (Test-CommandExists "curl.exe") { - try { - & curl.exe -f -sSL --connect-timeout 5 --max-time $TimeoutSec "$Uri" -o "$OutFile" 2>$null - if ($LASTEXITCODE -eq 0 -and (Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) { - return $true - } - } catch {} - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - } - - # 2. Fallback to Invoke-WebRequest (suppressing progress bar to eliminate PowerShell buffer hangs) - try { - $prevProgress = $ProgressPreference - $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri $Uri -OutFile $OutFile -TimeoutSec $TimeoutSec -UseBasicParsing -ErrorAction Stop - $ProgressPreference = $prevProgress - if ((Test-Path $OutFile) -and ((Get-Item $OutFile).Length -gt 0)) { - return $true - } - } catch { - $ProgressPreference = $prevProgress - } - - if (Test-Path $OutFile) { - Remove-Item -Path $OutFile -Force -ErrorAction SilentlyContinue - } - return $false -} - function Install-PortablePlatformTools { $ptDir = "$env:LOCALAPPDATA\Programs\platform-tools" if (Test-Path "$ptDir\adb.exe") { @@ -258,38 +162,37 @@ function Install-PortableScrcpy { } Write-Host (Get-LaunchText " [INFO] Installing portable scrcpy in user space..." " [安装] 正在安装 scrcpy(当前用户)...") -ForegroundColor Cyan - if ($ArtemisDownloads.mode -eq 'China' -and $ArtemisDownloads.scrcpy_url.StartsWith('https://github.com/')) { - Write-Host (Get-LaunchText ' [WARN] China mode has no non-GitHub scrcpy source. Configure overrides.scrcpy_url or install scrcpy manually.' ' [提示] 国内镜像中没有可用的 scrcpy 下载地址;请设置 config/downloads.json 的 overrides.scrcpy_url,或自行安装。') -ForegroundColor Yellow + if ($ArtemisDownloads.scrcpy_urls.Count -eq 0) { + Write-Host (Get-LaunchText ' [WARN] No scrcpy download source is configured. Set overrides.scrcpy_url.' ' [提示] 未配置可用的 scrcpy 下载源;请设置 overrides.scrcpy_url。') -ForegroundColor Yellow return $false } + $work = Join-Path ([IO.Path]::GetTempPath()) ('artemis-scrcpy-' + [Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $work -ErrorAction Stop | Out-Null try { - $zipPath = "$env:TEMP\scrcpy-win64.zip" - if (Invoke-DownloadFile -Uri $ArtemisDownloads.scrcpy_url -OutFile $zipPath) { - $actualHash = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualHash -ne $ArtemisDownloads.scrcpy_sha256) { - throw 'Downloaded scrcpy ZIP failed official SHA256 verification.' - } - $extractDir = "$env:TEMP\scrcpy_extract" - if (Test-Path $extractDir) { Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue } - New-Item -ItemType Directory -Path $extractDir -Force | Out-Null - Expand-Archive -Path $zipPath -DestinationPath $extractDir -Force + $zipPath = Join-Path $work 'scrcpy.zip' + if (Invoke-ArtemisVerifiedDownload -Urls $ArtemisDownloads.scrcpy_urls -OutFile $zipPath -Sha256 $ArtemisDownloads.scrcpy_sha256) { + $extractDir = Join-Path $work 'extracted' + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $extractDir) $extractedFolder = Get-ChildItem -Path $extractDir -Directory | Select-Object -First 1 - if ($extractedFolder) { - if (-not (Test-Path $scrcpyDir)) { - New-Item -ItemType Directory -Path $scrcpyDir -Force | Out-Null - } - Copy-Item -Path "$($extractedFolder.FullName)\*" -Destination $scrcpyDir -Recurse -Force - } - Remove-Item $zipPath -Force -ErrorAction SilentlyContinue - Remove-Item $extractDir -Recurse -Force -ErrorAction SilentlyContinue - if (Test-Path "$scrcpyDir\scrcpy.exe") { - $env:PATH = "$scrcpyDir;$env:PATH" - Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green - return $true - } + $contentDir = if (Test-Path -LiteralPath (Join-Path $extractDir 'scrcpy.exe')) { $extractDir } + elseif ($extractedFolder) { $extractedFolder.FullName } + else { throw 'scrcpy ZIP contains no application directory.' } + if (-not (Test-Path -LiteralPath (Join-Path $contentDir 'scrcpy.exe'))) { throw 'scrcpy ZIP contains no scrcpy.exe.' } + New-Item -ItemType Directory -Path $scrcpyDir -Force -ErrorAction Stop | Out-Null + Get-ChildItem -LiteralPath $contentDir | Copy-Item -Destination $scrcpyDir -Recurse -Force -ErrorAction Stop + $env:PATH = "$scrcpyDir;$env:PATH" + Write-Host (Get-LaunchText " [OK] Portable scrcpy installed in user space." " [完成] scrcpy 已安装。") -ForegroundColor Green + return $true } } catch { Write-Host (Get-LaunchText " [WARN] Failed to install portable scrcpy: $_" " [提示] scrcpy 安装失败:$_") -ForegroundColor DarkYellow + } finally { + $fullWork = [IO.Path]::GetFullPath($work) + $tempPrefix = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\') + '\' + if ($fullWork.StartsWith($tempPrefix, [StringComparison]::OrdinalIgnoreCase) -and (Split-Path $fullWork -Leaf) -like 'artemis-scrcpy-*') { + Remove-Item -LiteralPath $fullWork -Recurse -Force -ErrorAction SilentlyContinue + } } return $false } @@ -446,7 +349,7 @@ if ($missingCore.Count -gt 0) { } if (-not (Test-CommandExists "scrcpy")) { if (-not (Install-PortableScrcpy)) { - Write-Host (Get-LaunchText ' [WARN] scrcpy download failed. Set overrides.scrcpy_url in config/downloads.json to a reachable release ZIP, then retry.' ' [提示] scrcpy 下载失败。请在 config/downloads.json 中设置可访问的 overrides.scrcpy_url,然后重试。') -ForegroundColor Yellow + Write-Host (Get-LaunchText ' [WARN] All scrcpy sources failed. Check the HTTP/timeout/checksum error above, or configure overrides.scrcpy_url and scrcpy_fallback_urls in config/downloads.json.' ' [提示] scrcpy 下载源均未成功;请查看上方 HTTP、超时或校验错误,或在 config/downloads.json 中设置 overrides.scrcpy_url 与备用地址 scrcpy_fallback_urls。') -ForegroundColor Yellow } } } diff --git a/scripts/upgrade.ps1 b/scripts/upgrade.ps1 index 5d12311..3e1d54b 100644 --- a/scripts/upgrade.ps1 +++ b/scripts/upgrade.ps1 @@ -11,6 +11,7 @@ $env:PYTHONUTF8 = '1' $env:PYTHONIOENCODING = 'utf-8' if ($Help) { Write-Host '用法:update.bat 或 update-cn.bat [-Help]' + Write-Host '未运行过启动脚本也可升级;缺少的 Python 环境会自动准备。' Write-Host '升级会终止本项目的 ARTEMIS 服务、MCP 和任务进程;保留配置、密钥与历史资料。' Write-Host '随后快进更新源码,同步依赖、网页构建和 Codex MCP;完成后请重新启动并重载 MCP。' exit 0 @@ -33,7 +34,7 @@ if (-not $Staged) { if ($fullStage.StartsWith($rootPrefix, [StringComparison]::OrdinalIgnoreCase)) { throw 'TEMP must be outside the project directory.' } New-Item -ItemType Directory -Path $stage | Out-Null $stageCreated = $true - foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py')) { + foreach ($name in @('upgrade.ps1', 'installation_lock.ps1', 'upgrade_processes.ps1', 'update_installation.py', 'windows_tools.ps1', 'download_config.ps1', 'bootstrap_python.ps1')) { Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $stage $name) } $forward = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', (Join-Path $stage 'upgrade.ps1'), '-Staged', '-ProjectRoot', $ProjectRoot) @@ -47,13 +48,15 @@ if (-not $Staged) { if ($stageCreated -and (Test-Path -LiteralPath $stage)) { Remove-Item -LiteralPath $stage -Recurse -Force } Exit-ArtemisInstallationLock -Mutex $lock } + if ($code -ne 0 -and $env:ARTEMIS_NO_PAUSE -ne '1' -and -not [Console]::IsInputRedirected) { + try { Read-Host '[升级失败] 请查看上方原因。按回车键关闭窗口' | Out-Null } catch {} + } exit $code } try { Set-Location -LiteralPath $PSScriptRoot $python = Join-Path $ProjectRoot '.venv\Scripts\python.exe' - if (-not (Test-Path -LiteralPath $python)) { throw '尚未安装项目 Python 环境;请先运行 start.bat 或 start-cn.bat。' } if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw '未安装 Git,无法升级。' } if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot '.git'))) { throw '此目录不含 Git 信息;请改用 Git 克隆或新版发布源码包。' } # Refuse dirty checkouts before terminating the user's running processes. @@ -64,6 +67,15 @@ try { Write-Host 'ARTEMIS 独立升级:将终止当前项目的服务、MCP 及任务。' -ForegroundColor Yellow . (Join-Path $PSScriptRoot 'upgrade_processes.ps1') Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot + if (-not (Test-Path -LiteralPath $python)) { + Write-Host '[首次准备] 未找到项目 Python 环境,正在自动安装;随后拉取源码并同步新版依赖。' -ForegroundColor Cyan + . (Join-Path $PSScriptRoot 'windows_tools.ps1') + . (Join-Path $PSScriptRoot 'download_config.ps1') + . (Join-Path $PSScriptRoot 'bootstrap_python.ps1') + Initialize-ArtemisDownloads -ProjectRoot $ProjectRoot -Mode $Mirror + Update-EnvironmentPath + $python = Initialize-ArtemisPython -ProjectRoot $ProjectRoot -RuntimeOnly + } & $python (Join-Path $PSScriptRoot 'update_installation.py') source --root $ProjectRoot if ($LASTEXITCODE -notin @(0, 10)) { throw '源码升级未完成;配置和历史资料已保留。' } Stop-ArtemisUpgradeProcesses -ProjectRoot $ProjectRoot diff --git a/scripts/windows_tools.ps1 b/scripts/windows_tools.ps1 new file mode 100644 index 0000000..a7699c7 --- /dev/null +++ b/scripts/windows_tools.ps1 @@ -0,0 +1,58 @@ +# Process-local Windows tool discovery shared by startup and the frozen upgrader. +# Keep UTF-8 BOM for Windows PowerShell 5.1. + +function Test-CommandExists { + param([string]$Command) + $res = Get-Command $Command -ErrorAction SilentlyContinue + return ($null -ne $res) +} + +function Update-EnvironmentPath { + $standardDirs = @( + "$env:LOCALAPPDATA\Microsoft\WinGet\Links", + "$env:LOCALAPPDATA\Programs\node", + "$env:USERPROFILE\.local\share\node", + "$env:LOCALAPPDATA\Programs\scrcpy", + "$env:USERPROFILE\.local\share\scrcpy", + "$env:LOCALAPPDATA\Programs\platform-tools", + "$env:USERPROFILE\.local\share\platform-tools", + "$env:LOCALAPPDATA\Programs\uv", + "$env:LOCALAPPDATA\uv", + "$env:APPDATA\uv", + "$env:USERPROFILE\.cargo\bin", + "$env:USERPROFILE\.local\bin", + "$env:USERPROFILE\scoop\shims", + "C:\ProgramData\chocolatey\bin", + "$env:LOCALAPPDATA\Android\Sdk\platform-tools", + "$env:LOCALAPPDATA\Android\android-sdk\platform-tools", + "$env:ProgramFiles\Android\platform-tools", + "${env:ProgramFiles(x86)}\Android\android-sdk\platform-tools", + "$env:ProgramFiles\nodejs", + "${env:ProgramFiles(x86)}\nodejs", + "$env:APPDATA\npm" + ) + if ($env:CARGO_HOME) { $standardDirs = @("$env:CARGO_HOME\bin") + $standardDirs } + if ($env:ANDROID_HOME) { $standardDirs += "$env:ANDROID_HOME\platform-tools" } + if ($env:ANDROID_SDK_ROOT) { $standardDirs += "$env:ANDROID_SDK_ROOT\platform-tools" } + if ($env:NVM_SYMLINK) { $standardDirs = @($env:NVM_SYMLINK) + $standardDirs } + if ($env:NVM_HOME) { $standardDirs = @($env:NVM_HOME) + $standardDirs } + + $regPath = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine") + $currentPaths = ($env:PATH -split ";") + ($regPath -split ";") + $standardDirs | Where-Object { [string]::IsNullOrWhiteSpace($_) -eq $false -and (Test-Path $_) } | Select-Object -Unique + $env:PATH = $currentPaths -join ";" + + # Ensure user-space portable tools take absolute priority over system-level outdated versions + $portableNode = "$env:LOCALAPPDATA\Programs\node" + if (Test-Path "$portableNode\node.exe") { + $env:PATH = "$portableNode;$env:PATH" + } + $portableScrcpy = "$env:LOCALAPPDATA\Programs\scrcpy" + if (Test-Path "$portableScrcpy\scrcpy.exe") { + $env:PATH = "$portableScrcpy;$env:PATH" + } + $portablePt = "$env:LOCALAPPDATA\Programs\platform-tools" + if (Test-Path "$portablePt\adb.exe") { + $env:PATH = "$portablePt;$env:PATH" + } +} + diff --git a/tests/unit/test_download_mirrors.py b/tests/unit/test_download_mirrors.py index f6d1d43..69f64bd 100644 --- a/tests/unit/test_download_mirrors.py +++ b/tests/unit/test_download_mirrors.py @@ -22,7 +22,10 @@ pytestmark = pytest.mark.skipif( def powershell(tmp_path, script, **env): path = tmp_path / "test.ps1" path.write_text( - "$ErrorActionPreference = 'Stop'\n. $env:DOWNLOAD_SCRIPT\n" + script, encoding="ascii" + "$ErrorActionPreference = 'Stop'\n" + "[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n" + ". $env:DOWNLOAD_SCRIPT\n" + script, + encoding="ascii", ) clean_env = { key: value @@ -38,7 +41,7 @@ def powershell(tmp_path, script, **env): **env, }, capture_output=True, - text=True, + encoding="utf-8", timeout=45, ) @@ -58,7 +61,8 @@ if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw ' if ($ArtemisDownloads.python_index -ne 'https://intranet.example/simple') { throw 'Environment ignored' } if ($env:npm_config_registry -ne 'https://registry.npmmirror.com') { throw 'npm mirror missing' } if ($env:UV_HTTP_TIMEOUT -ne '180') { throw 'Timeout missing' } -if ($ArtemisDownloads.scrcpy_url -match 'github[.]com') { throw 'China scrcpy still uses GitHub' } +if (([Uri]$ArtemisDownloads.scrcpy_url).Host -eq 'github.com') { throw 'China scrcpy still uses GitHub' } +if ($ArtemisDownloads.scrcpy_urls.Count -lt 2) { throw 'China scrcpy has no fallback' } if ($ArtemisDownloads.scrcpy_sha256 -notmatch '^[a-f0-9]{64}$') { throw 'scrcpy digest missing' } Remove-Item Env:npm_config_registry Remove-Item Env:UV_PYTHON_INSTALL_MIRROR @@ -71,11 +75,153 @@ if ($env:npm_config_registry) { throw 'Official mode selected a China registry' assert result.returncode == 0, result.stdout + result.stderr -def test_scrcpy_archive_is_checked_before_extraction(): - launcher = (ROOT / "scripts/start.ps1").read_text(encoding="utf-8-sig") - assert launcher.index("Get-FileHash -LiteralPath $zipPath") < launcher.index( - "Remove-Item $extractDir -Recurse" +@pytest.mark.parametrize("first_failure", ["http", "timeout", "hash", "all"]) +def test_scrcpy_source_failover_checks_hash_before_installing(tmp_path, first_failure): + archive = tmp_path / "fixture.zip" + with zipfile.ZipFile(archive, "w") as package: + package.writestr("scrcpy-win64-v4.1/scrcpy.exe", b"verified executable") + package.writestr("scrcpy-win64-v4.1/scrcpy-server", b"verified server") + digest = hashlib.sha256(archive.read_bytes()).hexdigest() + install_dir = tmp_path / "user/Programs/scrcpy" + install_dir.mkdir(parents=True) + (install_dir / "keep.txt").write_text("user file") + temp_dir = tmp_path / "temporary" + temp_dir.mkdir() + result = powershell( + tmp_path, + """ +$tokens = $null; $errors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($env:LAUNCH_SCRIPT, [ref]$tokens, [ref]$errors) +if ($errors) { throw 'Launcher parse error' } +$installer = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Install-PortableScrcpy' }, $true) +. ([scriptblock]::Create($installer.Extent.Text)) +function Get-LaunchText { param($English, $Chinese); return $English } +$script:attempts = @() +function Invoke-DownloadFile { + param($Uri, $OutFile) + $script:attempts += $Uri + if ($Uri -eq 'https://first.example/scrcpy.zip' -or $env:FIRST_FAILURE -eq 'all') { + Set-Content -LiteralPath $OutFile -Value 'rejected content' + if ($env:FIRST_FAILURE -eq 'hash') { return $true } + $script:ArtemisDownloadError = if ($env:FIRST_FAILURE -eq 'timeout') { 'Download timed out' } else { 'HTTP 404' } + return $false + } + Copy-Item -LiteralPath (Join-Path $env:TEST_ROOT 'fixture.zip') -Destination $OutFile -Force + return $true +} +$script:ArtemisDownloads = [pscustomobject]@{ + scrcpy_urls = @('https://first.example/scrcpy.zip', 'https://second.example/scrcpy.zip') + scrcpy_sha256 = $env:FIXTURE_HASH +} +$installed = Install-PortableScrcpy +Write-Host "INSTALLED=$installed" +Write-Host ('ATTEMPTS=' + ($script:attempts -join ',')) +""", + LOCALAPPDATA=str(tmp_path / "user"), + TEMP=str(temp_dir), + TMP=str(temp_dir), + LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"), + FIRST_FAILURE=first_failure, + FIXTURE_HASH=digest, ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stderr == "" + assert "ATTEMPTS=https://first.example/scrcpy.zip,https://second.example/scrcpy.zip" in ( + result.stdout + ) + assert (install_dir / "keep.txt").read_text() == "user file" + assert not list(temp_dir.iterdir()), "Installer must clean its temporary download directory" + if first_failure == "all": + assert "INSTALLED=False" in result.stdout + assert "HTTP 404" in result.stdout + assert not (install_dir / "scrcpy.exe").exists() + else: + assert "INSTALLED=True" in result.stdout + assert (install_dir / "scrcpy.exe").read_bytes() == b"verified executable" + assert (install_dir / "scrcpy-server").read_bytes() == b"verified server" + expected_error = { + "http": "HTTP 404", + "timeout": "Download timed out", + "hash": "SHA256 verification failed", + }[first_failure] + assert expected_error in result.stdout + + +@pytest.mark.parametrize("curl_exit", [22, 28]) +def test_download_reports_http_or_timeout_without_repeating_request(tmp_path, curl_exit): + result = powershell( + tmp_path, + """ +function curl.exe { + $target = $args[[Array]::IndexOf($args, '-o') + 1] + Set-Content -LiteralPath $target -Value 'partial download' + $global:LASTEXITCODE = [int]$env:CURL_EXIT + return '404' +} +function Invoke-WebRequest { throw 'HTTP rejection/timeout must not be retried with a second client' } +$destination = Join-Path $env:TEST_ROOT 'download.zip' +$downloaded = Invoke-DownloadFile -Uri 'https://missing.example/file.zip' -OutFile $destination -TimeoutSec 10 +Write-Host "DOWNLOADED=$downloaded" +Write-Host "REASON=$script:ArtemisDownloadError" +if (Test-Path -LiteralPath $destination) { throw 'Partial download was left behind' } +""", + CURL_EXIT=str(curl_exit), + ) + assert result.returncode == 0, result.stdout + result.stderr + assert "DOWNLOADED=False" in result.stdout + assert ("HTTP 404" if curl_exit == 22 else "timed out after 10 seconds") in result.stdout + + +@pytest.mark.parametrize( + "fallbacks,valid", + [ + (["https://backup.example/scrcpy.zip"], True), + ([], True), + (["http://insecure.example/scrcpy.zip"], False), + ("https://not-an-array.example/scrcpy.zip", False), + ([None], False), + ], +) +def test_scrcpy_custom_sources_validate_urls_and_preserve_order(tmp_path, fallbacks, valid): + config = json.loads((ROOT / "config/downloads.json").read_text()) + config["overrides"]["scrcpy_url"] = "https://custom.example/scrcpy.zip" + config["overrides"]["scrcpy_fallback_urls"] = fallbacks + (tmp_path / "config").mkdir() + (tmp_path / "config/downloads.json").write_text(json.dumps(config)) + result = powershell( + tmp_path, + """ +Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China +Write-Host ('SOURCES=' + ($ArtemisDownloads.scrcpy_urls -join ',')) +""", + ) + if valid: + assert result.returncode == 0, result.stdout + result.stderr + assert "SOURCES=" + ",".join(["https://custom.example/scrcpy.zip", *fallbacks]) in ( + result.stdout + ) + else: + assert result.returncode != 0 + assert "HTTPS URLs" in result.stderr + + +def test_china_sources_never_fall_back_to_direct_github(tmp_path): + config = json.loads((ROOT / "config/downloads.json").read_text()) + config["overrides"]["scrcpy_fallback_urls"] = [ + "https://github.com/Genymobile/scrcpy/releases/download/v4.1/scrcpy-win64-v4.1.zip", + config["china"]["scrcpy_url"], # Duplicate should be downloaded only once. + ] + (tmp_path / "config").mkdir() + (tmp_path / "config/downloads.json").write_text(json.dumps(config)) + result = powershell( + tmp_path, + """ +Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode China +if ($ArtemisDownloads.scrcpy_urls.Count -ne 1) { throw 'Invalid China sources' } +if (([Uri]$ArtemisDownloads.scrcpy_urls[0]).Host -eq 'github.com') { throw 'Direct GitHub fallback' } +""", + ) + assert result.returncode == 0, result.stdout + result.stderr @pytest.mark.parametrize("corrupt", [False, True]) diff --git a/tests/unit/test_windows_upgrade.py b/tests/unit/test_windows_upgrade.py index 8e93e1d..ce3012b 100644 --- a/tests/unit/test_windows_upgrade.py +++ b/tests/unit/test_windows_upgrade.py @@ -11,6 +11,15 @@ import time import pytest ROOT = Path(__file__).resolve().parents[2] +UPGRADE_FILES = ( + "upgrade.ps1", + "upgrade_processes.ps1", + "installation_lock.ps1", + "update_installation.py", + "windows_tools.ps1", + "download_config.ps1", + "bootstrap_python.ps1", +) pytestmark = pytest.mark.skipif( sys.platform != "win32" or not shutil.which("powershell.exe") or not shutil.which("uv"), reason="Windows PowerShell and uv required", @@ -138,20 +147,22 @@ def test_real_project_process_tree_stops_but_unrelated_process_survives(tmp_path @pytest.mark.parametrize("prepare_exit", [0, 7]) +@pytest.mark.parametrize("initialized", [True, False]) def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure( - tmp_path, prepare_exit + tmp_path, prepare_exit, initialized ): if not shutil.which("git"): pytest.skip("Git required") author = tmp_path / "author" (author / "scripts").mkdir(parents=True) - for name in ( - "upgrade.ps1", - "upgrade_processes.ps1", - "installation_lock.ps1", - "update_installation.py", - ): + for name in UPGRADE_FILES: shutil.copyfile(ROOT / "scripts" / name, author / "scripts" / name) + (author / "config").mkdir() + shutil.copyfile(ROOT / "config/downloads.json", author / "config/downloads.json") + (author / "pyproject.toml").write_text( + '[project]\nname="upgrade-fixture"\nversion="0.1.0"\nrequires-python=">=3.12"\ndependencies=[]\n' + ) + (author / "uv.lock").write_text('version = 1\nrequires-python = ">=3.12"\n') for name in ("update.bat", "update-cn.bat"): shutil.copyfile(ROOT / name, author / name) (author / ".gitignore").write_text(".env\n.venv/\nprepared.txt\n") @@ -161,15 +172,18 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure( git(author, "config", "user.email", "fixture@example.test") git(author, "add", ".") git(author, "commit", "-m", "old version") - installed = tmp_path / "installed with spaces!" + installed = tmp_path / "installed 中文 with spaces!" git(tmp_path, "clone", "-b", "main", str(author), str(installed)) - make_venv(installed) + if initialized: + make_venv(installed) (installed / ".env").write_text("secret stays local") # A running file AND both BAT wrappers are replaced during the merge. (author / "scripts/upgrade.ps1").write_text("throw 'new updater must not run in this turn'\n") (author / "scripts/upgrade_processes.ps1").write_text( "throw 'new process helper must not run'\n" ) + for name in ("windows_tools.ps1", "download_config.ps1", "bootstrap_python.ps1"): + (author / "scripts" / name).write_text("throw 'new bootstrap helper must not run'\n") (author / "update.bat").write_text("@echo off\nexit /b 33\n") (author / "update-cn.bat").write_text("@echo off\nexit /b 34\n") (author / "scripts/start.ps1").write_text( @@ -184,17 +198,25 @@ def test_frozen_upgrade_survives_launcher_replacement_and_reports_setup_failure( result = subprocess.run( ["cmd.exe", "/d", "/c", "update-cn.bat"], cwd=installed, + env={**os.environ, "UV_OFFLINE": "1", "ARTEMIS_NO_PAUSE": "1"}, capture_output=True, encoding="utf-8", timeout=45, ) assert result.returncode == (0 if prepare_exit == 0 else 1), result.stdout + result.stderr - assert result.stderr == "" + if initialized: + assert result.stderr == "" + else: + # uv prints normal venv creation progress to stderr. + assert "Creating virtual environment" in result.stderr assert (installed / "prepared.txt").read_text().strip() == "new version prepared" assert git(installed, "rev-parse", "HEAD") == git(author, "rev-parse", "HEAD") assert (installed / ".env").read_text() == "secret stays local" assert ("升级完成" in result.stdout) == (prepare_exit == 0) assert "new updater must not run" not in result.stdout + assert "new bootstrap helper must not run" not in result.stdout + assert ("首次准备" in result.stdout) == (not initialized) + assert (installed / ".venv/Scripts/python.exe").exists() @pytest.mark.parametrize("name", ["update.bat", "update-cn.bat"]) @@ -262,7 +284,7 @@ def test_installation_lock_blocks_same_checkout_and_recovers_after_owner_crash(t def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_path): root = tmp_path / "prepared project" (root / "scripts").mkdir(parents=True) - for name in ("start.ps1", "installation_lock.ps1"): + for name in ("start.ps1", "installation_lock.ps1", "windows_tools.ps1"): shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name) (root / "scripts/download_config.ps1").write_text( "function Initialize-ArtemisDownloads { $script:ArtemisDownloads = @{mode='Official'} }\n", @@ -314,17 +336,13 @@ def test_actual_preparation_build_checks_run_without_launching_ui_or_adb(tmp_pat assert (root / ".env").read_text() == "keep credentials" -def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path): +@pytest.mark.parametrize("initialized", [True, False]) +def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path, initialized): if not shutil.which("git"): pytest.skip("Git required") root = tmp_path / "dirty installation" (root / "scripts").mkdir(parents=True) - for name in ( - "upgrade.ps1", - "upgrade_processes.ps1", - "installation_lock.ps1", - "update_installation.py", - ): + for name in UPGRADE_FILES: shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name) (root / ".gitignore").write_text(".venv/\n") source = root / "user.txt" @@ -334,7 +352,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path): git(root, "config", "user.email", "fixture@example.test") git(root, "add", ".") git(root, "commit", "-m", "initial") - python = make_venv(root) + python = make_venv(root) if initialized else sys.executable source.write_text("user changes") worker = subprocess.Popen([str(python), "-c", "import time; time.sleep(60)"]) try: @@ -344,6 +362,7 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path): assert "[STOP]" not in result.stdout assert worker.poll() is None assert source.read_text() == "user changes" + assert (root / ".venv").exists() == initialized finally: # Windows venv redirectors can have a second interpreter process. import psutil @@ -355,3 +374,32 @@ def test_dirty_checkout_refuses_upgrade_before_stopping_processes(tmp_path): if child.is_running(): child.kill() worker.wait(timeout=5) + + +@pytest.mark.parametrize("launcher", ["update.bat", "update-cn.bat"]) +def test_fresh_non_git_directory_reports_error_without_bootstrapping_or_hanging(tmp_path, launcher): + root = tmp_path / "未初始化 source with spaces!" + (root / "scripts").mkdir(parents=True) + for name in UPGRADE_FILES: + shutil.copyfile(ROOT / "scripts" / name, root / "scripts" / name) + for name in ("update.bat", "update-cn.bat"): + shutil.copyfile(ROOT / name, root / name) + (root / ".env").write_text("existing config must stay local") + result = subprocess.run( + ["cmd.exe", "/d", "/c", launcher], + cwd=root, + # Redirected input must not wait for a key, even without ARTEMIS_NO_PAUSE. + env={key: value for key, value in os.environ.items() if key != "ARTEMIS_NO_PAUSE"}, + input="", + capture_output=True, + encoding="utf-8", + timeout=15, + ) + assert result.returncode == 1 + assert result.stderr == "" + assert "[升级失败]" in result.stdout + assert "此目录不含 Git 信息" in result.stdout + assert "首次准备" not in result.stdout + assert "�" not in result.stdout + assert not (root / ".venv").exists() + assert (root / ".env").read_text() == "existing config must stay local"