diff --git a/loopx/presentation/chat_bundle.py b/loopx/presentation/chat_bundle.py index 37d3baf745..ce148c5580 100644 --- a/loopx/presentation/chat_bundle.py +++ b/loopx/presentation/chat_bundle.py @@ -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", diff --git a/loopx/windows_install.py b/loopx/windows_install.py index 285a13dc13..0296190076 100644 --- a/loopx/windows_install.py +++ b/loopx/windows_install.py @@ -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" @@ -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)) diff --git a/scripts/install-windows.ps1 b/scripts/install-windows.ps1 index e79fb3c6eb..7bceab6632 100644 --- a/scripts/install-windows.ps1 +++ b/scripts/install-windows.ps1 @@ -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") } diff --git a/tests/presentation/test_chat_bundle.py b/tests/presentation/test_chat_bundle.py index ec057ecfac..d78d049a04 100644 --- a/tests/presentation/test_chat_bundle.py +++ b/tests/presentation/test_chat_bundle.py @@ -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" diff --git a/tests/test_windows_install.py b/tests/test_windows_install.py index 3cb1ddbf9a..774c3abd50 100644 --- a/tests/test_windows_install.py +++ b/tests/test_windows_install.py @@ -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") @@ -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()