Skip to content
Open
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
5 changes: 3 additions & 2 deletions .github/release-notes-template.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,9 @@ New install on native Windows, in PowerShell:
irm https://raven.evermind.ai/install.ps1 | iex
```

Windows PowerShell 5.1 (the version built into Windows) rejects that URL with
`Permanent Redirect`; use the direct one instead:
Windows PowerShell 5.1 (the version built into Windows) cannot follow that URL's
redirect and stops with `(308) Permanent Redirect`. If you see that error, use the
direct one instead:

```powershell
irm https://raw.githubusercontent.com/EverMind-AI/Raven/refs/heads/main/install.ps1 | iex
Expand Down
26 changes: 25 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,12 @@ jobs:
# that touch an installer pay for it; every push to main runs it.
runs-on: ${{ matrix.os }}
timeout-minutes: 25
# Python opens text files in the locale encoding when no encoding= is
# passed. On a GBK-locale Windows runner, any step or subprocess doing
# such I/O can fail on non-ASCII content. UTF-8 mode removes that; on
# ubuntu-latest it is already the default encoding and has no effect.
env:
PYTHONUTF8: "1"
strategy:
fail-fast: false
matrix:
Expand Down Expand Up @@ -325,7 +331,7 @@ jobs:
cat install.sh | sh
"$UV_TOOL_BIN_DIR/raven" --version

- name: Install the latest release with install.ps1, piped
- name: Install the latest release with install.ps1 under pwsh, piped
if: steps.changed.outputs.run == 'true' && runner.os == 'Windows'
shell: pwsh
env:
Expand All @@ -339,6 +345,24 @@ jobs:
Get-Content install.ps1 -Raw | Invoke-Expression
& "$env:UV_TOOL_BIN_DIR\raven.exe" --version

# Windows PowerShell 5.1 is the shell every Windows ships with, and its
# web cmdlets report failures differently from pwsh's, so a script that
# passes under one can fail under the other. Its own tool, bin and home
# directories keep the version check answering for this install alone.
- name: Install the latest release with install.ps1 under Windows PowerShell 5.1, piped
if: steps.changed.outputs.run == 'true' && runner.os == 'Windows'
shell: powershell
env:
RAVEN_MINIMAL: "1"
RAVEN_NO_LAUNCH: "1"
UV_TOOL_DIR: ${{ runner.temp }}\tools-ps51
UV_TOOL_BIN_DIR: ${{ runner.temp }}\bin-ps51
UV_CACHE_DIR: ${{ runner.temp }}\cache
RAVEN_HOME: ${{ runner.temp }}\home-ps51
run: |
Get-Content install.ps1 -Raw | Invoke-Expression
& "$env:UV_TOOL_BIN_DIR\raven.exe" --version

windows-upgrade:
name: Windows self-upgrade
runs-on: windows-latest
Expand Down
11 changes: 8 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ DOCKER_IMAGE ?= raven:local
COVERAGE_REPORT_ARGS = --cov=raven --cov=raven_everos --cov-branch --cov-report=term-missing:skip-covered --cov-report=xml --cov-report=json --cov-report=html
# One slice of the suite, K/N; the shard writes its .coverage data and no report.
COVERAGE_SHARD ?= 1/1
# Without an explicit encoding= Python opens text files in the locale encoding.
# On a GBK-locale Windows checkout, the suite would read and write cp936
# wherever a test forgot encoding=. The three pytest targets export it below; a
# bare `uv run pytest` does not go through this file and stays exposed.
PYTHONUTF8 = 1
# Extra pytest flags for a shard; CI passes --idle-ceiling-strict, a local run stays warning-only.
PYTEST_ARGS ?=
COVERAGE_DATA_ARGS = --cov=raven --cov=raven_everos --cov-branch --cov-report=
Expand Down Expand Up @@ -101,13 +106,13 @@ test-ui:
npm test --prefix ui-web

test-python:
uv run --frozen --python $(PYTHON_VERSION) --all-extras pytest -q
PYTHONUTF8=$(PYTHONUTF8) uv run --frozen --python $(PYTHON_VERSION) --all-extras pytest -q

coverage:
TERM=dumb uv run --frozen --python $(PYTHON_VERSION) --all-extras pytest -q $(COVERAGE_REPORT_ARGS)
TERM=dumb PYTHONUTF8=$(PYTHONUTF8) uv run --frozen --python $(PYTHON_VERSION) --all-extras pytest -q $(COVERAGE_REPORT_ARGS)

coverage-shard:
TERM=dumb uv run --frozen --python $(PYTHON_VERSION) --all-extras pytest -q --shard $(COVERAGE_SHARD) --durations=25 $(PYTEST_ARGS) $(COVERAGE_DATA_ARGS)
TERM=dumb PYTHONUTF8=$(PYTHONUTF8) uv run --frozen --python $(PYTHON_VERSION) --all-extras pytest -q --shard $(COVERAGE_SHARD) --durations=25 $(PYTEST_ARGS) $(COVERAGE_DATA_ARGS)

coverage-combine:
uv run --frozen --python $(PYTHON_VERSION) coverage combine --keep $(COVERAGE_DATA_DIR)
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,9 @@ Native Windows PowerShell:
irm https://raven.evermind.ai/install.ps1 | iex
```

Windows PowerShell 5.1 may reject the redirect. Use the direct installer URL instead:
Windows PowerShell 5.1, the version built into Windows, cannot follow that URL's
redirect and stops with `(308) Permanent Redirect`. If you see that error, use the
direct installer URL instead:

```powershell
irm https://raw.githubusercontent.com/EverMind-AI/Raven/refs/heads/main/install.ps1 | iex
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ curl -fsSL https://raven.evermind.ai/install.sh | bash
irm https://raven.evermind.ai/install.ps1 | iex
```

Windows PowerShell 5.1 可能拒绝重定向,请改用直连安装地址:
Windows 自带的 Windows PowerShell 5.1 无法跟随这个地址的重定向,会报错 `(308) Permanent Redirect`。遇到这个错误时,请改用直连安装地址:

```powershell
irm https://raw.githubusercontent.com/EverMind-AI/Raven/refs/heads/main/install.ps1 | iex
Expand Down
4 changes: 3 additions & 1 deletion docs-site/docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@ curl -fsSL https://raven.evermind.ai/install.sh | bash
irm https://raven.evermind.ai/install.ps1 | iex
```

If Windows PowerShell 5.1 rejects the redirect, use the direct installer URL:
Windows PowerShell 5.1, the version built into Windows, cannot follow that URL's
redirect and stops with `(308) Permanent Redirect`. If you see that error, use the
direct installer URL:

```powershell
irm https://raw.githubusercontent.com/EverMind-AI/Raven/refs/heads/main/install.ps1 | iex
Expand Down
3 changes: 2 additions & 1 deletion docs-site/docs/quick-start.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ curl -fsSL https://raven.evermind.ai/install.sh | bash
irm https://raven.evermind.ai/install.ps1 | iex
```

如果 Windows PowerShell 5.1 无法处理重定向,请使用直连安装地址:
Windows 自带的 Windows PowerShell 5.1 无法跟随这个地址的重定向,会报错
`(308) Permanent Redirect`。遇到这个错误时,请使用直连安装地址:

```powershell
irm https://raw.githubusercontent.com/EverMind-AI/Raven/refs/heads/main/install.ps1 | iex
Expand Down
9 changes: 6 additions & 3 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,14 @@ function Install-PrivateNode {
function Resolve-RavenLatestVersion {
$target = ""
try {
$response = Invoke-WebRequest "https://github.com/EverMind-AI/Raven/releases/latest" -MaximumRedirection 0 -UseBasicParsing -ErrorAction Stop
# Windows PowerShell 5.1 hands back the unfollowed redirect as the
# response and reports the exceeded redirect count as an error with no
# response attached, so that error is ignored rather than caught.
$response = Invoke-WebRequest "https://github.com/EverMind-AI/Raven/releases/latest" -MaximumRedirection 0 -UseBasicParsing -ErrorAction Ignore
$target = [string]$response.Headers.Location
} catch {
# Windows PowerShell raises on an unfollowed redirect; the Location header
# still rides on the exception's response.
# PowerShell 7 raises on an unfollowed redirect whatever -ErrorAction
# says; the Location header rides on the exception's response.
$failed = $_.Exception.Response
if ($failed) {
try { $target = [string]$failed.Headers.Location } catch { $target = "" }
Expand Down
20 changes: 19 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,28 @@

from __future__ import annotations

import locale
import os

# The suite must run in UTF-8 mode. Without an explicit encoding=, text I/O
# follows the locale code page, so a Windows checkout under GBK decodes and
# encodes cp936 wherever a test forgot encoding= -- the mechanism behind
# test_no_workflow_step_enters_the_removed_page_directory. This repo documents
# `uv run pytest` in AGENTS.md, and that caller does not read the Makefile, so
# Makefile exports alone cannot close the gap. Any conftest-loaded run on a
# non-UTF-8 interpreter stops here, with the fix on the message.
if locale.getpreferredencoding(False).lower() not in ("utf-8", "utf8"):
raise RuntimeError(
"raven's tests must run in UTF-8 mode (set 'PYTHONUTF8=1' or add "
"'-X utf8' to the python call) so text I/O without an explicit encoding= decodes "
"consistently on every platform. The current interpreter's preferred "
"encoding is "
f"{locale.getpreferredencoding(False)!r}."
)

import contextlib
import fnmatch
import functools
import os
import shutil
import tempfile
import threading
Expand Down
2 changes: 1 addition & 1 deletion tests/test_cli_onboard_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -6493,7 +6493,7 @@ def test_no_workflow_step_enters_the_removed_page_directory() -> None:
workflows = sorted((root / ".github" / "workflows").glob("*.yml"))
assert workflows, "no workflows found -- the glob is wrong, not the tree"
for path in workflows:
text = path.read_text()
text = path.read_text(encoding="utf-8")
assert "working-directory: ui\n" not in text + "\n", f"{path.name}: enters the removed ui/ tree"
assert "--prefix ui " not in text and "--prefix ui\n" not in text + "\n", (
f"{path.name}: runs npm against the removed ui/ tree"
Expand Down
52 changes: 49 additions & 3 deletions tests/test_install_script.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@
INSTALL_SH = Path(__file__).resolve().parents[1] / "install.sh"
INSTALL_PS1 = Path(__file__).resolve().parents[1] / "install.ps1"

# Tests that run install.sh code under sh. Git Bash can run them on Windows, but
# not as a Linux or Mac host would: its curl sits outside /usr/bin and the
# script's paths mix separators with Windows ones, so a harness there fails or
# passes for the wrong reason.
POSIX_SH_ONLY = pytest.mark.skipif(sys.platform == "win32" or shutil.which("sh") is None, reason="POSIX sh only")


def test_the_installer_is_where_this_tripwire_thinks_it_is() -> None:
assert INSTALL_SH.is_file()
Expand Down Expand Up @@ -124,7 +130,7 @@ def test_the_launch_probes_before_calling_a_subcommand_the_release_may_lack() ->
assert "Raven installed." in launch


@pytest.mark.skipif(sys.platform == "win32" or shutil.which("sh") is None, reason="POSIX sh only")
@POSIX_SH_ONLY
def test_launch_web_answers_each_release_shape_it_exists_for(tmp_path: Path) -> None:
"""The text pins cannot tell a probe that gates the launch from one that is
merely present. Two fake ravens stand in: the shape of the latest release,
Expand Down Expand Up @@ -256,7 +262,7 @@ def test_the_staleness_walk_prunes_what_the_build_itself_writes() -> None:
assert r"\( -name node_modules -o -name dist -o -name .modern \) -prune -o" in walk


@pytest.mark.skipif(sys.platform == "win32" or shutil.which("sh") is None, reason="POSIX sh only")
@POSIX_SH_ONLY
def test_is_stale_answers_each_case_it_exists_for(tmp_path: Path) -> None:
"""The text pins above cannot tell a working mtime comparison from a broken
one, and this is the half that decides whether a rebuild happens at all."""
Expand Down Expand Up @@ -335,7 +341,7 @@ def test_the_build_time_node_fetch_cannot_fail_the_install() -> None:
assert 'blocker="Found node but not npm"' in probe


@pytest.mark.skipif(sys.platform == "win32" or shutil.which("sh") is None, reason="POSIX sh only")
@POSIX_SH_ONLY
def test_resolve_node_dir_answers_each_case_it_exists_for(tmp_path: Path) -> None:
"""The text pins cannot tell a working fallback from one that never fires."""
source = INSTALL_SH.read_text(encoding="utf-8")
Expand Down Expand Up @@ -460,10 +466,40 @@ def test_the_ci_gate_installs_the_latest_release_the_way_users_do() -> None:
assert 'raven.exe" --version' in job and '"$UV_TOOL_BIN_DIR/raven" --version' in job


def test_the_ci_gate_runs_the_windows_install_under_both_powershells() -> None:
"""Windows PowerShell 5.1 is the shell every Windows ships with, pwsh is the
one CI reaches for, and their web cmdlets fail differently. A release
lookup that only worked the pwsh way passed this gate while every install
from the stock shell failed, so the gate runs both -- each into its own
tool directory, so that each version check answers for its own install."""
workflow = (Path(__file__).resolve().parents[1] / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
job = workflow[workflow.index(" installer:") :]
job = job[: job.index("\n windows-upgrade:")]
steps = [step for step in job.split("\n - ") if "Get-Content install.ps1 -Raw | Invoke-Expression" in step]
assert sorted(re.search(r"\n shell: (\S+)", step).group(1) for step in steps) == ["powershell", "pwsh"]
assert len({re.search(r"\n UV_TOOL_DIR: (.+)", step).group(1) for step in steps}) == 2


def test_the_windows_installer_is_where_this_tripwire_thinks_it_is() -> None:
assert INSTALL_PS1.is_file()


def test_the_windows_release_lookup_reads_the_redirect_in_both_powershells() -> None:
"""The lookup stops at the release page redirect and reads its Location.
Windows PowerShell 5.1 returns that redirect as the response and reports
the exceeded redirect count as an error with no response attached, so the
error has to be ignored: raised and caught instead, it left the catch
nothing to read, and every install from the stock shell ended in "Could not
resolve the latest Raven release wheel". pwsh raises whatever -ErrorAction
says, so the catch still reads the exception's response."""
text = INSTALL_PS1.read_text(encoding="utf-8")
lookup = text[text.index("function Resolve-RavenLatestVersion") : text.index("function Resolve-RavenWheel")]
probe = next(line for line in lookup.splitlines() if "-MaximumRedirection 0" in line)
assert "-ErrorAction Ignore" in probe
assert "$target = [string]$response.Headers.Location" in lookup
assert "$failed = $_.Exception.Response" in lookup


def test_the_windows_capability_steps_exist_and_are_skippable() -> None:
text = INSTALL_PS1.read_text(encoding="utf-8")
for fn in ("Install-Browser", "Install-Office"):
Expand Down Expand Up @@ -653,6 +689,7 @@ def test_the_font_step_is_skippable_and_runs_before_the_launch() -> None:
assert main_body.index("install_cjk_fonts") < main_body.index("launch_web")


@POSIX_SH_ONLY
def test_a_linux_host_with_a_han_face_downloads_nothing_and_keeps_its_previews(tmp_path: Path) -> None:
"""LibreOffice reads the same fontconfig as fc-list on Linux, so a Chinese
family listed there is one a page is drawn with."""
Expand All @@ -667,6 +704,7 @@ def test_a_linux_host_with_a_han_face_downloads_nothing_and_keeps_its_previews(t
assert calls == []


@POSIX_SH_ONLY
def test_a_linux_host_without_one_gets_the_face_and_loses_its_stale_previews(tmp_path: Path) -> None:
harness = _font_step_harness(tmp_path, **_published_face(tmp_path))
result, calls, cached, home = _run_font_step(tmp_path, harness, os_name="linux", tools=("soffice",))
Expand All @@ -678,6 +716,7 @@ def test_a_linux_host_without_one_gets_the_face_and_loses_its_stale_previews(tmp
assert not cached.exists(), "a preview cached before the face existed shows boxes forever"


@POSIX_SH_ONLY
def test_a_download_that_does_not_match_its_digest_is_not_installed(tmp_path: Path) -> None:
"""A truncated or substituted OTF still parses and draws nothing, which is
exactly the failure the step is for -- so a mismatch installs nothing."""
Expand All @@ -694,6 +733,7 @@ def test_a_download_that_does_not_match_its_digest_is_not_installed(tmp_path: Pa
assert cached.is_file()


@POSIX_SH_ONLY
def test_a_mac_is_left_alone_by_the_font_step(tmp_path: Path) -> None:
"""A Mac already ships Han faces, and raven links them into LibreOffice's
profiles itself (raven/utils/office.py): no download, no password, and no
Expand Down Expand Up @@ -785,6 +825,7 @@ def _run_office_step(tmp_path: Path, harness: Path, dmg: bytes = b"a dmg that is
return result, calls, apps, home


@POSIX_SH_ONLY
def test_a_mac_without_homebrew_still_gets_libreoffice(tmp_path: Path) -> None:
"""Deck preview is the render-truth capability, and it was off on every Mac
without Homebrew: the script only warned. The release dmg is fetched,
Expand All @@ -809,6 +850,7 @@ def test_a_mac_without_homebrew_still_gets_libreoffice(tmp_path: Path) -> None:
assert [p.name for p in tmp_path.glob("raven-libreoffice.*")] == [], "the download does not outlive the step"


@POSIX_SH_ONLY
def test_a_dmg_that_does_not_match_its_digest_is_never_mounted(tmp_path: Path) -> None:
"""An app copied from an image nobody verified is the one thing worse than
no preview. Both sources are tried, then the step gives up and says so."""
Expand All @@ -825,6 +867,7 @@ def test_a_dmg_that_does_not_match_its_digest_is_never_mounted(tmp_path: Path) -
assert "libreoffice.org" in result.stderr


@POSIX_SH_ONLY
def test_an_app_already_in_applications_gets_a_launcher_and_no_second_copy(tmp_path: Path) -> None:
"""The libreoffice.org dmg puts nothing on PATH. Downloading again would
copy a second bundle over the one the user installed."""
Expand All @@ -841,6 +884,7 @@ def test_an_app_already_in_applications_gets_a_launcher_and_no_second_copy(tmp_p
assert (home / ".local" / "bin" / "soffice").is_file()


@POSIX_SH_ONLY
def test_a_launcher_that_cannot_be_written_does_not_abort_the_install(tmp_path: Path) -> None:
"""The step is best effort: an existing app whose launcher cannot be written
(here ~/.local is a file, which blocks root as well) is a warning, and the
Expand All @@ -863,6 +907,7 @@ def test_a_launcher_that_cannot_be_written_does_not_abort_the_install(tmp_path:
assert calls == []


@POSIX_SH_ONLY
def test_a_linux_host_without_libreoffice_downloads_no_font(tmp_path: Path) -> None:
"""The LibreOffice offer declined, or a distro without apt: nothing will
render, so the 8 MB face would be fetched for nobody."""
Expand All @@ -876,6 +921,7 @@ def test_a_linux_host_without_libreoffice_downloads_no_font(tmp_path: Path) -> N
assert cached.is_file()


@POSIX_SH_ONLY
def test_the_pinned_face_by_name_counts_as_a_han_face(tmp_path: Path) -> None:
"""A host with the fontconfig library but no fc-list binary: the file this
step installs, found by its own name, means a second run downloads nothing."""
Expand Down
Loading