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
2 changes: 1 addition & 1 deletion loopx/presentation/chat_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ def digest(path: Path) -> str:
def source_digest(path: Path, content: bytes | None = None) -> str:
# Git may check text out with CRLF on Windows; binary assets remain exact.
data = path.read_bytes() if content is None else content
if path.suffix in {
if path.name == ".gitkeep" or path.suffix in {
".ts",
".tsx",
".js",
Expand Down
35 changes: 29 additions & 6 deletions loopx/windows_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ def _release_id(requested: str | None, releases_dir: Path) -> str:
return candidate


def _ensure_chat_bundle(
*,
bundle_builder: Path,
source_root: Path,
python: Path,
pointer: Path,
) -> None:
command = [str(python), str(bundle_builder), "ensure"]
if pointer.is_file():
pointer_payload = json.loads(pointer.read_text(encoding="utf-8"))
previous_root = pointer_payload.get("release_root")
if isinstance(previous_root, str) and previous_root.strip():
previous = Path(previous_root) / "loopx/web/chat"
if (previous / "index.html").is_file():
command.extend(["--previous", str(previous)])
subprocess.run(
command,
cwd=source_root,
check=True,
stdout=sys.stderr,
)


def _copy_release(source_root: Path, target: Path) -> None:
ignored = shutil.ignore_patterns(
"__pycache__", "*.pyc", "node_modules", ".next", "dist", "build", "coverage"
Expand Down Expand Up @@ -277,13 +300,13 @@ def install_windows(

with exclusive_file_lock(install_root / ".install-guard"):
if bundle_builder.is_file():
command = [str(python), str(bundle_builder), "ensure"]
pointer = install_root / "current-release.json"
if pointer.is_file():
previous = Path(json.loads(pointer.read_text(encoding="utf-8"))["release_root"]) / "loopx/web/chat"
if (previous / "index.html").is_file():
command.extend(["--previous", str(previous)])
subprocess.run(command, cwd=source_root, check=True)
_ensure_chat_bundle(
bundle_builder=bundle_builder,
source_root=source_root,
python=python,
pointer=pointer,
)
release_id = _release_id(requested_release_id, releases_dir)
release_root = releases_dir / release_id
temporary = Path(tempfile.mkdtemp(prefix=f".{release_id}.", dir=releases_dir))
Expand Down
2 changes: 1 addition & 1 deletion scripts/install-windows.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,5 @@ if ($AddToUserPath) {
if (-not (($env:Path -split ";") -contains $BinDir)) {
$env:Path = "$BinDir;$env:Path"
}
Write-Output "LoopX Windows user PATH includes: $BinDir"
[Console]::Error.WriteLine("LoopX Windows user PATH includes: $BinDir")
}
6 changes: 5 additions & 1 deletion tests/presentation/test_chat_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,15 @@ def test_windows_drive_paths_are_not_portable_bundle_names():
assert not builder.contract.safe_relative("C:escape.js")


def test_source_fingerprints_normalize_windows_pwa_text_but_not_binary(tmp_path):
def test_source_fingerprints_normalize_windows_text_but_not_binary(tmp_path):
manifest = tmp_path / "manifest.webmanifest"
assert builder.contract.source_digest(
manifest, b"{}\r\n"
) == builder.contract.source_digest(manifest, b"{}\n")
placeholder = tmp_path / ".gitkeep"
assert builder.contract.source_digest(
placeholder, b"\r\n"
) == builder.contract.source_digest(placeholder, b"\n")
image = tmp_path / "image.png"
assert builder.contract.source_digest(
image, b"image\r\n"
Expand Down
125 changes: 101 additions & 24 deletions tests/test_windows_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,77 @@ def _run_loopx(
)


def _windows_user_path(pwsh: str) -> str:
result = subprocess.run(
[
pwsh,
"-NoLogo",
"-NoProfile",
"-Command",
(
"$value = [Environment]::GetEnvironmentVariable('Path', 'User'); "
"if ($null -eq $value) { 'null' } else { "
"[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($value)) }"
),
],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
timeout=30,
)
return result.stdout.strip()


def _restore_windows_user_path(pwsh: str, encoded_path: str) -> None:
env = dict(os.environ)
env["LOOPX_TEST_USER_PATH"] = encoded_path
subprocess.run(
[
pwsh,
"-NoLogo",
"-NoProfile",
"-Command",
(
"$encoded = $env:LOOPX_TEST_USER_PATH; "
"$value = if ($encoded -eq 'null') { $null } else { "
"[Text.Encoding]::UTF8.GetString("
"[Convert]::FromBase64String($encoded)) }; "
"[Environment]::SetEnvironmentVariable('Path', $value, 'User')"
),
],
check=True,
capture_output=True,
text=True,
encoding="utf-8",
env=env,
timeout=30,
)


def test_chat_bundle_preflight_preserves_stdout_with_legacy_pointer(
tmp_path: Path,
capfd: pytest.CaptureFixture[str],
) -> None:
source_root = tmp_path / "source"
source_root.mkdir()
bundle_builder = source_root / "bundle_builder.py"
bundle_builder.write_text("print('bundle progress')\n", encoding="utf-8")
pointer = tmp_path / "current-release.json"
pointer.write_text('{"release_id":"known-good"}\n', encoding="utf-8")

windows_install._ensure_chat_bundle(
bundle_builder=bundle_builder,
source_root=source_root,
python=Path(sys.executable),
pointer=pointer,
)

captured = capfd.readouterr()
assert captured.out == ""
assert captured.err.splitlines() == ["bundle progress"]


@pytest.mark.skipif(os.name != "nt", reason="native Windows installer regression")
def test_windows_installer_promotes_release_and_runs_doctor(tmp_path: Path) -> None:
pwsh = shutil.which("pwsh")
Expand All @@ -45,33 +116,39 @@ def test_windows_installer_promotes_release_and_runs_doctor(tmp_path: Path) -> N
env = dict(os.environ)
env["PYTHONDONTWRITEBYTECODE"] = "1"

install = subprocess.run(
[
pwsh,
"-NoLogo",
"-NoProfile",
"-File",
str(repo_root / "scripts" / "install-windows.ps1"),
"-Python",
sys.executable,
"-InstallRoot",
str(install_root),
"-BinDir",
str(bin_dir),
"-SkillsDir",
str(skills_dir),
],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
timeout=180,
)
original_user_path = _windows_user_path(pwsh)
try:
install = subprocess.run(
[
pwsh,
"-NoLogo",
"-NoProfile",
"-File",
str(repo_root / "scripts" / "install-windows.ps1"),
"-Python",
sys.executable,
"-InstallRoot",
str(install_root),
"-BinDir",
str(bin_dir),
"-SkillsDir",
str(skills_dir),
"-AddToUserPath",
],
check=False,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
env=env,
timeout=180,
)
finally:
_restore_windows_user_path(pwsh, original_user_path)

assert install.returncode == 0, install.stderr
installed = json.loads(install.stdout)
assert f"LoopX Windows user PATH includes: {bin_dir}" in install.stderr
pointer_path = install_root / "current-release.json"
assert installed["pointer"] == str(pointer_path)
assert (bin_dir / "loopx.ps1").is_file()
Expand Down
Loading