Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion scripts/ci/package_windows_portable.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import re
import shutil
import tempfile
import tomllib
from typing import Iterable

from scripts.ci.lib.artifact_arch import normalize_arch_alias
Expand All @@ -31,6 +32,7 @@
- Microsoft Edge WebView2 Runtime must already be installed on this Windows machine.
"""
TAURI_CONFIG_RELATIVE_PATH = pathlib.Path("src-tauri") / "tauri.conf.json"
CARGO_TOML_RELATIVE_PATH = pathlib.Path("src-tauri") / "Cargo.toml"
BACKEND_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "backend"
WEBUI_RESOURCE_RELATIVE_PATH = pathlib.Path("resources") / "webui"
WINDOWS_CLEANUP_SCRIPT_RELATIVE_PATH = (
Expand All @@ -45,6 +47,7 @@
class ProjectConfig:
root: pathlib.Path
product_name: str
binary_name: str
portable_marker_name: str


Expand Down Expand Up @@ -88,10 +91,12 @@ def load_portable_runtime_marker(project_root: pathlib.Path) -> str:
def load_project_config_from(start_path: pathlib.Path) -> ProjectConfig:
project_root = resolve_project_root_from(start_path)
product_name = resolve_product_name(project_root)
binary_name = load_binary_name_from_cargo(project_root)
portable_marker_name = load_portable_runtime_marker(project_root)
return ProjectConfig(
root=project_root,
product_name=product_name,
binary_name=binary_name,
portable_marker_name=portable_marker_name,
)

Expand Down Expand Up @@ -142,6 +147,33 @@ def load_tauri_config(project_root: pathlib.Path) -> dict:
return json.loads(config_path.read_text(encoding="utf-8"))


def load_binary_name_from_cargo(project_root: pathlib.Path) -> str:
cargo_toml_path = project_root / CARGO_TOML_RELATIVE_PATH
if not cargo_toml_path.is_file():
raise FileNotFoundError(f"Cargo.toml not found: {cargo_toml_path}")

with cargo_toml_path.open("rb") as handle:
cargo_data = tomllib.load(handle)

bins = cargo_data.get("bin")
if isinstance(bins, list):
for entry in bins:
if isinstance(entry, dict):
binary_name = str(entry.get("name", "")).strip()
if binary_name:
return binary_name

package_table = cargo_data.get("package")
if not isinstance(package_table, dict):
raise ValueError(f"Missing [package] in {cargo_toml_path}")

binary_name = str(package_table.get("name", "")).strip()
if not binary_name:
raise ValueError(f"Missing [package].name in {cargo_toml_path}")

return binary_name


def resolve_product_name(project_root: pathlib.Path) -> str:
config = load_tauri_config(project_root)
product_name = str(config.get("productName", "")).strip()
Expand All @@ -158,7 +190,7 @@ def resolve_main_executable_path(
bundle_dir: pathlib.Path, project_config: ProjectConfig
) -> pathlib.Path:
release_dir = resolve_release_dir(bundle_dir)
main_executable_path = release_dir / f"{project_config.product_name}.exe"
main_executable_path = release_dir / f"{project_config.binary_name}.exe"
if not main_executable_path.is_file():
raise FileNotFoundError(f"Main executable not found: {main_executable_path}")
return main_executable_path
Expand Down
123 changes: 121 additions & 2 deletions scripts/ci/test_package_windows_portable.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import re
import tempfile
import unittest
from pathlib import Path
Expand Down Expand Up @@ -109,21 +110,134 @@ def test_load_project_config_from_returns_root_product_and_marker(self):
project_root / "scripts" / "ci" / "package_windows_portable.py"
)
tauri_config_path = project_root / "src-tauri" / "tauri.conf.json"
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH

script_path.parent.mkdir(parents=True)
script_path.write_text("# placeholder")
tauri_config_path.parent.mkdir(parents=True)
tauri_config_path.write_text('{"productName":"AstrBot"}')
cargo_toml_path.write_text('[package]\nname = "astrbot-desktop-tauri"\n')
marker_path.parent.mkdir(parents=True, exist_ok=True)
marker_path.write_text("portable.flag\n")

project_config = MODULE.load_project_config_from(script_path)

self.assertEqual(project_config.root, project_root.resolve())
self.assertEqual(project_config.product_name, "AstrBot")
self.assertEqual(project_config.binary_name, "astrbot-desktop-tauri")
self.assertEqual(project_config.portable_marker_name, "portable.flag")

def test_load_cargo_package_name_supports_inline_comments(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text(
'[package]\nname = "astrbot-desktop-tauri" # main binary\n'
)

self.assertEqual(
MODULE.load_binary_name_from_cargo(project_root),
"astrbot-desktop-tauri",
)

def test_load_cargo_package_name_missing_cargo_toml_raises_file_not_found(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"

with self.assertRaisesRegex(
FileNotFoundError, re.escape(str(cargo_toml_path))
):
MODULE.load_binary_name_from_cargo(project_root)

def test_load_cargo_package_name_missing_package_table_raises_value_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text('[workspace]\nmembers = ["crates/*"]\n')

with self.assertRaisesRegex(ValueError, re.escape(str(cargo_toml_path))):
MODULE.load_binary_name_from_cargo(project_root)

def test_load_cargo_package_name_missing_package_name_raises_value_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text('[package]\nversion = "0.1.0"\n')

with self.assertRaisesRegex(ValueError, re.escape(str(cargo_toml_path))):
MODULE.load_binary_name_from_cargo(project_root)

def test_load_cargo_package_name_empty_package_name_raises_value_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text('[package]\nname = ""\n')

with self.assertRaisesRegex(ValueError, re.escape(str(cargo_toml_path))):
MODULE.load_binary_name_from_cargo(project_root)

def test_load_cargo_package_name_falls_back_to_package_when_bin_missing_name(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text(
"[package]\n"
'name = "astrbot-desktop-tauri"\n\n'
"[[bin]]\n"
'path = "src/main.rs"\n'
)

self.assertEqual(
MODULE.load_binary_name_from_cargo(project_root),
"astrbot-desktop-tauri",
)

def test_load_cargo_package_name_prefers_explicit_bin_name(self):
Comment on lines +131 to +202
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for load_cargo_package_name error paths and fallback behavior

The new tests cover the happy path, but the function’s error and fallback branches are still untested:

  • Missing Cargo.tomlFileNotFoundError
  • Missing [package] table → ValueError
  • [package] present but missing/empty nameValueError
  • [[bin]] present but empty or without name → should fall back to [package].name

Please add unit tests that mirror the existing tempdir setup to assert the correct exception types (and optionally messages) for the error cases, and to confirm the fallback returns the package name when [[bin]] entries lack name.

Suggested change
def test_load_cargo_package_name_supports_inline_comments(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text(
'[package]\nname = "astrbot-desktop-tauri" # main binary\n'
)
self.assertEqual(
MODULE.load_cargo_package_name(project_root),
"astrbot-desktop-tauri",
)
def test_load_cargo_package_name_prefers_explicit_bin_name(self):
def test_load_cargo_package_name_supports_inline_comments(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text(
'[package]\nname = "astrbot-desktop-tauri" # main binary\n'
)
self.assertEqual(
MODULE.load_cargo_package_name(project_root),
"astrbot-desktop-tauri",
)
def test_load_cargo_package_name_missing_cargo_toml_raises_file_not_found(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
# Intentionally DO NOT create src-tauri/Cargo.toml
with self.assertRaises(FileNotFoundError):
MODULE.load_cargo_package_name(project_root)
def test_load_cargo_package_name_missing_package_table_raises_value_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
# No [package] table at all
cargo_toml_path.write_text(
'[workspace]\n'
'members = ["crates/*"]\n'
)
with self.assertRaises(ValueError):
MODULE.load_cargo_package_name(project_root)
def test_load_cargo_package_name_missing_package_name_raises_value_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
# [package] table present but missing name
cargo_toml_path.write_text(
"[package]\n"
'version = "0.1.0"\n'
)
with self.assertRaises(ValueError):
MODULE.load_cargo_package_name(project_root)
def test_load_cargo_package_name_empty_package_name_raises_value_error(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
# [package].name present but empty
cargo_toml_path.write_text(
"[package]\n"
'name = ""\n'
)
with self.assertRaises(ValueError):
MODULE.load_cargo_package_name(project_root)
def test_load_cargo_package_name_falls_back_to_package_when_bin_missing_name(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
# [[bin]] table exists but has no name; should fall back to package.name
cargo_toml_path.write_text(
"[package]\n"
'name = "astrbot-desktop-tauri"\n'
"\n"
"[[bin]]\n"
'path = "src/main.rs"\n'
)
self.assertEqual(
MODULE.load_cargo_package_name(project_root),
"astrbot-desktop-tauri",
)
def test_load_cargo_package_name_prefers_explicit_bin_name(self):

with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
cargo_toml_path.parent.mkdir(parents=True)
cargo_toml_path.write_text(
"[package]\n"
'name = "astrbot-desktop-tauri"\n\n'
"[[bin]]\n"
'name = "AstrBot"\n'
)

self.assertEqual(
MODULE.load_binary_name_from_cargo(project_root), "AstrBot"
)

def test_resolve_main_executable_path_uses_binary_name_not_product_name(self):
with tempfile.TemporaryDirectory() as tmpdir:
project_root = Path(tmpdir)
bundle_dir = (
project_root / "src-tauri" / "target" / "release" / "bundle" / "nsis"
)
release_dir = project_root / "src-tauri" / "target" / "release"
bundle_dir.mkdir(parents=True)
release_dir.mkdir(parents=True, exist_ok=True)
(release_dir / "astrbot-desktop-tauri.exe").write_text("exe")

project_config = MODULE.ProjectConfig(
root=project_root,
product_name="AstrBot",
binary_name="astrbot-desktop-tauri",
portable_marker_name="portable.flag",
)

self.assertEqual(
MODULE.resolve_main_executable_path(bundle_dir, project_config),
release_dir / "astrbot-desktop-tauri.exe",
)

def test_iter_installer_paths_only_returns_installer_style_executables(self):
with tempfile.TemporaryDirectory() as tmpdir:
bundle_dir = Path(tmpdir)
Expand Down Expand Up @@ -156,6 +270,7 @@ def test_populate_portable_root_copies_release_bundle_contents(self):
webui_dir = project_root / "resources" / "webui"
windows_dir = project_root / "src-tauri" / "windows"
tauri_config_path = project_root / "src-tauri" / "tauri.conf.json"
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH

script_path.parent.mkdir(parents=True)
Expand All @@ -167,8 +282,9 @@ def test_populate_portable_root_copies_release_bundle_contents(self):
windows_dir.mkdir(parents=True)

tauri_config_path.write_text('{"productName":"AstrBot"}')
cargo_toml_path.write_text('[package]\nname = "astrbot-desktop-tauri"\n')
marker_path.write_text("portable.flag\n")
(release_dir / "AstrBot.exe").write_text("exe")
(release_dir / "astrbot-desktop-tauri.exe").write_text("exe")
(release_dir / "WebView2Loader.dll").write_text("dll")
(backend_dir / "runtime-manifest.json").write_text("{}")
(backend_dir / "launch_backend.py").write_text("print('ok')")
Expand All @@ -183,7 +299,7 @@ def test_populate_portable_root_copies_release_bundle_contents(self):
project_config=MODULE.load_project_config_from(script_path),
)

self.assertTrue((destination_root / "AstrBot.exe").is_file())
self.assertTrue((destination_root / "astrbot-desktop-tauri.exe").is_file())
self.assertTrue((destination_root / "WebView2Loader.dll").is_file())
self.assertTrue(
(
Expand All @@ -210,6 +326,7 @@ def test_populate_portable_root_rejects_missing_main_executable(self):
backend_dir = project_root / "resources" / "backend"
webui_dir = project_root / "resources" / "webui"
tauri_config_path = project_root / "src-tauri" / "tauri.conf.json"
cargo_toml_path = project_root / "src-tauri" / "Cargo.toml"
marker_path = project_root / MODULE.PORTABLE_RUNTIME_MARKER_RELATIVE_PATH

script_path.parent.mkdir(parents=True)
Expand All @@ -219,6 +336,7 @@ def test_populate_portable_root_rejects_missing_main_executable(self):
webui_dir.mkdir(parents=True)
tauri_config_path.parent.mkdir(parents=True, exist_ok=True)
tauri_config_path.write_text('{"productName":"AstrBot"}')
cargo_toml_path.write_text('[package]\nname = "astrbot-desktop-tauri"\n')
marker_path.parent.mkdir(parents=True, exist_ok=True)
marker_path.write_text("portable.flag\n")
(backend_dir / "runtime-manifest.json").write_text("{}")
Expand All @@ -237,6 +355,7 @@ def test_add_portable_runtime_files_writes_marker_and_readme(self):
project_config = MODULE.ProjectConfig(
root=Path(tmpdir),
product_name="AstrBot",
binary_name="astrbot-desktop-tauri",
portable_marker_name="portable.flag",
)

Expand Down