Files
nyaterm/scripts/tests/test_package_native.py
T
Kang d37cd2a042 feat(conpty): integrate bundled ConPTY support for Windows
- Added support for a bundled ConPTY implementation on Windows, enhancing local terminal functionality.
- Updated the Cargo.toml and Cargo.lock to include the new `nyaterm-transport` dependency.
- Implemented configuration and status checks for the bundled ConPTY in the application.
- Enhanced the UI to display ConPTY status and added localization for related messages in multiple languages.
- Updated release scripts to package and verify ConPTY files for Windows distributions.
- Added tests to ensure proper functionality and integration of the ConPTY feature.
2026-09-29 20:07:19 +08:00

438 lines
22 KiB
Python

from __future__ import annotations
import plistlib
import shutil
import subprocess
import struct
import tarfile
from contextlib import contextmanager
import sys
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest import mock
RELEASE_SCRIPTS = Path(__file__).resolve().parents[1] / "release"
sys.path.insert(0, str(RELEASE_SCRIPTS))
import package_native # noqa: E402
import verify_native_package # noqa: E402
@contextmanager
def staged_package(target: str):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
binary = root / "build" / "nyaterm"
binary.parent.mkdir()
binary.write_bytes(b"application")
suffix = ".exe" if "windows" in target else ""
helpers = [binary.parent / f"{name}{suffix}" for name in package_native.HELPER_BINS]
for helper in helpers:
helper.write_bytes(b"helper")
if "windows" in target:
for relative in package_native.conpty_files(target):
path = root / "staging/resources/windows/conpty" / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"MZ")
def run(command, **kwargs):
if command[0] == "rpmbuild":
output = root / "work" / "rpm" / "rpmbuild" / "RPMS" / package_native.linux_rpm_arch(target)
output.mkdir(parents=True)
(output / "test.rpm").write_bytes(b"rpm")
with (
mock.patch.object(package_native, "WORK_DIR", root / "work"),
mock.patch.object(package_native, "DIST_DIR", root / "dist"),
mock.patch.object(package_native.prepare_conpty, "STAGING", root / "staging"),
mock.patch.object(package_native, "helper_binary_paths", return_value=helpers),
mock.patch.object(package_native, "require_tool", side_effect=lambda name: name),
mock.patch.object(package_native, "find_makensis", return_value="makensis"),
mock.patch.object(package_native, "run", side_effect=run),
mock.patch.object(package_native, "linux_deb_dependencies", return_value="libc6"),
):
package_native.WORK_DIR.mkdir()
package_native.DIST_DIR.mkdir()
yield root, binary, package_native.target_info(target)
class PackageNativeTests(unittest.TestCase):
@unittest.skipUnless(shutil.which("makensis"), "NSIS is not installed")
def test_windows_installer_with_conpty_compiles_with_nsis(self) -> None:
with staged_package("x86_64-pc-windows-msvc") as (root, binary, info):
with (
mock.patch.object(package_native, "find_makensis", return_value=shutil.which("makensis")),
mock.patch.object(
package_native,
"run",
side_effect=lambda command: subprocess.run(
command, check=True, capture_output=True, text=True
),
),
):
package_native.create_windows_packages(binary, info, "2.0.0", "2.0.0")
self.assertGreater(
(root / "dist/NyaTerm_2.0.0_windows_x64-setup.exe").stat().st_size,
1024,
)
def test_semver_resolves_application_identity(self) -> None:
for version in ("2.0.0", "2.0.1", "2.1.0", "2.0.0+build-with-hyphen"):
self.assertEqual(package_native.release_identity(version), package_native.STABLE_IDENTITY)
for version in ("2.0.0-preview.1", "2.0.0-preview.2", "2.0.0-beta.1", "2.0.0-rc.1"):
self.assertEqual(package_native.release_identity(version), package_native.PREVIEW_IDENTITY)
for version in ("02.0.0", "2.0.0-preview.01", "2.0.0-", "2.0.0+", "2.0.0-a..b"):
with self.subTest(version=version), self.assertRaises(ValueError):
package_native.release_identity(version)
def test_windows_identity_isolation_in_generated_installer(self) -> None:
for version in ("2.0.0", "2.0.0-preview.1"):
with self.subTest(version=version), staged_package("x86_64-pc-windows-msvc") as (root, binary, info):
identity = package_native.release_identity(version)
package_native.create_windows_packages(binary, info, version, version)
script = (root / "work" / "nyaterm-installer.nsi").read_text()
verify_native_package.verify_windows_installer_script(script, version)
numeric_version = package_native.windows_numeric_version(version)
self.assertIn(f'VIProductVersion "{numeric_version}"', script)
self.assertIn(f'VIFileVersion "{numeric_version}"', script)
self.assertIn(f'VIAddVersionKey "ProductVersion" "{version}"', script)
self.assertIn(f'VIAddVersionKey "FileVersion" "{version}"', script)
self.assertIn(f'InstallDir "$LOCALAPPDATA\\Programs\\{identity.display_name}"', script)
self.assertIn(r'!define MUI_FINISHPAGE_RUN "$INSTDIR\NyaTerm.exe"', script)
self.assertIn(
f'!define MUI_FINISHPAGE_RUN_TEXT "Launch {identity.display_name}"',
script,
)
self.assertIn(f'DeleteRegKey HKCU "{identity.windows_registry_key}"', script)
other_version = "2.0.0-preview.1" if version == "2.0.0" else "2.0.0"
with self.assertRaises(RuntimeError):
verify_native_package.verify_windows_installer_script(script, other_version)
other = package_native.release_identity(other_version)
with self.assertRaises(RuntimeError):
verify_native_package.verify_windows_installer_script(
script + f'\nDeleteRegKey HKCU "{other.windows_registry_key}"', version
)
def test_macos_bundle_and_updater_archive_use_the_same_identity(self) -> None:
for version in ("2.0.0", "2.0.0-preview.1"):
with self.subTest(version=version), staged_package("aarch64-apple-darwin") as (root, binary, info):
identity = package_native.release_identity(version)
package_native.create_macos_packages(binary, info, version, version)
bundle = root / "work" / identity.macos_bundle_name
plist = plistlib.loads((bundle / "Contents" / "Info.plist").read_bytes())
self.assertEqual(plist["CFBundleDisplayName"], identity.display_name)
self.assertEqual(plist["CFBundleName"], identity.display_name)
self.assertEqual(plist["CFBundleIdentifier"], identity.macos_identifier)
self.assertEqual(plist["CFBundleURLTypes"][0]["CFBundleURLSchemes"], [identity.desktop_id])
self.assertTrue((root / "work" / "dmg" / identity.macos_bundle_name).is_dir())
self.assertEqual((root / "work" / "dmg" / "Applications").readlink().as_posix(), "/Applications")
archive = next((root / "dist").glob("*.tar.gz"))
with tarfile.open(archive) as handle:
self.assertIn(f"{identity.macos_bundle_name}/Contents/MacOS/NyaTerm", handle.getnames())
def test_run_with_retry_retries_transient_command_failures(self) -> None:
failure = subprocess.CalledProcessError(1, ["hdiutil", "create"])
with (
mock.patch.object(package_native, "run", side_effect=[failure, None]) as run,
mock.patch.object(package_native.time, "sleep") as sleep,
):
package_native.run_with_retry(
["hdiutil", "create"], attempts=3, delay_seconds=0.5
)
self.assertEqual(run.call_count, 2)
sleep.assert_called_once_with(0.5)
def test_linux_formats_use_isolated_package_desktop_icons_and_install_paths(self) -> None:
for version in ("2.0.0", "2.0.0-preview.1"):
with self.subTest(version=version), staged_package("x86_64-unknown-linux-gnu") as (root, binary, info):
identity = package_native.release_identity(version)
package_native.create_linux_packages(binary, info, version, version)
control = (root / "work" / "deb" / "DEBIAN" / "control").read_text()
self.assertIn(f"Package: {identity.desktop_id}\n", control)
spec = (root / "work" / "rpm" / "rpmbuild" / "SPECS" / "nyaterm.spec").read_text()
self.assertIn(f"Name: {identity.desktop_id}\n", spec)
self.assertIn(f"/opt/{identity.desktop_id}\n", spec)
for payload in (root / "work" / "deb", root / "work" / "rpm" / "payload", root / "work" / "NyaTerm.AppDir"):
desktop = payload / "usr" / "share" / "applications" / identity.linux_desktop_file
executable = "nyaterm" if payload.name == "NyaTerm.AppDir" else f"/opt/{identity.desktop_id}/nyaterm"
verify_native_package.verify_linux_desktop(desktop.read_text(), executable, str(desktop), identity)
self.assertTrue((payload / "usr" / "share" / "icons" / "hicolor" / "128x128" / "apps" / f"{identity.desktop_id}.png").is_file())
app_root = payload / "usr" / "bin" if payload.name == "NyaTerm.AppDir" else payload / "opt" / identity.desktop_id
for name in ("nyaterm", *package_native.HELPER_BINS):
self.assertTrue((app_root / name).is_file())
def test_release_tag_is_normalized(self) -> None:
self.assertEqual(package_native.validate_version("v2.0.0"), "2.0.0")
self.assertEqual(
package_native.validate_version("2.0.0-preview.1"),
"2.0.0-preview.1",
)
def test_invalid_or_mismatched_version_is_rejected(self) -> None:
with self.assertRaises(ValueError):
package_native.validate_version("release-2")
with self.assertRaisesRegex(ValueError, "does not match"):
package_native.validate_version("v2.0.1", "2.0.0")
def test_snapshot_is_only_allowed_as_an_artifact_label(self) -> None:
self.assertEqual(
package_native.validate_artifact_version("main-snapshot"),
"main-snapshot",
)
with self.assertRaises(ValueError):
package_native.validate_version("main-snapshot")
with self.assertRaises(ValueError):
package_native.validate_artifact_version("nightly")
self.assertEqual(
package_native.artifact_names(
"x86_64-pc-windows-msvc", "main-snapshot"
),
{
"NyaTerm_main-snapshot_windows_x64_portable.zip",
"NyaTerm_main-snapshot_windows_x64-setup.exe",
},
)
def test_all_release_targets_have_expected_artifact_names(self) -> None:
expected = {
"aarch64-apple-darwin": {
"NyaTerm_2.0.0_macos_arm64.dmg",
"NyaTerm_2.0.0_macos_arm64.app.tar.gz",
},
"x86_64-apple-darwin": {
"NyaTerm_2.0.0_macos_x64.dmg",
"NyaTerm_2.0.0_macos_x64.app.tar.gz",
},
"aarch64-unknown-linux-gnu": {
"NyaTerm_2.0.0_linux_arm64.AppImage",
"NyaTerm_2.0.0_linux_arm64.deb",
"NyaTerm_2.0.0_linux_arm64.rpm",
},
"x86_64-unknown-linux-gnu": {
"NyaTerm_2.0.0_linux_x64.AppImage",
"NyaTerm_2.0.0_linux_x64.deb",
"NyaTerm_2.0.0_linux_x64.rpm",
},
"aarch64-pc-windows-msvc": {
"NyaTerm_2.0.0_windows_arm64_portable.zip",
"NyaTerm_2.0.0_windows_arm64-setup.exe",
},
"x86_64-pc-windows-msvc": {
"NyaTerm_2.0.0_windows_x64_portable.zip",
"NyaTerm_2.0.0_windows_x64-setup.exe",
},
}
for target, names in expected.items():
with self.subTest(target=target):
self.assertEqual(package_native.artifact_names(target, "v2.0.0"), names)
def test_release_binary_always_uses_explicit_target_directory(self) -> None:
with mock.patch.dict("os.environ", {}, clear=True):
linux = package_native.release_binary_path("x86_64-unknown-linux-gnu")
windows = package_native.release_binary_path("aarch64-pc-windows-msvc")
self.assertEqual(
linux.relative_to(package_native.ROOT_DIR).as_posix(),
"target/x86_64-unknown-linux-gnu/release/nyaterm",
)
self.assertEqual(
windows.relative_to(package_native.ROOT_DIR).as_posix(),
"target/aarch64-pc-windows-msvc/release/nyaterm.exe",
)
def test_helper_binaries_resolve_beside_the_application(self) -> None:
self.assertIn("nyaterm-rdp-helper", package_native.HELPER_BINS)
self.assertIn("nyaterm-mcp", package_native.HELPER_BINS)
with mock.patch.dict("os.environ", {}, clear=True):
linux = package_native.helper_binary_paths("x86_64-unknown-linux-gnu")
windows = package_native.helper_binary_paths("aarch64-pc-windows-msvc")
self.assertEqual(
[path.name for path in linux], list(package_native.HELPER_BINS)
)
self.assertEqual(
[path.name for path in windows],
[f"{name}.exe" for name in package_native.HELPER_BINS],
)
application = package_native.release_binary_path("x86_64-unknown-linux-gnu")
for path in linux:
self.assertEqual(path.parent, application.parent)
def test_copy_helpers_stages_every_helper_beside_the_application(self) -> None:
target = "x86_64-unknown-linux-gnu"
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
sources = root / "build"
destination = root / "package"
destination.mkdir()
staged = []
for path in package_native.helper_binary_paths(target):
fake = sources / path.name
fake.parent.mkdir(parents=True, exist_ok=True)
fake.write_bytes(b"helper")
staged.append(fake)
with mock.patch.object(
package_native, "helper_binary_paths", return_value=staged
):
copied = package_native.copy_helpers(destination, target)
self.assertEqual(
sorted(path.name for path in copied),
sorted(package_native.HELPER_BINS),
)
for path in copied:
self.assertTrue(path.is_file())
self.assertEqual(path.parent, destination)
def test_windows_installer_script_installs_and_removes_every_helper(self) -> None:
target = "x86_64-pc-windows-msvc"
info = package_native.target_info(target)
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with (
mock.patch.object(package_native, "WORK_DIR", root / "work"),
mock.patch.object(package_native, "DIST_DIR", root / "dist"),
mock.patch.object(package_native.prepare_conpty, "STAGING", root / "staging"),
mock.patch.object(package_native, "run"),
mock.patch.object(package_native, "helper_binary_paths", return_value=[root / "build" / f"{name}.exe" for name in package_native.HELPER_BINS]),
mock.patch.object(
package_native, "find_makensis", return_value="makensis"
),
):
package_native.WORK_DIR.mkdir(parents=True)
package_native.DIST_DIR.mkdir(parents=True)
application = root / "nyaterm.exe"
application.write_bytes(b"MZ")
for path in package_native.helper_binary_paths(target):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"MZ")
for relative in package_native.conpty_files(target):
path = root / "staging/resources/windows/conpty" / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"MZ")
package_native.create_windows_packages(
application, info, "2.0.0", "2.0.0"
)
script = (
package_native.WORK_DIR / "nyaterm-installer.nsi"
).read_text(encoding="utf-8")
with zipfile.ZipFile(
package_native.DIST_DIR / "NyaTerm_2.0.0_windows_x64_portable.zip"
) as archive:
portable_entries = set(archive.namelist())
for name in package_native.HELPER_BINS:
filename = f"{name}.exe"
with self.subTest(helper=filename):
self.assertRegex(script, rf'File ".*{filename}"')
self.assertIn(f'Delete "$INSTDIR\\{filename}"', script)
for relative in package_native.conpty_files(target):
self.assertIn(str(relative.name), script)
self.assertIn(
f'Delete "$INSTDIR\\conpty\\{package_native.nsis_relative_path(relative)}"',
script,
)
self.assertIn(
f"NyaTerm-portable/conpty/{relative.as_posix()}", portable_entries
)
self.assertIn('Delete "$INSTDIR\\conpty\\LICENSE.txt"', script)
self.assertIn("NyaTerm-portable/conpty/LICENSE.txt", portable_entries)
self.assertIn(
r'WriteRegStr HKCU "Software\Classes\nyaterm" "URL Protocol" ""',
script,
)
self.assertIn(
r'WriteRegStr HKCU "Software\Classes\nyaterm\shell\open\command" "" "$\"$INSTDIR\NyaTerm.exe$\" $\"%1$\""',
script,
)
self.assertIn(
r'DeleteRegKey HKCU "Software\Classes\nyaterm"',
script,
)
self.assertNotIn(r"Software\Classes\ssh", script)
self.assertNotIn(r"Software\Classes\telnet", script)
def test_linux_desktop_registers_only_nyaterm_url_scheme(self) -> None:
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "nyaterm.desktop"
package_native.write_desktop_file(path, "/opt/nyaterm/nyaterm")
desktop = path.read_text(encoding="utf-8")
self.assertIn("Exec=/opt/nyaterm/nyaterm %U\n", desktop)
self.assertIn("MimeType=x-scheme-handler/nyaterm;\n", desktop)
self.assertNotIn("x-scheme-handler/ssh", desktop)
self.assertNotIn("x-scheme-handler/telnet", desktop)
def test_deb_dependencies_cover_helper_binaries(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
binaries = [root / "nyaterm", root / "nyaterm-rdp-helper"]
with (
mock.patch.object(package_native, "WORK_DIR", root / "work"),
mock.patch.object(
package_native, "require_tool", return_value="dpkg-shlibdeps"
),
mock.patch.object(
package_native.subprocess,
"check_output",
return_value="shlibs:Depends=libc6, libx11-6\n",
) as check_output,
):
dependencies = package_native.linux_deb_dependencies(binaries)
self.assertEqual(dependencies, "libc6, libx11-6")
command = check_output.call_args.args[0]
for binary in binaries:
self.assertIn(str(binary), command)
self.assertEqual(command.count("-e"), len(binaries))
def test_release_binary_respects_absolute_cargo_target_dir(self) -> None:
# Build the absolute path for the running platform: Path("/cache/cargo") has
# no drive letter, so is_absolute() is False on Windows and the assertion
# would compare against a path joined onto the repository root instead.
target_dir = Path(tempfile.gettempdir(), "nyaterm-cargo-target").resolve()
with mock.patch.dict("os.environ", {"CARGO_TARGET_DIR": str(target_dir)}):
path = package_native.release_binary_path("x86_64-unknown-linux-gnu")
self.assertEqual(
path, target_dir / "x86_64-unknown-linux-gnu" / "release" / "nyaterm"
)
def test_platform_package_versions_are_normalized(self) -> None:
self.assertEqual(package_native.windows_numeric_version("2.4.6-beta.1"), "2.4.6.0")
self.assertEqual(package_native.linux_rpm_version("2.4.6"), ("2.4.6", "1"))
self.assertEqual(
package_native.linux_rpm_version("2.4.6-beta.1"),
("2.4.6", "0.beta.1"),
)
def test_dpkg_dependency_output_is_parsed(self) -> None:
output = "ignored=value\nshlibs:Depends=libc6 (>= 2.34), libx11-6\n"
self.assertEqual(
package_native.parse_dpkg_dependencies(output),
"libc6 (>= 2.34), libx11-6",
)
with self.assertRaises(RuntimeError):
package_native.parse_dpkg_dependencies("shlibs:Depends=\n")
def test_native_icon_resources_have_expected_formats_and_sizes(self) -> None:
expected_png_sizes = {
"32x32.png": (32, 32),
"64x64.png": (64, 64),
"128x128.png": (128, 128),
"256x256.png": (256, 256),
"512x512.png": (512, 512),
}
for name, expected_size in expected_png_sizes.items():
with self.subTest(name=name):
data = (package_native.ICON_DIR / name).read_bytes()
self.assertEqual(data[:8], b"\x89PNG\r\n\x1a\n")
self.assertEqual(struct.unpack(">II", data[16:24]), expected_size)
self.assertEqual(
(package_native.ICON_DIR / "icon.icns").read_bytes()[:4], b"icns"
)
self.assertEqual(
(package_native.ICON_DIR / "icon.ico").read_bytes()[:4], b"\0\0\1\0"
)
if __name__ == "__main__":
unittest.main()