344 lines
14 KiB
Python
344 lines
14 KiB
Python
"""Check download presets and install locked packages from an isolated local index."""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import zipfile
|
|
|
|
import pytest
|
|
|
|
from tests.unit.test_windows_bootstrap import run_bootstrap
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
pytestmark = pytest.mark.skipif(
|
|
sys.platform != "win32" or not shutil.which("uv"), reason="Windows and uv required"
|
|
)
|
|
|
|
|
|
def powershell(tmp_path, script, **env):
|
|
path = tmp_path / "test.ps1"
|
|
path.write_text(
|
|
"$ErrorActionPreference = 'Stop'\n"
|
|
"[Console]::OutputEncoding = New-Object Text.UTF8Encoding($false)\n"
|
|
". $env:DOWNLOAD_SCRIPT\n" + script,
|
|
encoding="ascii",
|
|
)
|
|
clean_env = {
|
|
key: value
|
|
for key, value in os.environ.items()
|
|
if not key.upper().startswith(("UV_", "NPM_CONFIG_"))
|
|
}
|
|
return subprocess.run(
|
|
["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(path)],
|
|
env={
|
|
**clean_env,
|
|
"DOWNLOAD_SCRIPT": str(ROOT / "scripts/download_config.ps1"),
|
|
"TEST_ROOT": str(tmp_path),
|
|
**env,
|
|
},
|
|
capture_output=True,
|
|
encoding="utf-8",
|
|
timeout=45,
|
|
)
|
|
|
|
|
|
def test_presets_overrides_and_process_environment(tmp_path):
|
|
(tmp_path / "config").mkdir()
|
|
config = json.loads((ROOT / "config/downloads.json").read_text())
|
|
config["mode"] = "China"
|
|
config["overrides"]["node_mirror"] = "https://intranet.example/node"
|
|
(tmp_path / "config/downloads.json").write_text(json.dumps(config))
|
|
result = powershell(
|
|
tmp_path,
|
|
"""
|
|
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT
|
|
if ($ArtemisDownloads.mode -ne 'China') { throw 'Preset not selected' }
|
|
if ($ArtemisDownloads.node_mirror -ne 'https://intranet.example/node') { throw 'Override ignored' }
|
|
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 (([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
|
|
Initialize-ArtemisDownloads -ProjectRoot $env:TEST_ROOT -Mode Official
|
|
if ($ArtemisDownloads.mode -ne 'Official') { throw 'CLI mode ignored' }
|
|
if ($env:npm_config_registry) { throw 'Official mode selected a China registry' }
|
|
""",
|
|
UV_DEFAULT_INDEX="https://intranet.example/simple",
|
|
)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
|
|
def test_node_compatibility_matches_angular_supported_release_lines(tmp_path):
|
|
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' }
|
|
$checker = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Test-NodeCompatible' }, $true)
|
|
. ([scriptblock]::Create($checker.Extent.Text))
|
|
function Test-CommandExists { return $true }
|
|
function node { return $script:nodeVersion }
|
|
$cases = @{
|
|
'v20.19.0' = $false; 'v22.22.0' = $false; 'v22.22.2' = $false
|
|
'v22.22.3' = $true; 'v22.23.2' = $true; 'v23.0.0' = $false
|
|
'v24.14.0' = $false; 'v24.15.0' = $true; 'v24.22.1' = $true
|
|
'v24.15.0-rc.1' = $false; 'v25.0.0' = $false; 'v26.0.0' = $true
|
|
'not-a-version' = $false
|
|
}
|
|
foreach ($case in $cases.GetEnumerator()) {
|
|
$script:nodeVersion = $case.Key
|
|
if ((Test-NodeCompatible) -ne $case.Value) { throw "Wrong compatibility for $($case.Key)" }
|
|
}
|
|
""",
|
|
LAUNCH_SCRIPT=str(ROOT / "scripts/start.ps1"),
|
|
)
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
|
|
|
|
@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])
|
|
def test_uv_mirror_bootstrap_verifies_wheel_without_python(tmp_path, corrupt):
|
|
with zipfile.ZipFile(tmp_path / "fixture.zip", "w") as wheel:
|
|
wheel.writestr("uv-1.2.3.data/scripts/uv.exe", b"standalone fixture")
|
|
digest = hashlib.sha256((tmp_path / "fixture.zip").read_bytes()).hexdigest()
|
|
if corrupt:
|
|
digest = "0" * 64
|
|
(tmp_path / "index.html").write_text(
|
|
f'<a href="../../packages/uv-1.2.3-py3-none-win_amd64.whl#sha256={digest}">uv</a>'
|
|
)
|
|
result = powershell(
|
|
tmp_path,
|
|
"""
|
|
function Invoke-DownloadFile {
|
|
param($Uri, $OutFile)
|
|
if ($Uri -eq 'https://mirror.example/simple/uv/') {
|
|
Copy-Item -LiteralPath (Join-Path $env:TEST_ROOT 'index.html') -Destination $OutFile
|
|
} elseif ($Uri -eq 'https://mirror.example/packages/uv-1.2.3-py3-none-win_amd64.whl') {
|
|
Copy-Item -LiteralPath (Join-Path $env:TEST_ROOT 'fixture.zip') -Destination $OutFile
|
|
} else { throw "Unexpected URL: $Uri" }
|
|
return $true
|
|
}
|
|
Install-ArtemisUvFromIndex -IndexUrl 'https://mirror.example/simple' -InstallDir (Join-Path $env:TEST_ROOT 'bin')
|
|
""",
|
|
PROCESSOR_ARCHITECTURE="AMD64",
|
|
PROCESSOR_ARCHITEW6432="AMD64",
|
|
)
|
|
if corrupt:
|
|
assert result.returncode != 0
|
|
assert "SHA256 verification" in result.stderr
|
|
assert not (tmp_path / "bin/uv.exe").exists()
|
|
else:
|
|
assert result.returncode == 0, result.stdout + result.stderr
|
|
assert (tmp_path / "bin/uv.exe").read_bytes() == b"standalone fixture"
|
|
|
|
|
|
def test_mirror_installs_locked_version_and_reuses_it_without_changing_lock(tmp_path):
|
|
project = tmp_path / "project"
|
|
project.mkdir()
|
|
package_dir = tmp_path / "simple/mirror-fixture"
|
|
package_dir.mkdir(parents=True)
|
|
wheel = package_dir / "mirror_fixture-1.0.0-py3-none-any.whl"
|
|
with zipfile.ZipFile(wheel, "w") as archive:
|
|
archive.writestr("mirror_fixture.py", 'VERSION = "1.0.0"\n')
|
|
archive.writestr(
|
|
"mirror_fixture-1.0.0.dist-info/METADATA",
|
|
"Metadata-Version: 2.1\nName: mirror-fixture\nVersion: 1.0.0\n",
|
|
)
|
|
archive.writestr(
|
|
"mirror_fixture-1.0.0.dist-info/WHEEL",
|
|
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
|
)
|
|
archive.writestr("mirror_fixture-1.0.0.dist-info/RECORD", "")
|
|
digest = hashlib.sha256(wheel.read_bytes()).hexdigest()
|
|
(package_dir / "index.html").write_text(
|
|
f'<a href="{wheel.name}#sha256={digest}">{wheel.name}</a>'
|
|
)
|
|
(project / "pyproject.toml").write_text(
|
|
'[project]\nname="bootstrap-fixture"\nversion="0.1.0"\n'
|
|
'requires-python=">=3.12"\ndependencies=["mirror-fixture==1.0.0"]\n'
|
|
)
|
|
for module in ("artemis", "uvicorn", "fastapi", "langchain_openai"):
|
|
(project / f"{module}.py").write_text("")
|
|
lock = f"""version = 1
|
|
revision = 3
|
|
requires-python = ">=3.12"
|
|
[[package]]
|
|
name = "bootstrap-fixture"
|
|
version = "0.1.0"
|
|
source = {{ virtual = "." }}
|
|
dependencies = [{{ name = "mirror-fixture" }}]
|
|
[package.metadata]
|
|
requires-dist = [{{ name = "mirror-fixture", specifier = "==1.0.0" }}]
|
|
[[package]]
|
|
name = "mirror-fixture"
|
|
version = "1.0.0"
|
|
source = {{ registry = "https://pypi.org/simple" }}
|
|
wheels = [{{ url = "https://unreachable.invalid/{wheel.name}", hash = "sha256:{digest}", size = {wheel.stat().st_size} }}]
|
|
"""
|
|
(project / "uv.lock").write_text(lock)
|
|
index = (tmp_path / "simple").as_uri()
|
|
first = run_bootstrap(project, mirror_index=index)
|
|
assert first.returncode == 0, first.stdout + first.stderr
|
|
assert (project / ".venv/Lib/site-packages/mirror_fixture.py").exists()
|
|
assert (project / "uv.lock").read_text() == lock
|
|
second = run_bootstrap(project, mirror_index=index)
|
|
assert second.returncode == 0, second.stdout + second.stderr
|
|
assert "dependencies are already ready" in second.stdout
|
|
assert (project / "uv.lock").read_text() == lock
|