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
110 changes: 110 additions & 0 deletions .github/workflows/windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
name: Windows (compile and tests)

# Windows is NOT a supported platform yet (docs/PORTABILITY.md). This workflow
# only answers two questions on every change that could affect it, so a Windows
# regression is seen before anyone ships Windows:
# 1. does the Tauri wrapper still COMPILE for Windows (cargo check), and
# 2. does the Windows-safe part of the Python suite still pass on Windows.
# It builds no installer, signs nothing, publishes nothing, uploads no artifact
# and uses no secret. It is not a required check (branch protection does not
# list it), so a red run here does not block a merge; look at it when it is red.
# Runs only on GitHub-hosted runners (this repo is public); never a
# self-hosted one (tests/test_workflows_guard.py).
on:
workflow_dispatch:
push:
branches: [main]
paths: &paths
- "ops_dashboard/**"
- "tests/**"
- "desktop/**"
- "requirements*.txt"
- "pyproject.toml"
- ".github/workflows/windows.yml"
pull_request:
paths: *paths

permissions:
contents: read

concurrency:
group: windows-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
windows-python:
# The Python suite on Windows. Tests that need something Windows does not
# have (bubblewrap/seatbelt, bash scripts, AF_UNIX agent sockets, POSIX
# permission bits) are skipped with an explicit reason: see
# tests/windows_skips.py, which tests/conftest.py applies on win32 only.
runs-on: windows-latest
timeout-minutes: 45
defaults:
run:
shell: bash
env:
# The sidecar will be started with UTF-8 mode on Windows (plan item 8);
# the CI matches that. Encoding problems of the default cp1252 are not
# what this job looks for.
PYTHONUTF8: "1"
steps:
- name: Keep line endings as they are in the repository
run: git config --global core.autocrlf false

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0
with:
python-version: "3.14"
cache: pip
cache-dependency-path: requirements*.txt

- name: Install dev dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements-dev.txt

- name: Test (pytest, Windows-safe subset)
# Not run here: the live license worker, the two headless-Chrome JS
# tests and the *_ui_browser tests (they need Chrome/Playwright and
# run in ci.yml), and no coverage floor (ci.yml owns it).
run: >
pytest -m "not live" -p no:cacheprovider -q -ra
--ignore=tests/test_license_worker.py
--ignore=tests/test_browser_visibility_js.py
--ignore=tests/test_desktop_nav_js.py
--ignore-glob="tests/test_*ui_browser.py"
Comment thread
EVMCOD marked this conversation as resolved.

windows-rust:
# Compile-only: cargo check of the Tauri wrapper for the Windows target
# (this is where a missing #[cfg(windows)] branch shows up) and the tests of
# the pure-Rust URL policy. The wrapper's own tests are not run: they would
# link the WebView2 loader, and nothing here launches the app.
runs-on: windows-latest
timeout-minutes: 40
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
toolchain: stable

- uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
workspaces: desktop/src-tauri

- name: Placeholder sidecar (tauri-build checks externalBin exists)
run: |
mkdir -p desktop/src-tauri/binaries
triple="$(rustc -vV | sed -n 's/host: //p' | tr -d '\r')"
: > "desktop/src-tauri/binaries/conductloop-server-${triple}.exe"

- name: cargo check (locked)
working-directory: desktop/src-tauri
run: cargo check --locked --all-targets

- name: cargo test (url-policy)
run: cargo test --manifest-path desktop/url-policy/Cargo.toml
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ User-facing changes to ConductLoop (the desktop app and its local server), newes

## Unreleased

- CI only: a "Windows (compile and tests)" workflow checks on every relevant pull request that the desktop wrapper still compiles for Windows and that the Windows-safe part of the Python tests passes there. It builds no installer, signs nothing, publishes nothing and is not a required check. Windows is still not supported.
- **A home for your notes.** Without projects (a fresh install has none), the
dashboard opened on an empty project canvas and search hid behind ⌘P. It now
opens on "Your notes": how many are indexed, a box to ask a question (it goes
Expand Down
20 changes: 20 additions & 0 deletions docs/PORTABILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -860,6 +860,26 @@ byte-for-byte (see "Invariantes de macOS"). "Tests" are what that PR must add.
9. **`windows-latest` compile-only job** (Rust check and the Windows-safe Python
subset, which also runs the real Windows tests of PR 8). *Tests:* the job
itself; `test_workflows_guard.py`.
**Done in PR 9** (`.github/workflows/windows.yml`, stacked on PR 8): two
`windows-latest` jobs. `windows-rust` runs `cargo check --locked
--all-targets` of `desktop/src-tauri` (with a placeholder sidecar) and the
`desktop/url-policy` tests. `windows-python` runs the pytest suite
(`-m "not live"`, with `PYTHONUTF8=1`) minus the files listed with a reason in
`tests/windows_skips.py` (sandbox, bash scripts, AF_UNIX bridge), applied by
`tests/conftest.py` on `win32` only. It builds, signs and publishes nothing,
uses no secret and is not a required check.
The first run found 108 Windows failures (1676 tests passed, including the
real-Windows tests of PR 8): the evals harness leaked an open sqlite file into
a temp dir it then could not delete (fixed: `ignore_cleanup_errors`), and the
rest is skipped with a written reason in `tests/windows_skips.py`; the entries
marked "not investigated" (vault index/notes tools, skills compat, one HTTP
test) are the to-do list for Windows support.
Tests about POSIX permission bits (`st_mode`, `S_IMODE`, the executable bit,
`os.access`) are not listed one by one: they carry `@pytest.mark.posix_perms`
(skipped on `win32` by `tests/conftest.py`) or assert through
`tests/posix_perms.py`, whose helpers do nothing on Windows and leave the rest
of the test running. `tests/test_posix_perms_guard.py` fails when a test reads
permission bits without one of the two.
10. **Windows Credential Manager backend** (with the blob-size handling).
*Tests:* selector and size-limit tests with a fake backend.
11. **Windows packaging (NSIS), Help menu entry and release-manifest design:** a
Expand Down
11 changes: 8 additions & 3 deletions ops_dashboard/evals/checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ class CheckSpecError(ValueError):
_CITE_RE = re.compile(r"\[(\d{1,3})\]")


def _posix(path: Any) -> str:
"""A document path with forward slashes: Windows indexes ``a\\b.md``, the cases say ``a/b.md``."""
return str(path).replace("\\", "/")


def _req(spec: dict, key: str) -> Any:
if key not in spec:
raise CheckSpecError(f"check {spec.get('type')!r} needs {key!r}")
Expand Down Expand Up @@ -136,15 +141,15 @@ def _check(o: Outcome, spec: dict) -> CheckResult: # noqa: C901 - flat dispatch
+ (f"; invalid {invalid}" if invalid else ""))
if t == "cites":
path = str(_req(spec, "path"))
nums = [c["n"] for c in o.citations if str(c.get("path", "")).endswith(path)]
nums = [c["n"] for c in o.citations if _posix(c.get("path", "")).endswith(path)]
answer_refs = {int(x) for x in _CITE_RE.findall(o.answer)}
ok = any(n in answer_refs for n in nums)
return CheckResult(t, ok, f"{path} is citation {nums or 'none'}; answer cites {sorted(answer_refs)}")
if t == "citation_path":
path = str(_req(spec, "path"))
top = spec.get("within_top")
pool = o.citations[: int(top)] if top else o.citations
ok = any(str(c.get("path", "")).endswith(path) for c in pool)
ok = any(_posix(c.get("path", "")).endswith(path) for c in pool)
return CheckResult(t, ok, f"{path} {'in' if ok else 'not in'} "
f"{'top ' + str(top) if top else 'citations'} ({len(o.citations)} total)")
if t == "citations_min":
Expand Down Expand Up @@ -172,7 +177,7 @@ def _check(o: Outcome, spec: dict) -> CheckResult: # noqa: C901 - flat dispatch
path = str(_req(spec, "path"))
top = spec.get("within_top")
pool = o.results[: int(top)] if top else o.results
ok = any(str(r.get("path", "")).endswith(path) for r in pool)
ok = any(_posix(r.get("path", "")).endswith(path) for r in pool)
return CheckResult(t, ok, f"{path} {'in' if ok else 'not in'} {len(o.results)} result(s)")
if t == "result_title":
title = str(_req(spec, "text"))
Expand Down
2 changes: 1 addition & 1 deletion ops_dashboard/evals/record.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def record_case(suite: _runner.Suite, case: _runner.Case, *, provider: str, mode
if suite.target != "brain.ask":
raise RecordRefused("no_cassette_target", f"suite {suite.name} does not use cassettes")
rec = RecordingProvider(provider_builder(provider, model, key_name))
with tempfile.TemporaryDirectory(prefix="cl-eval-rec-") as td:
with tempfile.TemporaryDirectory(prefix="cl-eval-rec-", ignore_cleanup_errors=True) as td:
with _runner.case_env(suite, case, Path(td)):
from .. import brain
orig = brain.resolve_provider
Expand Down
5 changes: 4 additions & 1 deletion ops_dashboard/evals/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,10 @@ def run_case(suite: Suite, case: Case, *, guard: Any = None,
error: str | None = None
results: list[dict] = []
before = len(guard.attempts) if guard is not None else 0
with tempfile.TemporaryDirectory(prefix="cl-eval-") as td:
# ignore_cleanup_errors: the case's sqlite file may still be open (a connection
# not yet garbage-collected); Windows refuses to delete it, and a throwaway
# directory that cannot be removed must not fail the case.
with tempfile.TemporaryDirectory(prefix="cl-eval-", ignore_cleanup_errors=True) as td:
provider: Any = None
try:
with case_env(suite, case, Path(td)) as patches:
Expand Down
3 changes: 2 additions & 1 deletion pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ python_functions = test_*
addopts = -v --tb=short --strict-markers -m "not live"
markers =
live: hits the live Cloudflare Worker (requires network)
slow: tests that take >5 seconds
slow: tests that take >5 seconds
posix_perms: asserts POSIX permission bits (st_mode, chmod, executable bit); skipped on Windows (tests/posix_perms.py)
23 changes: 23 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,26 @@ def _vault_open():
secret_vault.unlock(TEST_PASSPHRASE)
yield
secret_vault.lock()


def pytest_collection_modifyitems(config, items):
"""On Windows, skip what cannot exist there, each with its reason.

* the files and tests of tests/windows_skips.py;
* every test marked ``posix_perms`` (asserts POSIX permission bits).
"""
import sys

if sys.platform != "win32":
return
from posix_perms import REASON as PERMS_REASON
from windows_skips import SKIPPED_ON_WINDOWS

for item in items:
name = Path(str(item.fspath)).name
test = getattr(item, "originalname", None) or item.name
reason = SKIPPED_ON_WINDOWS.get(name) or SKIPPED_ON_WINDOWS.get(f"{name}::{test}")
if reason:
item.add_marker(pytest.mark.skip(reason=f"not on Windows: {reason}"))
elif item.get_closest_marker("posix_perms"):
item.add_marker(pytest.mark.skip(reason=f"not on Windows: {PERMS_REASON}"))
57 changes: 57 additions & 0 deletions tests/posix_perms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Assertions about POSIX permission bits, which Windows does not have.

``chmod(0o600)`` only toggles the read-only attribute on Windows and ``st_mode``
reports 0o666 for ordinary files, so a test that asserts "owner only" or "is
executable" cannot pass there, and it is not a bug (docs/PORTABILITY.md 4.3,
``ops_dashboard/fileperms.py``). Two ways to deal with such a test, and the only
two the guard in tests/test_windows_workflow.py accepts:

* the whole test is about the bits: mark it ``@pytest.mark.posix_perms`` (skipped
on Windows by tests/conftest.py, with the reason below);
* the bits are one assertion among others: make that assertion with one of the
helpers here, which do nothing on Windows, and keep the rest of the test
running there.

macOS and Linux assert exactly what they asserted before.
"""
from __future__ import annotations

import os
import stat
import sys
from pathlib import Path

REASON = ("POSIX permission bits (st_mode, chmod, the executable bit) do not exist on Windows "
"(docs/PORTABILITY.md 4.3)")


# Decided once at import: some tests monkeypatch sys.platform to exercise another OS's
# code path, but the file system underneath is still the real one.
_WINDOWS = sys.platform == "win32"


def on_windows() -> bool:
return _WINDOWS


def assert_mode(path: str | os.PathLike[str], expected: int) -> None:
"""``S_IMODE(stat(path)) == expected``, except on Windows."""
if on_windows():
return
actual = stat.S_IMODE(Path(path).stat().st_mode)
assert actual == expected, f"{path}: mode {oct(actual)}, expected {oct(expected)}"


def assert_no_group_other_access(path: str | os.PathLike[str]) -> None:
"""No permission bit for group or others, except on Windows."""
if on_windows():
return
actual = stat.S_IMODE(Path(path).stat().st_mode)
assert actual & 0o077 == 0, f"{path}: mode {oct(actual)} gives access to group/others"


def assert_executable(path: str | os.PathLike[str]) -> None:
"""The file has an execute bit, except on Windows."""
if on_windows():
return
assert Path(path).stat().st_mode & 0o111, f"{path} is not executable"
1 change: 1 addition & 0 deletions tests/test_byok_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def test_empty_key_clears_both(monkeypatch):
assert _cfg() == {}


@pytest.mark.posix_perms
def test_config_written_owner_only(monkeypatch):
FakeKeychain(enabled=False).install(monkeypatch)
brain.set_provider_key("openai", api_key=FAKE)
Expand Down
4 changes: 2 additions & 2 deletions tests/test_learning_feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@
import os
import socket
import sqlite3
import stat
import threading
import time
import urllib.error
import urllib.request
from http.server import ThreadingHTTPServer

import pytest
from posix_perms import assert_mode

from ops_dashboard import migrations

Expand Down Expand Up @@ -230,7 +230,7 @@ def test_export_is_local_anonymised_and_private(tmpdb):
comment="comentario")
path = learning.export_local()
assert path.parent == learning.store.export_dir()
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
assert_mode(path, 0o600)
text = path.read_text()
assert "respuesta secreta" not in text and "comentario" not in text
data = json.loads(text)
Expand Down
2 changes: 2 additions & 0 deletions tests/test_linux_bundles_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import re
from pathlib import Path

import pytest
import yaml

ROOT = Path(__file__).resolve().parent.parent
Expand Down Expand Up @@ -59,6 +60,7 @@ def test_it_publishes_nothing_and_takes_no_secret():
assert DOC["permissions"] == {"contents": "read"}


@pytest.mark.posix_perms
def test_the_script_it_calls_exists_and_is_executable():
script = ROOT / "scripts" / "build-linux.sh"
assert script.is_file() and script.stat().st_mode & 0o111
1 change: 1 addition & 0 deletions tests/test_linux_packaging.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ def test_the_macos_release_script_is_not_wired_to_the_linux_one():
assert "build-linux" not in (ROOT / "scripts" / "release-desktop.sh").read_text()


@pytest.mark.posix_perms
def test_the_script_is_executable_and_valid_bash():
assert os.access(SCRIPT, os.X_OK)
subprocess.run(["bash", "-n", str(SCRIPT)], check=True)
Expand Down
Loading
Loading