From f1f73427e4773665ace00f6c9428f48313a997c7 Mon Sep 17 00:00:00 2001 From: Ali Khokhar Date: Fri, 25 Sep 2026 00:58:41 -0700 Subject: [PATCH] Run pytest and Playwright on Windows, Linux, and macOS (#1894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why CI previously exercised pytest and Playwright only on Linux, leaving Windows and macOS failures undiscovered. Its shared job exposed irrelevant browser steps in other checks, and post-merge cache warming covered only Linux. The new runners exposed protected-file metadata copying in macOS fixtures, browser tests racing startup or loading real providers, and packaged assets depending on the host MIME registry. Windows pytest also exceeded the inherited 15-minute limit before printing two Pi failures. ## How Give pytest and headless Chromium Playwright separate three-OS matrices. Keep the ban, Ruff, and Ty checks on Linux. Share managed Python and dependency-cache setup through one composite action with portable paths, dependency fingerprints, and unique browser-artifact names. Warm matching caches on all three operating systems after merges, with PR jobs restoring caches only. Copy executable permissions without protected macOS metadata, keep browser provider construction inside explicit test doubles, and await code-service startup before simulating unavailability. Serve packaged JS, CSS, SVG, and PNG assets with explicit content types. Add structural coverage for all six required test checks, including deletion cases, and bump FCC to 6.2.63. Give Windows pytest a 45-minute job budget and report slow test phases. Prepare Node's ES-module loader, TypeScript stripping, and fetch primitives through shared CI setup for all three pytest jobs. Windows tracing identified cold executable paging as the Pi timeout cause. The original Pi timeout checks now pass, so remove the temporary observer, profiling wrapper, and early-exit option. Use Playwright system dependency installation only on Linux, avoiding the slow Media Foundation installation on Windows. Give each pagination setup event its existing timeout instead of placing all 55 sequential events under one deadline. Wait for native submission before checking the competing-tabs input count. Run component-specific PowerShell installer cases through the real source functions, loaded using PowerShell's parser. Preserve parameterized compatibility and failure assertions while avoiding unrelated installations. Keep full-script coverage for fresh installation, prompts, shared PATH behavior, process guards, representative phase failures, and uninstall ownership. Continue exercising both PowerShell 7 and 5.1. Wait for integration request interception before inspecting or completing held browser requests. Keep temporary CI diagnostics enabled while investigating the macOS folder-picker hang and hosted Windows installer cost. Report stalled Python threads and picker tasks directly in live logs, and timestamp the existing fresh-install scenarios in both PowerShell versions.

RetriggerConfidence Score: 4/5

The outstanding Windows pytest time-limit concern should be addressed before merging.
Summary The PR runs pytest and Playwright across Linux, Windows, and macOS and improves cross-platform test setup. The Windows pytest job still has a 15-minute limit; if the full job exceeds it, CI will cancel before reporting all test results.
Reviews (13) · Last reviewed commit: ["Wait for intercepted requests and comple..."](https://github.com/alishahryar1/free-claude-code/commit/149cdd19d474f30eb29291f5b8f35bab3cf077bd) --- .github/actions/ci-environment/action.yml | 144 +++++++ .github/workflows/dependency-cache.yml | 102 ++--- .github/workflows/tests.yml | 147 ++++--- e2e/conftest.py | 28 +- e2e/test_admin_integrations.py | 32 +- e2e/test_admin_providers.py | 11 +- e2e/test_code_sessions.py | 50 +-- e2e/test_connected_accounts.py | 6 +- pyproject.toml | 5 +- src/free_claude_code/api/admin_routes.py | 9 +- tests/api/test_admin.py | 5 + tests/api/test_app_lifespan_and_errors.py | 9 +- tests/config/test_logging_config.py | 10 + tests/conftest.py | 19 +- tests/contracts/test_uv_policy.py | 59 ++- tests/installers/AGENTS.md | 28 ++ tests/{scripts => installers}/_pty_runner.py | 0 tests/installers/conftest.py | 51 +++ .../fixtures/.gitattributes | 0 .../fixtures/README.md | 0 .../fixtures/rtk-LICENSE | 0 .../fixtures/rtk-opencode-v1.ts | 0 .../test_installers.py | 391 +++++++++++++----- .../test_muse_windows_lifecycle.py | 6 + .../test_powershell_cache.py | 3 +- .../test_uninstallers.py | 4 +- tests/scripts/conftest.py | 4 +- uv.lock | 4 +- 28 files changed, 827 insertions(+), 300 deletions(-) create mode 100644 .github/actions/ci-environment/action.yml create mode 100644 tests/installers/AGENTS.md rename tests/{scripts => installers}/_pty_runner.py (100%) create mode 100644 tests/installers/conftest.py rename tests/{scripts => installers}/fixtures/.gitattributes (100%) rename tests/{scripts => installers}/fixtures/README.md (100%) rename tests/{scripts => installers}/fixtures/rtk-LICENSE (100%) rename tests/{scripts => installers}/fixtures/rtk-opencode-v1.ts (100%) rename tests/{scripts => installers}/test_installers.py (93%) rename tests/{scripts => installers}/test_muse_windows_lifecycle.py (99%) rename tests/{scripts => installers}/test_powershell_cache.py (94%) rename tests/{scripts => installers}/test_uninstallers.py (99%) diff --git a/.github/actions/ci-environment/action.yml b/.github/actions/ci-environment/action.yml new file mode 100644 index 0000000000..aa8db79dbd --- /dev/null +++ b/.github/actions/ci-environment/action.yml @@ -0,0 +1,144 @@ +name: CI environment +description: Restore managed Python and uv dependencies using the same cache identity on every runner. + +inputs: + uv-version: + description: Pinned uv version + required: true + runner-label: + description: GitHub runner image label + required: true + lookup-only: + description: Check dependency cache availability without downloading it + default: "false" + prepare-node: + description: Prepare the Node runtime used by pytest subprocess contracts. + default: "false" + +outputs: + python-path: + description: Managed Python cache directory + value: ${{ steps.identity.outputs.python-path }} + python-key: + description: Exact managed Python cache key + value: ${{ steps.identity.outputs.python-key }} + python-hit: + description: Whether managed Python was restored from the exact key + value: ${{ steps.python-cache.outputs.cache-hit }} + dependencies-path: + description: uv dependency cache directory + value: ${{ steps.identity.outputs.dependencies-path }} + dependencies-prefix: + description: Compatible dependency cache prefix + value: ${{ steps.identity.outputs.dependencies-prefix }} + dependencies-key: + description: Exact dependency cache key + value: ${{ steps.identity.outputs.dependencies-prefix }}${{ steps.dependencies.outputs.hash }} + dependencies-hit: + description: Whether the exact dependency key exists + value: ${{ steps.dependencies-cache.outputs.cache-hit }} + +runs: + using: composite + steps: + - name: Prepare cache identity + id: identity + shell: bash + env: + CI_TEMP: ${{ runner.temp }} + CI_RUNNER: ${{ inputs.runner-label }} + CI_OS: ${{ runner.os }} + CI_ARCH: ${{ runner.arch }} + CI_UV: ${{ inputs.uv-version }} + run: | + set -euo pipefail + version="$(tr -d '[:space:]' < .python-version)" + test -n "$version" + cache_root="${CI_TEMP//\\//}" + identity="$CI_RUNNER-$CI_OS-$CI_ARCH-py-$version-uv-$CI_UV" + { + echo "UV_CACHE_DIR=$cache_root/fcc-uv-cache" + echo "UV_PYTHON_INSTALL_DIR=$cache_root/fcc-uv-python" + echo "UV_PYTHON_PREFERENCE=only-managed" + } >> "$GITHUB_ENV" + { + echo "python-version=$version" + echo "python-path=$cache_root/fcc-uv-python" + echo "python-key=uv-python-v2-$identity" + echo "dependencies-path=$cache_root/fcc-uv-cache" + echo "dependencies-prefix=uv-deps-v2-$identity-" + } >> "$GITHUB_OUTPUT" + + - name: Restore Python + id: python-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.identity.outputs.python-path }} + key: ${{ steps.identity.outputs.python-key }} + + - name: Install uv + uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 + with: + version: ${{ inputs.uv-version }} + enable-cache: false + + - name: Install Python + if: steps.python-cache.outputs.cache-hit != 'true' + shell: bash + env: + CI_PYTHON: ${{ steps.identity.outputs.python-version }} + run: uv python install "$CI_PYTHON" + + - name: Prepare Node runtime + if: inputs.prepare-node == 'true' + shell: bash + env: + CI_PYTHON: ${{ steps.identity.outputs.python-version }} + run: | + uv run --no-project --python "$CI_PYTHON" python - <<'PY' + import shutil + import subprocess + + node = shutil.which("node") + if node is None: + raise RuntimeError("Node is required for pytest subprocess contracts") + # Cold executable paging must happen before the timed behavior checks. + subprocess.run( + [node, "--experimental-strip-types", "--input-type=module", "--eval", + "import { stripTypeScriptTypes } from 'node:module'; " + "stripTypeScriptTypes('const value: number = 1'); " + "await new Response('ready').text(); " + "console.log('Node runtime ready:', process.version);"], + check=True, + timeout=120, + ) + PY + + - name: Fingerprint dependencies + id: dependencies + shell: bash + env: + CI_PYTHON: ${{ steps.identity.outputs.python-version }} + run: | + set -euo pipefail + uv export --locked --group dev --no-emit-workspace --no-header --no-annotate \ + --output-file "$RUNNER_TEMP/uv-ci-dependencies.txt" > /dev/null + uv run --no-project --python "$CI_PYTHON" python - <<'PY' + import hashlib + import os + from pathlib import Path + + dependencies = Path(os.environ["RUNNER_TEMP"]) / "uv-ci-dependencies.txt" + digest = hashlib.sha256(dependencies.read_bytes().replace(b"\r\n", b"\n")).hexdigest() + with Path(os.environ["GITHUB_OUTPUT"]).open("a", encoding="utf-8") as output: + output.write(f"hash={digest}\n") + PY + + - name: Restore dependency cache + id: dependencies-cache + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ${{ steps.identity.outputs.dependencies-path }} + key: ${{ steps.identity.outputs.dependencies-prefix }}${{ steps.dependencies.outputs.hash }} + restore-keys: ${{ steps.identity.outputs.dependencies-prefix }} + lookup-only: ${{ inputs.lookup-only }} diff --git a/.github/workflows/dependency-cache.yml b/.github/workflows/dependency-cache.yml index 7aa45683c5..27c54e31e9 100644 --- a/.github/workflows/dependency-cache.yml +++ b/.github/workflows/dependency-cache.yml @@ -4,11 +4,9 @@ env: CI_UV_VERSION: "0.12.13" UV_MALWARE_CHECK: "1" UV_PREVIEW_FEATURES: "malware-check" - UV_CACHE_DIR: /tmp/.uv-cache - UV_PYTHON_INSTALL_DIR: /tmp/uv-python - UV_PYTHON_PREFERENCE: only-managed on: + workflow_dispatch: push: branches: [main] @@ -18,87 +16,57 @@ concurrency: jobs: warm-dependency-cache: - name: Warm dependency cache - runs-on: ubuntu-latest + name: Warm dependency cache (${{ matrix.os }}) + if: github.ref == 'refs/heads/main' + runs-on: ${{ matrix.runner }} timeout-minutes: 15 permissions: contents: read + strategy: + fail-fast: false + matrix: + include: + - os: Linux + runner: ubuntu-latest + - os: Windows + runner: windows-latest + - os: macOS + runner: macos-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: fetch-depth: 1 - - name: Read Python version - id: python - shell: bash - run: | - set -euo pipefail - version="$(tr -d '[:space:]' < .python-version)" - test -n "$version" - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Restore Python - id: python-cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + - name: Set up CI environment + id: environment + uses: ./.github/actions/ci-environment with: - path: ${{ env.UV_PYTHON_INSTALL_DIR }} - key: uv-python-v1-${{ runner.os }}-${{ runner.arch }}-py-${{ steps.python.outputs.version }}-uv-${{ env.CI_UV_VERSION }} - - - name: Install uv - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 - with: - version: ${{ env.CI_UV_VERSION }} - enable-cache: false - - - name: Install Python - if: steps.python-cache.outputs.cache-hit != 'true' - run: uv python install "${{ steps.python.outputs.version }}" + uv-version: ${{ env.CI_UV_VERSION }} + runner-label: ${{ matrix.runner }} + lookup-only: "true" - name: Save Python - if: steps.python-cache.outputs.cache-hit != 'true' + if: steps.environment.outputs.python-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: - path: ${{ env.UV_PYTHON_INSTALL_DIR }} - key: ${{ steps.python-cache.outputs.cache-primary-key }} - - - name: Fingerprint dependencies - id: dependencies - shell: bash - run: | - set -euo pipefail - uv export \ - --locked \ - --no-emit-workspace \ - --no-header \ - --no-annotate \ - --output-file "$RUNNER_TEMP/uv-ci-dependencies.txt" - echo "hash=$(sha256sum "$RUNNER_TEMP/uv-ci-dependencies.txt" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" - - - name: Check for exact uv cache - id: exact-cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 - with: - path: ${{ env.UV_CACHE_DIR }} - key: uv-deps-v1-${{ runner.os }}-${{ runner.arch }}-py-${{ steps.python.outputs.version }}-uv-${{ env.CI_UV_VERSION }}-${{ steps.dependencies.outputs.hash }} - lookup-only: true + path: ${{ steps.environment.outputs.python-path }} + key: ${{ steps.environment.outputs.python-key }} - - name: Restore compatible uv cache - if: steps.exact-cache.outputs.cache-hit != 'true' - id: uv-cache + - name: Restore compatible dependency cache + if: steps.environment.outputs.dependencies-hit != 'true' uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: - path: ${{ env.UV_CACHE_DIR }} - key: uv-deps-v1-${{ runner.os }}-${{ runner.arch }}-py-${{ steps.python.outputs.version }}-uv-${{ env.CI_UV_VERSION }}-${{ steps.dependencies.outputs.hash }} - restore-keys: | - uv-deps-v1-${{ runner.os }}-${{ runner.arch }}-py-${{ steps.python.outputs.version }}-uv-${{ env.CI_UV_VERSION }}- + path: ${{ steps.environment.outputs.dependencies-path }} + key: ${{ steps.environment.outputs.dependencies-key }} + restore-keys: ${{ steps.environment.outputs.dependencies-prefix }} - - name: Warm uv cache - if: steps.exact-cache.outputs.cache-hit != 'true' - run: uv sync --locked --no-install-workspace + - name: Warm dependency cache + if: steps.environment.outputs.dependencies-hit != 'true' + run: uv sync --locked --group dev --no-install-workspace - - name: Save uv cache - if: steps.exact-cache.outputs.cache-hit != 'true' + - name: Save dependency cache + if: steps.environment.outputs.dependencies-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 with: - path: ${{ env.UV_CACHE_DIR }} - key: ${{ steps.uv-cache.outputs.cache-primary-key }} + path: ${{ steps.environment.outputs.dependencies-path }} + key: ${{ steps.environment.outputs.dependencies-key }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index da1a019a15..b51c64b18b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,6 +1,8 @@ # Branch protection: require every status check this workflow reports, for example: # Ban suppressions and legacy annotations -# ruff-format, ruff-check, ty, pytest, playwright +# ruff-format, ruff-check, ty +# pytest (Linux), pytest (Windows), pytest (macOS) +# playwright (Linux), playwright (Windows), playwright (macOS) # GitHub may prefix with the workflow name (e.g. "CI / ruff-format"); use the names the branch protection UI offers after a run. name: CI @@ -9,9 +11,6 @@ env: CI_UV_VERSION: "0.12.13" UV_MALWARE_CHECK: "1" UV_PREVIEW_FEATURES: "malware-check" - UV_CACHE_DIR: /tmp/.uv-cache - UV_PYTHON_INSTALL_DIR: /tmp/uv-python - UV_PYTHON_PREFERENCE: only-managed on: pull_request: @@ -53,18 +52,11 @@ jobs: matrix: include: - id: ruff-format - run: uv run ruff format --check + run: uv run --locked ruff format --check - id: ruff-check - run: uv run ruff check + run: uv run --locked ruff check - id: ty - run: uv run ty check - - id: pytest - run: uv run pytest -v --tb=short - - id: playwright - run: >- - uv run pytest e2e -v --tb=short - --tracing=retain-on-failure --screenshot=only-on-failure - --full-page-screenshot --output=test-results + run: uv run --locked ty check steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: @@ -72,65 +64,100 @@ jobs: repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} fetch-depth: 1 - - name: Read Python version - id: python - shell: bash - run: | - set -euo pipefail - version="$(tr -d '[:space:]' < .python-version)" - test -n "$version" - echo "version=$version" >> "$GITHUB_OUTPUT" - - - name: Restore Python - id: python-cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + - name: Set up CI environment + uses: ./.github/actions/ci-environment with: - path: ${{ env.UV_PYTHON_INSTALL_DIR }} - key: uv-python-v1-${{ runner.os }}-${{ runner.arch }}-py-${{ steps.python.outputs.version }}-uv-${{ env.CI_UV_VERSION }} + uv-version: ${{ env.CI_UV_VERSION }} + runner-label: ubuntu-latest + + - name: Install dependencies + run: uv sync --locked --group dev + + - name: Run + run: ${{ matrix.run }} - - name: Install uv - uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 + pytest: + name: pytest (${{ matrix.os }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: Linux + runner: ubuntu-latest + - os: Windows + runner: windows-latest + - os: macOS + runner: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - version: ${{ env.CI_UV_VERSION }} - enable-cache: false + ref: ${{ github.event.pull_request.head.sha || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + fetch-depth: 1 - - name: Install Python - if: steps.python-cache.outputs.cache-hit != 'true' - run: uv python install "${{ steps.python.outputs.version }}" + - name: Set up CI environment + uses: ./.github/actions/ci-environment + with: + uv-version: ${{ env.CI_UV_VERSION }} + runner-label: ${{ matrix.runner }} + prepare-node: "true" - - name: Fingerprint dependencies - id: dependencies - shell: bash - run: | - set -euo pipefail - uv export \ - --locked \ - --no-emit-workspace \ - --no-header \ - --no-annotate \ - --output-file "$RUNNER_TEMP/uv-ci-dependencies.txt" - echo "hash=$(sha256sum "$RUNNER_TEMP/uv-ci-dependencies.txt" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" - - - name: Restore uv cache - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + - name: Install dependencies + run: uv sync --locked --group dev + + - name: Run pytest + run: uv run --locked pytest -v --tb=short --durations=50 + + playwright: + name: playwright (${{ matrix.os }}) + runs-on: ${{ matrix.runner }} + timeout-minutes: 15 + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - os: Linux + runner: ubuntu-latest + - os: Windows + runner: windows-latest + - os: macOS + runner: macos-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - path: ${{ env.UV_CACHE_DIR }} - key: uv-deps-v1-${{ runner.os }}-${{ runner.arch }}-py-${{ steps.python.outputs.version }}-uv-${{ env.CI_UV_VERSION }}-${{ steps.dependencies.outputs.hash }} - restore-keys: | - uv-deps-v1-${{ runner.os }}-${{ runner.arch }}-py-${{ steps.python.outputs.version }}-uv-${{ env.CI_UV_VERSION }}- + ref: ${{ github.event.pull_request.head.sha || github.ref }} + repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }} + fetch-depth: 1 + + - name: Set up CI environment + uses: ./.github/actions/ci-environment + with: + uv-version: ${{ env.CI_UV_VERSION }} + runner-label: ${{ matrix.runner }} + + - name: Install dependencies + run: uv sync --locked --group dev - name: Install Chromium - if: matrix.id == 'playwright' - run: uv run playwright install --with-deps chromium + run: uv run --locked playwright install ${{ matrix.os == 'Linux' && '--with-deps chromium' || 'chromium' }} - - name: Run - run: ${{ matrix.run }} + - name: Run Playwright + run: >- + uv run --locked pytest e2e -v --tb=short + --tracing=retain-on-failure --screenshot=only-on-failure + --full-page-screenshot --output=test-results - name: Upload Playwright failure artifacts - if: failure() && matrix.id == 'playwright' + if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a with: - name: playwright-results-${{ github.run_id }}-${{ github.run_attempt }} + name: playwright-results-${{ runner.os }}-${{ runner.arch }}-${{ github.run_id }}-${{ github.run_attempt }} path: test-results/ if-no-files-found: ignore retention-days: 7 diff --git a/e2e/conftest.py b/e2e/conftest.py index 0eb1e22df8..ece94712c9 100644 --- a/e2e/conftest.py +++ b/e2e/conftest.py @@ -23,6 +23,7 @@ clear_settings_cache, get_settings, ) +from free_claude_code.config.settings import Settings from free_claude_code.core.anthropic.models import MessagesRequest from free_claude_code.core.openai_responses import OpenAIResponsesRequest from free_claude_code.core.reasoning import DEFAULT_REASONING_POLICY, ReasoningPolicy @@ -130,6 +131,21 @@ def admin_client_files(): """Allow integration tests to seed client files before server startup.""" +@pytest.fixture +def provider_load_guard(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + attempted: list[str] = [] + + def forbidden(provider_id: str, *_args): + attempted.append(provider_id) + raise AssertionError(f"Browser fixture loaded real provider: {provider_id}") + + monkeypatch.setattr( + "free_claude_code.providers.runtime.runtime._load_constructor", forbidden + ) + yield + assert attempted == [], f"Browser fixture loaded real providers: {attempted}" + + @pytest.fixture def admin_base_url( request: pytest.FixtureRequest, @@ -137,6 +153,7 @@ def admin_base_url( tmp_path: Path, code_control: CodeControl, admin_client_files, + provider_load_guard, ) -> Iterator[str]: """Serve one fully isolated Admin application on an OS-assigned port.""" @@ -202,6 +219,7 @@ def admin_base_url( provider_secret = "CREDENTIAL[unrecognized-format-987654321]" providers: dict[str, BaseProvider] = { + "nvidia_nim": _ModelListingProvider(), "open_router": _ModelListingProvider( frozenset( { @@ -225,9 +243,17 @@ def admin_base_url( error=RuntimeError(f"Provider rejected credential {provider_secret}") ), } + + async def fixture_provider(provider_id: str, _settings: Settings) -> BaseProvider: + if provider_id not in providers: + raise AssertionError(f"Missing browser fixture provider: {provider_id}") + return providers[provider_id] + manager = ProviderRuntimeManager( get_settings(), - runtime_factory=lambda snapshot: ProviderRuntime(snapshot, dict(providers)), + runtime_factory=lambda snapshot: ProviderRuntime( + snapshot, dict(providers), provider_constructor=fixture_provider + ), ) runtime = ApplicationRuntime( manager, diff --git a/e2e/test_admin_integrations.py b/e2e/test_admin_integrations.py index b4657cda0b..a717468f4d 100644 --- a/e2e/test_admin_integrations.py +++ b/e2e/test_admin_integrations.py @@ -22,14 +22,18 @@ def test_connection_check_uses_disabled_loading_button( page, admin_base_url, integration, button_id, connected ): pending = [] - page.route( - f"**/admin/api/integrations/{integration}", lambda route: pending.append(route) - ) + + def hold_check(route): + pending.append(route) + page.evaluate("window.integrationCheckIntercepted = true") + + page.route(f"**/admin/api/integrations/{integration}", hold_check) page.goto(f"{admin_base_url}/admin/integrations") button = page.locator(f"#{button_id}") for visit in range(2): if visit: page.get_by_role("button", name="Providers", exact=True).click() + page.evaluate("window.integrationCheckIntercepted = false") page.get_by_role("button", name="Integrations", exact=True).click() expect(button).to_be_disabled() expect(button).to_have_text("Loading…") @@ -42,6 +46,7 @@ def test_connection_check_uses_disabled_loading_button( == "integration-spinner" ) expect(page.locator("#view-integrations .status-pill")).to_have_count(0) + page.wait_for_function("window.integrationCheckIntercepted === true") assert len(pending) == 1 pending.pop().fulfill(json={"connected": connected, "paths": None}) expect(button).to_be_enabled() @@ -269,14 +274,20 @@ def test_save_pending_and_failure_stay_in_modal(page, admin_base_url): page.goto(f"{admin_base_url}/admin/integrations") page.locator("#openClaudeIntegration").click() requests = [] + + def hold_save(route): + requests.append(route) + page.evaluate("window.integrationSaveIntercepted = true") + page.route( "**/admin/api/integrations/claude-vscode/connect", - lambda route: requests.append(route), + hold_save, ) action = page.locator("#confirmClaudeIntegration") action.click() expect(action).to_be_disabled() expect(action).to_have_text("Saving…") + page.wait_for_function("window.integrationSaveIntercepted === true") requests[0].fulfill(status=503, json={"detail": "Could not save settings."}) expect(action).to_be_enabled() expect(page.locator("#claudeIntegrationDialog")).to_be_visible() @@ -371,8 +382,11 @@ def retry(route): expect(button).to_have_text("Retry") expect(button).to_be_enabled() expect(message).to_have_text(progress["message"]) - button.click() - expect(button).to_be_disabled() + with page.expect_response( + f"{admin_base_url}/admin/api/integrations/{integration}/refresh" + ): + button.click() + expect(button).to_be_disabled() assert retries == ["POST"] page.wait_for_function( "id => state.startup?.startup?.integrations[id]?.state === 'starting'", @@ -418,7 +432,11 @@ def admin_client_files(request, tmp_path): def test_startup_updates_before_opening_integrations( admin_client_files, admin_base_url, page, tmp_path ): - page.goto(f"{admin_base_url}/admin/integrations") + page.goto(f"{admin_base_url}/admin") + page.wait_for_function( + "state.startup?.startup?.integrations['claude-vscode']?.state === 'ready'" + ) + page.get_by_role("button", name="Integrations", exact=True).click() button = page.locator("#openClaudeIntegration") expect(button).to_have_text("Disconnect") expect(page.locator("#claudeIntegrationMessage")).to_be_hidden() diff --git a/e2e/test_admin_providers.py b/e2e/test_admin_providers.py index 84ff1ed6d1..0de403b0a1 100644 --- a/e2e/test_admin_providers.py +++ b/e2e/test_admin_providers.py @@ -166,15 +166,18 @@ def test_delayed_startup_status_does_not_replace_a_manual_provider_check( snapshot = page.request.get(f"{admin_base_url}/admin/api/status").json() startup: list[Route] = [] manual: list[Route] = [] + page.route("**/admin/api/status", lambda route: startup.append(route)) page.route( "**/admin/api/providers/open_router/test", lambda route: manual.append(route) ) - with page.expect_request("**/admin/api/status"): - page.evaluate("void refreshStartup()") + page.expose_function("startupRequestIntercepted", lambda: bool(startup)) + page.expose_function("manualRequestIntercepted", lambda: bool(manual)) + page.evaluate("void refreshStartup()") + page.wait_for_function("window.startupRequestIntercepted()") dialog = open_provider(page, "open_router") - with page.expect_request("**/admin/api/providers/open_router/test"): - dialog.get_by_role("button", name="Refresh models", exact=True).click() + dialog.get_by_role("button", name="Refresh models", exact=True).click() + page.wait_for_function("window.manualRequestIntercepted()") expected = "Checking..." if manual_result != "pending": manual.pop().fulfill( diff --git a/e2e/test_code_sessions.py b/e2e/test_code_sessions.py index 17f673c021..9c2d7e2de7 100644 --- a/e2e/test_code_sessions.py +++ b/e2e/test_code_sessions.py @@ -435,14 +435,11 @@ def test_pending_child_review_outside_page_survives_refresh_in_both_tabs( send(page, "Continue") code_control.run(code_control.harness.wait_inputs(2)) - async def more_output(): - for index in range(55): - await connection.text( - "turn-2", str(index), f"Output {index}", complete=True - ) - await connection.finish("turn-2") - - code_control.run(more_output()) + for index in range(55): + code_control.run( + connection.text("turn-2", str(index), f"Output {index}", complete=True) + ) + code_control.run(connection.finish("turn-2")) page.reload() second = context.new_page() try: @@ -514,14 +511,11 @@ def test_reobserved_child_review_outside_page_appears_without_refresh( code_control.run(code_control.harness.wait_inputs(2)) replacement = code_control.harness.connections[-1] - async def more_output(): - for index in range(55): - await replacement.text( - "turn-2", str(index), f"Output {index}", complete=True - ) - await replacement.finish("turn-2") - - code_control.run(more_output()) + for index in range(55): + code_control.run( + replacement.text("turn-2", str(index), f"Output {index}", complete=True) + ) + code_control.run(replacement.finish("turn-2")) page.reload() second = context.new_page() try: @@ -604,11 +598,13 @@ def test_settings_apply_preserves_open_creation_and_picker( ): pending = [] page.route("**/admin/api/config/apply", lambda route: pending.append(route)) + page.expose_function("applyRequestIntercepted", lambda: bool(pending)) page.goto(f"{admin_base_url}/admin") expect(page.locator("#messageArea")).to_have_text("") page.locator("#field-PORT").fill("8081") page.get_by_role("button", name="Apply", exact=True).click() expect(page.locator("#messageArea")).to_have_text("Applying…") + page.wait_for_function("window.applyRequestIntercepted()") assert len(pending) == 1 page.get_by_role("button", name="Code sessions", exact=True).click() page.get_by_role("button", name="New code session", exact=True).click() @@ -871,13 +867,20 @@ def test_existing_session_keeps_streaming_while_folder_picker_is_open( observer.close() +def pause_clock(page): + # Freeze wall time first so pausing cannot target the past on a busy runner. + page.clock.set_fixed_time(1000) + page.clock.pause_at(1000) + # Let Date.now advance with run_for once the clock is paused. + page.clock.set_system_time(1000) + + @pytest.mark.parametrize("terminal", ["completed", "failed", "interrupted"]) def test_thinking_fills_quiet_gaps_without_changing_transcript( page, admin_base_url, tmp_path, code_control, terminal ): create_session(page, admin_base_url, tmp_path) - page.clock.install(time=1000) - page.clock.pause_at(1000) + pause_clock(page) send(page, "Inspect this project") connection = code_control.connection() expect(page.locator("#codeStop")).to_be_visible() @@ -934,8 +937,7 @@ def test_recovered_output_restarts_thinking_quiet_interval( ): control_feed(page) create_session(page, admin_base_url, tmp_path) - page.clock.install(time=1000) - page.clock.pause_at(1000) + pause_clock(page) send(page, "Inspect") connection = code_control.connection() code_control.run( @@ -968,8 +970,7 @@ def test_thinking_respects_prompts_refresh_navigation_and_stop( ): control_feed(page) url = create_session(page, admin_base_url, tmp_path) - page.clock.install(time=1000) - page.clock.pause_at(1000) + pause_clock(page) send(page, "Inspect") connection = code_control.connection() code_control.run(connection.prompt(0)) @@ -1037,8 +1038,7 @@ def test_thinking_preserves_expanded_items_and_scroll_position( page, admin_base_url, tmp_path, code_control ): create_session(page, admin_base_url, tmp_path) - page.clock.install(time=1000) - page.clock.pause_at(1000) + pause_clock(page) send(page, "Inspect") connection = code_control.connection() code_control.run(connection.text("turn-1", "reason", "Reading", kind="reasoning")) @@ -1239,6 +1239,7 @@ def test_competing_tabs_keep_the_rejected_draft( expect(second.get_by_role("textbox", name="Message", exact=True)).to_have_value( "Second tab draft" ) + code_control.run(code_control.harness.wait_inputs(1)) assert ( sum( len(connection.inputs) @@ -1923,6 +1924,7 @@ def test_unavailable_feed_shows_reason_and_clears_it_on_recovery( page, admin_base_url, code_control ): async def availability(available): + await code_control.service.start() code_control.service._accepting = available code_control.service._message = ( None if available else "Code storage is unavailable" diff --git a/e2e/test_connected_accounts.py b/e2e/test_connected_accounts.py index 6df8396183..63cfc72a4f 100644 --- a/e2e/test_connected_accounts.py +++ b/e2e/test_connected_accounts.py @@ -343,6 +343,7 @@ def status(route: Route) -> None: data["cached_models"][provider_id] = ( ["model-a", "model-b"] if phase == "ready" else [] ) + data["cached_models"]["open_router"] = data["cached_models"][provider_id] route.fulfill(json=data) page.route("**/admin/api/status", status) @@ -351,6 +352,7 @@ def status(route: Route) -> None: other = page.locator('[data-provider-check-result="open_router"]') expect(card.get_by_role("button", name="Disconnect", exact=True)).to_be_enabled() expect(card.locator(".provider-meta")).to_have_text("Checking models…") + expect(other).to_have_text("Checking models…") expect(card.locator(".provider-meta")).to_have_css( "color", other.evaluate("element => getComputedStyle(element).color") ) @@ -359,11 +361,13 @@ def status(route: Route) -> None: proxy.fill("http://pending-proxy:8080") proxy.evaluate("input => input.setSelectionRange(7, 14)") phase = outcome - expect(card.locator(".provider-meta")).to_have_text( + expected_message = ( "2 models available" if outcome == "ready" else "Could not load models. Check the provider's settings and retry." ) + expect(card.locator(".provider-meta")).to_have_text(expected_message) + expect(other).to_have_text(expected_message) expect(card.locator(".provider-meta")).to_have_css( "color", other.evaluate("element => getComputedStyle(element).color") ) diff --git a/pyproject.toml b/pyproject.toml index d031b952b2..54b7896e78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "free-claude-code" -version = "6.2.62" +version = "6.2.63" description = "Local proxy connecting coding agents to OpenAI-compatible AI providers" readme = "README.md" license = "AGPL-3.0-only" @@ -90,6 +90,7 @@ dev = [ "ruff>=0.16.7", "pytest-xdist>=3.8.0", "pytest-playwright>=0.9.0", + "pyyaml>=6.0.3", ] [tool.ruff] @@ -126,7 +127,7 @@ skip-magic-trailing-comma = false [tool.pytest.ini_options] pythonpath = ["src"] -addopts = "-n auto" +addopts = "-n auto --dist=worksteal" filterwarnings = [ "error", # Starlette's test client uses this deprecated alias, including in 1.6.0. diff --git a/src/free_claude_code/api/admin_routes.py b/src/free_claude_code/api/admin_routes.py index 533abe6a07..e2f8f34beb 100644 --- a/src/free_claude_code/api/admin_routes.py +++ b/src/free_claude_code/api/admin_routes.py @@ -39,6 +39,12 @@ STATIC_DIR = Path(__file__).resolve().parent / "admin_static" PACKAGE_ASSETS_DIR = Path(__file__).resolve().parent.parent / "assets" _ADMIN_ASSET_VERSION_PLACEHOLDER = "__FCC_VERSION__" +_ADMIN_ASSET_MEDIA_TYPES = { + ".js": "text/javascript", + ".css": "text/css", + ".svg": "image/svg+xml", + ".png": "image/png", +} _ADMIN_ASSET_FILENAMES = frozenset( { "admin.css", @@ -87,7 +93,8 @@ def _asset_path(filename: str) -> Path: def _asset_response(filename: str) -> FileResponse: - return FileResponse(_asset_path(filename)) + path = _asset_path(filename) + return FileResponse(path, media_type=_ADMIN_ASSET_MEDIA_TYPES[path.suffix]) def admin_page_response() -> HTMLResponse: diff --git a/tests/api/test_admin.py b/tests/api/test_admin.py index fa42983eaa..f14212a7bd 100644 --- a/tests/api/test_admin.py +++ b/tests/api/test_admin.py @@ -1,4 +1,5 @@ import asyncio +import mimetypes from dataclasses import replace from pathlib import Path from unittest.mock import AsyncMock, patch @@ -212,6 +213,8 @@ def test_admin_page_uses_installed_version(monkeypatch, tmp_path): ("code_sessions.js", "text/javascript"), ("session_ui.js", "text/javascript"), ("model_combobox.js", "text/javascript"), + ("providers/openrouter.svg", "image/svg+xml"), + ("providers/lightning.png", "image/png"), ), ) def test_admin_versioned_assets_serve_packaged_files( @@ -220,6 +223,8 @@ def test_admin_versioned_assets_serve_packaged_files( filename, media_type, ): + mimetypes.init() + monkeypatch.setitem(mimetypes.types_map, Path(filename).suffix, "text/plain") asset_path = ( Path(__file__).resolve().parents[2] / "src" diff --git a/tests/api/test_app_lifespan_and_errors.py b/tests/api/test_app_lifespan_and_errors.py index fe450deda9..f19b7dcb87 100644 --- a/tests/api/test_app_lifespan_and_errors.py +++ b/tests/api/test_app_lifespan_and_errors.py @@ -49,7 +49,7 @@ async def test_runtime_startup_logs_admin_url_without_printed_server_banner(): runtime = ApplicationRuntime( manager, configuration=AsyncMock(spec=ConfigurationService), transcriber=None ) - uvicorn_logger = MagicMock() + uvicorn_logger = logging.getLogger("uvicorn.error") with ( patch("builtins.print") as printed, @@ -59,17 +59,16 @@ async def test_runtime_startup_logs_admin_url_without_printed_server_banner(): "free_claude_code.runtime.application.messaging_platform_factory.create_messaging_components", return_value=None, ), - patch.object(logging, "getLogger", return_value=uvicorn_logger) as get_logger, + patch.object(uvicorn_logger, "info") as log_info, ): await runtime.start() - uvicorn_logger.info.assert_not_called() + log_info.assert_not_called() runtime.http_started() await runtime.close() printed.assert_not_called() start_refresh.assert_called_once() - get_logger.assert_any_call("uvicorn.error") - uvicorn_logger.info.assert_called_once_with( + log_info.assert_called_once_with( "Admin UI: %s (local-only)", "http://127.0.0.1:9099/admin", ) diff --git a/tests/config/test_logging_config.py b/tests/config/test_logging_config.py index 660bc04fae..8ea8729cbb 100644 --- a/tests/config/test_logging_config.py +++ b/tests/config/test_logging_config.py @@ -11,6 +11,16 @@ from free_claude_code.config.logging_config import configure_logging +def test_log_capture_does_not_feed_back_into_interception(caplog, capsys, monkeypatch): + monkeypatch.setattr(logging.root, "handlers", [logging_config.InterceptHandler()]) + with caplog.at_level(logging.WARNING): + logger.info("below capture level") + logger.warning("capture this once") + + assert [record.getMessage() for record in caplog.records] == ["capture this once"] + assert "Logging error" not in capsys.readouterr().err + + def test_configure_logging_creates_parent_directories(tmp_path) -> None: """Nested log path: parent directories are created before truncating.""" log_file = tmp_path / "nested" / "dir" / "app.log" diff --git a/tests/conftest.py b/tests/conftest.py index 24dd9bb049..0d4c88ae24 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ import asyncio import contextlib -import logging import os from typing import Any from unittest.mock import AsyncMock, MagicMock @@ -260,19 +259,15 @@ def _create(**kwargs): @pytest.fixture(autouse=True) -def _propagate_loguru_to_caplog(): - """Route loguru logs to stdlib logging so pytest caplog captures them.""" +def _propagate_loguru_to_caplog(caplog): + """Capture Loguru directly without re-entering the stdlib interceptor.""" from loguru import logger as loguru_logger - class _PropagateHandler: - def write(self, message): - record = message.record - level = record["level"].no - stdlib_level = min(level, logging.CRITICAL) - py_logger = logging.getLogger(record["name"]) - py_logger.log(stdlib_level, record["message"]) - - handler_id = loguru_logger.add(_PropagateHandler(), format="{message}") + handler_id = loguru_logger.add( + caplog.handler, + format="{message}", + filter=lambda record: record["level"].no >= caplog.handler.level, + ) yield with contextlib.suppress(ValueError): loguru_logger.remove( diff --git a/tests/contracts/test_uv_policy.py b/tests/contracts/test_uv_policy.py index bda685717f..7559ad3935 100644 --- a/tests/contracts/test_uv_policy.py +++ b/tests/contracts/test_uv_policy.py @@ -1,11 +1,51 @@ import tomllib from pathlib import Path +import pytest +import yaml + UV_MINIMUM = "0.12.13" +CI_SETUP = Path(".github/actions/ci-environment/action.yml") UV_WORKFLOWS = ( Path(".github/workflows/tests.yml"), Path(".github/workflows/dependency-cache.yml"), ) +REQUIRED_TEST_RUNNERS = { + "Linux": "ubuntu-latest", + "Windows": "windows-latest", + "macOS": "macos-latest", +} + + +def _assert_required_test_matrix(workflow: dict, suite: str) -> None: + job = workflow["jobs"][suite] + assert job["name"] == f"{suite} (${{{{ matrix.os }}}})" + assert job["runs-on"] == "${{ matrix.runner }}" + matrix = job["strategy"]["matrix"] + assert not matrix.get("exclude") + entries = matrix["include"] + assert len(entries) == len(REQUIRED_TEST_RUNNERS) + assert {(entry["os"], entry["runner"]) for entry in entries} == set( + REQUIRED_TEST_RUNNERS.items() + ) + + +@pytest.mark.parametrize("suite", ("pytest", "playwright")) +def test_required_test_matrix_covers_supported_platforms(suite: str) -> None: + workflow = yaml.safe_load(UV_WORKFLOWS[0].read_text(encoding="utf-8")) + _assert_required_test_matrix(workflow, suite) + + +@pytest.mark.parametrize("suite", ("pytest", "playwright")) +@pytest.mark.parametrize("os_name", REQUIRED_TEST_RUNNERS) +def test_required_test_matrix_rejects_missing_platform( + suite: str, os_name: str +) -> None: + workflow = yaml.safe_load(UV_WORKFLOWS[0].read_text(encoding="utf-8")) + entries = workflow["jobs"][suite]["strategy"]["matrix"]["include"] + entries[:] = [entry for entry in entries if entry["os"] != os_name] + with pytest.raises(AssertionError): + _assert_required_test_matrix(workflow, suite) def test_supported_uv_minimum_is_consistent() -> None: @@ -19,7 +59,7 @@ def test_supported_uv_minimum_is_consistent() -> None: for workflow_path in UV_WORKFLOWS: workflow = workflow_path.read_text(encoding="utf-8") assert f'CI_UV_VERSION: "{UV_MINIMUM}"' in workflow - assert "version: ${{ env.CI_UV_VERSION }}" in workflow + assert "uv-version: ${{ env.CI_UV_VERSION }}" in workflow def test_every_uv_workflow_inherits_malware_check() -> None: @@ -36,16 +76,21 @@ def test_only_trusted_main_workflow_writes_caches() -> None: main_workflow = UV_WORKFLOWS[1].read_text(encoding="utf-8") assert "actions/cache/save@" not in pull_request_workflow + assert "actions/cache/save@" not in CI_SETUP.read_text(encoding="utf-8") assert main_workflow.count("actions/cache/save@") == 2 + assert "if: github.ref == 'refs/heads/main'" in main_workflow def test_uv_workflows_share_managed_python_cache_policy() -> None: - python_policy = ( - " UV_PYTHON_INSTALL_DIR: /tmp/uv-python\n" - " UV_PYTHON_PREFERENCE: only-managed\n" - ) - + setup = CI_SETUP.read_text(encoding="utf-8") + assert "UV_PYTHON_PREFERENCE=only-managed" in setup + assert "runner.temp" in setup + assert "sha256sum" not in setup + assert "--no-emit-workspace" in setup for workflow_path in UV_WORKFLOWS: workflow = workflow_path.read_text(encoding="utf-8") - assert workflow.count(python_policy) == 1 + assert "uses: ./.github/actions/ci-environment" in workflow assert "actions/setup-python@" not in workflow + assert "/tmp/" not in workflow + assert "uv-deps-v" not in workflow + assert "uv-python-v" not in workflow diff --git a/tests/installers/AGENTS.md b/tests/installers/AGENTS.md new file mode 100644 index 0000000000..55bf8e7f76 --- /dev/null +++ b/tests/installers/AGENTS.md @@ -0,0 +1,28 @@ +# Installer test rules + +These rules apply to installer, uninstaller, and harness lifecycle tests here. + +- Test behavior through the real script functions. Use focused function scenarios + for command discovery, version validation, process checks, and component errors. + Do not run the entire installer for every variation of a component rule. +- Keep full workflows for fresh installation, upgrades, cross-harness interactions, + and abort ordering between installation phases. Preserve those assertions when + making tests faster. Prepare unrelated prerequisites as already installed. +- Keep coverage for both Windows PowerShell and PowerShell 7, plus the applicable + POSIX paths. Do not skip coverage or move it out of CI to hide slow tests. +- Stub downloads and external installers. Never install real harnesses, use live + providers, or modify the user's installed tools and configuration in these tests. +- Give every scenario private files, configuration, PATH, and cache storage. Use + the shared `powershell_module_paths` fixture for Windows subprocesses, including + child installers. Inherited host modules can make missing-command discovery scan + or load unrelated software. `-NoProfile` alone does not isolate module discovery. +- Preserve the isolated module-analysis cache path from `conftest.py`. Test that + cache writes occur inside the temporary directory, not merely that nothing failed. +- Preserve process isolation between scenarios. Do not share mutable shell state + or user configuration to save startup time. Clean up processes you launch. +- Diagnose slowness with per-test durations and timings inside subprocesses. Do not + increase timeouts or add sleeps without identifying the operation being delayed. +- Use `scripts/ci.ps1` for local verification. `PYTEST_ADDOPTS` can select focused + cases and enable duration reporting. Run the full relevant suite before pushing. +- Reuse pytest's capture handler for logs. Do not create a logging cycle between + Python logging and Loguru while adding diagnostics. diff --git a/tests/scripts/_pty_runner.py b/tests/installers/_pty_runner.py similarity index 100% rename from tests/scripts/_pty_runner.py rename to tests/installers/_pty_runner.py diff --git a/tests/installers/conftest.py b/tests/installers/conftest.py new file mode 100644 index 0000000000..52655a13a9 --- /dev/null +++ b/tests/installers/conftest.py @@ -0,0 +1,51 @@ +"""Isolate caches created by installer and lifecycle test subprocesses.""" + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + + +@pytest.fixture(autouse=True) +def _isolate_powershell_module_cache( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + # A redirected USERPROFILE can make Windows PowerShell's default cache path + # relative to the checkout, even when LOCALAPPDATA points at a temporary dir. + monkeypatch.setenv( + "PSModuleAnalysisCachePath", str(tmp_path / "powershell-module-cache") + ) + + +@pytest.fixture(scope="session") +def powershell_module_paths(tmp_path_factory): + if os.name != "nt": + return {} + root = tmp_path_factory.mktemp("powershell-modules") + paths = {} + for shell in dict.fromkeys( + path for name in ("pwsh", "powershell") if (path := shutil.which(name)) + ): + path = root / Path(shell).stem + # A private path avoids Windows PowerShell adding machine-wide module + # directories back when it recognizes its default system module path. + # Junctions keep the bundled modules intact without copying their DLLs. + subprocess.run( + [ + shell, + "-NoProfile", + "-NonInteractive", + "-Command", + "New-Item -ItemType Junction -Path $env:FCC_TEST_MODULE_PATH " + "-Target (Join-Path $PSHOME 'Modules') | Out-Null", + ], + env=os.environ | {"FCC_TEST_MODULE_PATH": str(path)}, + check=True, + capture_output=True, + text=True, + timeout=30, + ) + paths[shell] = str(path) + return paths diff --git a/tests/scripts/fixtures/.gitattributes b/tests/installers/fixtures/.gitattributes similarity index 100% rename from tests/scripts/fixtures/.gitattributes rename to tests/installers/fixtures/.gitattributes diff --git a/tests/scripts/fixtures/README.md b/tests/installers/fixtures/README.md similarity index 100% rename from tests/scripts/fixtures/README.md rename to tests/installers/fixtures/README.md diff --git a/tests/scripts/fixtures/rtk-LICENSE b/tests/installers/fixtures/rtk-LICENSE similarity index 100% rename from tests/scripts/fixtures/rtk-LICENSE rename to tests/installers/fixtures/rtk-LICENSE diff --git a/tests/scripts/fixtures/rtk-opencode-v1.ts b/tests/installers/fixtures/rtk-opencode-v1.ts similarity index 100% rename from tests/scripts/fixtures/rtk-opencode-v1.ts rename to tests/installers/fixtures/rtk-opencode-v1.ts diff --git a/tests/scripts/test_installers.py b/tests/installers/test_installers.py similarity index 93% rename from tests/scripts/test_installers.py rename to tests/installers/test_installers.py index 55dec643ac..d899582c8a 100644 --- a/tests/scripts/test_installers.py +++ b/tests/installers/test_installers.py @@ -283,7 +283,7 @@ def use_process_list_fallback(self, process_line: str) -> None: awk = shutil.which("awk", path=self.env["PATH"]) if awk is None: pytest.skip("awk is required for the POSIX process fallback scenario") - shutil.copy2(awk, fallback_bin / "awk") + shutil.copy(awk, fallback_bin / "awk") self.env["FCC_PS_OUTPUT"] = process_line self.env["PATH"] = str(fallback_bin) @@ -2155,6 +2155,11 @@ class PowerShellHarness: def add_client(self, name: str) -> None: _write_executable(self.bin_dir / f"{name}.cmd", _batch_client(name)) + def add_installed_clients(self, *, except_for: tuple[str, ...] = ()) -> None: + for name in CODING_AGENTS: + if name not in except_for: + self.add_client(name) + def add_unrelated_pi(self) -> None: _write_executable(self.bin_dir / "pi.cmd", _batch_client("unrelated-pi")) @@ -2200,6 +2205,20 @@ def run(self, *args: str, fail_step: str = "") -> subprocess.CompletedProcess[st env=env, ) + def run_functions( + self, body: str, *args: str, fail_step: str = "" + ) -> subprocess.CompletedProcess[str]: + """Exercise real installer functions without running the full workflow.""" + scenario = self.root / "scenario.ps1" + scenario.write_text( + body + '\nWrite-Output "Installer scenario completed."\n', encoding="utf-8" + ) + self.env["FCC_INSTALLER_SCENARIO"] = str(scenario) + try: + return self.run(*args, fail_step=fail_step) + finally: + self.env["FCC_INSTALLER_SCENARIO"] = "" + def run_interactive( self, answers: list[str], *args: str, fail_step: str = "" ) -> subprocess.CompletedProcess[str]: @@ -2219,6 +2238,7 @@ def calls(self) -> list[str]: def powershell_harness( tmp_path: Path, request: pytest.FixtureRequest, + powershell_module_paths, ) -> PowerShellHarness: powershell = request.param if powershell is None or os.name != "nt": @@ -2541,6 +2561,20 @@ def powershell_harness( } '@ $installerSource = $installerSource.Replace($nativeVersionProbe, $fakeArchiveVersionProbe.TrimEnd()) +if ($env:FCC_INSTALLER_SCENARIO) { + $tokens = $null + $parseErrors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + $installerSource, [ref] $tokens, [ref] $parseErrors + ) + if ($parseErrors.Count) { throw "Invalid installer source: $parseErrors" } + $definitions = @($ast.EndBlock.Statements | Where-Object { + $_ -is [System.Management.Automation.Language.FunctionDefinitionAst] + }) + if (-not $definitions.Count) { throw "Installer function definitions missing" } + $installerSource = $installerSource.Substring(0, $definitions[-1].Extent.EndOffset) + + [Environment]::NewLine + [IO.File]::ReadAllText($env:FCC_INSTALLER_SCENARIO) +} $installer = [scriptblock]::Create($installerSource) & $installer @args """, @@ -2555,6 +2589,7 @@ def powershell_harness( [str(bin_dir), str(Path(system_root) / "System32"), system_root] ), "PATHEXT": ".COM;.EXE;.BAT;.CMD", + "PSMODULEPATH": powershell_module_paths[powershell], "USERPROFILE": str(home), "LOCALAPPDATA": str(local_app_data), "APPDATA": str(app_data), @@ -2573,6 +2608,7 @@ def powershell_harness( "FAKE_OPENCODE_RELEASE": "", "FAKE_OPENCODE_ARCHIVE_VERSION": "", "FCC_INSTALLER_ANSWERS": "", + "FCC_INSTALLER_SCENARIO": "", "FCC_TEST_USER_PATH": "", } ) @@ -2588,6 +2624,40 @@ def powershell_harness( ) +class TestPowerShellModuleIsolation: + @pytest.fixture(autouse=True) + def unrelated_host_module(self, tmp_path, monkeypatch): + modules = tmp_path / "host-modules" + module = modules / "FccUnrelated" + module.mkdir(parents=True) + (module / "FccUnrelated.psd1").write_text( + "@{RootModule='FccUnrelated.psm1'; ModuleVersion='1.0.0'; " + "FunctionsToExport=@('Get-FccUnrelatedProbe')}", + encoding="utf-8", + ) + (module / "FccUnrelated.psm1").write_text( + "Add-Content -LiteralPath $env:CALL_LOG -Value 'unrelated-module-import'\n" + "function Get-FccUnrelatedProbe { 'unrelated' }\n" + "Export-ModuleMember -Function Get-FccUnrelatedProbe\n", + encoding="utf-8", + ) + monkeypatch.setenv( + "PSMODULEPATH", + str(modules) + os.pathsep + os.environ.get("PSMODULEPATH", ""), + ) + + def test_missing_commands_do_not_load_host_modules(self, powershell_harness): + result = powershell_harness.run_functions( + """$null = Get-ApplicationCommand 'FccUnrelatedProbe' +$child = Get-PowerShellExecutable +& $child -NoProfile -Command 'Get-Command FccUnrelatedProbe -CommandType Application -ErrorAction SilentlyContinue; Get-Item -LiteralPath $env:FAKE_FIXTURES | Out-Null; exit 0' +if ($LASTEXITCODE -ne 0) { throw 'Child command lookup failed' } +""" + ) + assert result.returncode == 0, result.stderr + assert "unrelated-module-import" not in powershell_harness.calls() + + @pytest.mark.parametrize("rtk", (False, True, None)) def test_install_ps1_auto_selects_installed_harnesses( powershell_harness: PowerShellHarness, @@ -2867,13 +2937,8 @@ def _assert_lookup_is_isolated( if present: _write_executable(destination / f"{command}{suffix}", make_command(command)) - source = ( - _repo_root() / "scripts" / f"install.{'ps1' if windows else 'sh'}" - ).read_text(encoding="utf-8") - if windows: - source = ( - source.split("\nif ($Help) {", 1)[0] - + """ + if isinstance(harness, PowerShellHarness): + result = harness.run_functions(""" $script:OriginalOpenCode = $null Add-KnownBinDirectories $pathBefore = $env:Path @@ -2884,13 +2949,9 @@ def _assert_lookup_is_isolated( throw "Lookup did not return an application" } Write-Output "lookup isolated" -""" - ) - installer = harness.root / "query.ps1" - installer.write_text(source, encoding="utf-8") - harness.env["FCC_INSTALLER"] = str(installer) - result = harness.run() +""") else: + source = (_repo_root() / "scripts/install.sh").read_text(encoding="utf-8") source = ( source.split('\nparse_args "$@"\n', 1)[0] + """ @@ -2947,10 +3008,7 @@ def test_install_ps1_lookup_is_isolated( def test_install_ps1_lookup_restores_path_after_exception( powershell_harness: PowerShellHarness, ) -> None: - source = (_repo_root() / "scripts/install.ps1").read_text(encoding="utf-8") - source = ( - source.split("\nif ($Help) {", 1)[0] - + """ + result = powershell_harness.run_functions(""" $script:OriginalOpenCode = $null $env:UV_TOOL_BIN_DIR = Join-Path $env:USERPROFILE "exception lookup" $pathBefore = $env:Path @@ -2970,14 +3028,7 @@ def test_install_ps1_lookup_restores_path_after_exception( if (-not $caught) { throw "Expected lookup exception" } if ($env:Path -cne $pathBefore) { throw "Failed lookup changed PATH" } Write-Output "exception isolated" -""" - ) - installer = powershell_harness.root / "exception.ps1" - installer.write_text(source, encoding="utf-8") - powershell_harness.env["FCC_INSTALLER"] = str(installer) - - result = powershell_harness.run() - +""") assert result.returncode == 0, result.stdout + result.stderr assert "exception isolated" in result.stdout assert powershell_harness.calls() == [] @@ -3178,10 +3229,13 @@ def test_install_ps1_fresh_install_is_verified( def test_install_ps1_discovers_grok_in_custom_bin_directory( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") custom_grok_bin = powershell_harness.root / "custom-grok-bin" powershell_harness.env["GROK_BIN_DIR"] = str(custom_grok_bin) - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Grok" + ) assert result.returncode == 0, result.stderr assert (custom_grok_bin / "grok.cmd").is_file() @@ -3209,12 +3263,15 @@ def test_install_ps1_preserves_upstream_managed_harness_without_parsing_version( client: str, install_call: str, ) -> None: + powershell_harness.add_uv("0.12.13") _write_executable( powershell_harness.bin_dir / f"{client}.cmd", _batch_client(client, version_output="opaque upstream version output"), ) - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-" + client + ) assert result.returncode == 0, result.stderr calls = powershell_harness.calls() @@ -3225,9 +3282,12 @@ def test_install_ps1_preserves_upstream_managed_harness_without_parsing_version( def test_install_ps1_delegates_compatible_external_muse_without_adopting_it( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.add_client("muse") - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Muse" + ) assert result.returncode == 0, result.stderr calls = powershell_harness.calls() @@ -3244,10 +3304,13 @@ def test_install_ps1_stops_when_muse_install_fails( powershell_harness: PowerShellHarness, failure: str, ) -> None: - result = powershell_harness.run(fail_step=failure) + powershell_harness.add_uv("0.12.13") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Muse", fail_step=failure + ) assert result.returncode != 0 - assert "Free Claude Code is installed and verified." not in result.stdout + assert "Installer scenario completed." not in result.stdout _assert_uv_ready_without_fcc_install(powershell_harness.calls()) @@ -3256,20 +3319,26 @@ def test_install_ps1_stops_when_grok_install_fails( powershell_harness: PowerShellHarness, failure: str, ) -> None: - result = powershell_harness.run(fail_step=failure) + powershell_harness.add_uv("0.12.13") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Grok", fail_step=failure + ) assert result.returncode != 0 - assert "Free Claude Code is installed and verified." not in result.stdout + assert "Installer scenario completed." not in result.stdout _assert_uv_ready_without_fcc_install(powershell_harness.calls()) def test_install_ps1_stops_when_aider_install_fails( powershell_harness: PowerShellHarness, ) -> None: - result = powershell_harness.run(fail_step="aider-install") + powershell_harness.add_uv("0.12.13") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Aider", fail_step="aider-install" + ) assert result.returncode != 0 - assert "Free Claude Code is installed and verified." not in result.stdout + assert "Installer scenario completed." not in result.stdout calls = powershell_harness.calls() aider_install = ( "uv:tool install --force --python python3.12 --with pip aider-chat@latest" @@ -3285,7 +3354,9 @@ def test_install_ps1_discovers_aider_in_custom_uv_tool_bin( custom_tool_bin = powershell_harness.root / "custom-tool-bin" powershell_harness.env["UV_TOOL_BIN_DIR"] = str(custom_tool_bin) - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Aider" + ) assert result.returncode == 0, result.stderr assert (custom_tool_bin / "aider.cmd").is_file() @@ -3314,7 +3385,9 @@ def test_install_ps1_checks_existing_aider_in_custom_uv_tool_bin_before_installi result = ( powershell_harness.run_interactive(["n"] * 9, fail_step=fail_step) if interactive - else powershell_harness.run(fail_step=fail_step) + else powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Aider", fail_step=fail_step + ) ) if fail_step: @@ -3334,9 +3407,12 @@ def test_install_ps1_checks_existing_aider_in_custom_uv_tool_bin_before_installi def test_install_ps1_rejects_broken_existing_aider_without_replacing_it( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.add_client("aider") - result = powershell_harness.run(fail_step="aider-verify") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Aider", fail_step="aider-verify" + ) assert result.returncode != 0 calls = powershell_harness.calls() @@ -3348,9 +3424,12 @@ def test_install_ps1_rejects_broken_existing_aider_without_replacing_it( def test_install_ps1_preserves_exact_dsh_preview( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.add_client("dsh") - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Dsh" + ) assert result.returncode == 0, result.stderr assert "already matches the supported preview" in result.stdout @@ -3364,6 +3443,7 @@ def test_install_ps1_replaces_mismatched_dsh_preview( powershell_harness: PowerShellHarness, interactive: bool, ) -> None: + powershell_harness.add_uv("0.12.13") (powershell_harness.bin_dir / "dsh.cmd").write_text( _batch_client("dsh").replace("0.1.0-rc.8", "0.1.0-rc.7"), encoding="utf-8", @@ -3372,7 +3452,9 @@ def test_install_ps1_replaces_mismatched_dsh_preview( result = ( powershell_harness.run_interactive(["n"] * 9) if interactive - else powershell_harness.run() + else powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Dsh" + ) ) assert result.returncode == 0, result.stdout + result.stderr @@ -3383,13 +3465,16 @@ def test_install_ps1_replaces_mismatched_dsh_preview( def test_install_ps1_rejects_exact_dsh_on_unsupported_node( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.add_client("dsh") (powershell_harness.bin_dir / "node.cmd").write_text( _batch_client("node").replace("node 22.19.0", "node 23.9.0"), encoding="utf-8", ) - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Dsh" + ) assert result.returncode != 0 assert "requires Node.js ^22.19.0 or >=24.0.0" in ( @@ -3403,6 +3488,7 @@ def test_install_ps1_rejects_incompatible_node_for_selected_dsh( powershell_harness: PowerShellHarness, node_version: str, ) -> None: + powershell_harness.add_uv("0.12.13") (powershell_harness.bin_dir / "dsh.cmd").write_text( _batch_client("dsh").replace("0.1.0-rc.8", "0.1.0-rc.7"), encoding="utf-8", @@ -3412,16 +3498,20 @@ def test_install_ps1_rejects_incompatible_node_for_selected_dsh( encoding="utf-8", ) - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Dsh" + ) assert result.returncode != 0 - assert "Free Claude Code is installed and verified." not in result.stdout + assert "Installer scenario completed." not in result.stdout _assert_uv_ready_without_fcc_install(powershell_harness.calls()) def test_install_ps1_noninteractive_skips_dsh_without_node( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_installed_clients(except_for=("dsh",)) + powershell_harness.add_uv("0.12.13") (powershell_harness.bin_dir / "node.cmd").unlink() (powershell_harness.bin_dir / "npm.cmd").unlink() @@ -3437,20 +3527,26 @@ def test_install_ps1_noninteractive_skips_dsh_without_node( def test_install_ps1_stops_when_selected_dsh_install_fails( powershell_harness: PowerShellHarness, ) -> None: - result = powershell_harness.run(fail_step="dsh-install") + powershell_harness.add_uv("0.12.13") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Dsh", fail_step="dsh-install" + ) assert result.returncode != 0 - assert "Free Claude Code is installed and verified." not in result.stdout + assert "Installer scenario completed." not in result.stdout _assert_uv_ready_without_fcc_install(powershell_harness.calls()) def test_install_ps1_rejects_unsupported_hermes_architecture_before_download( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.env["PROCESSOR_ARCHITECTURE"] = "MIPS" powershell_harness.env["PROCESSOR_ARCHITEW6432"] = "MIPS" - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Hermes" + ) assert result.returncode != 0 assert "does not provide a supported Windows release" in result.stderr @@ -3465,7 +3561,9 @@ def test_install_ps1_selects_official_opencode_arm64_archive( (powershell_harness.bin_dir / "opencode.cmd").unlink() powershell_harness.env["PROCESSOR_ARCHITEW6432"] = "ARM64" - result = powershell_harness.run() + result = powershell_harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) assert result.returncode == 0, result.stderr assert any( @@ -3480,7 +3578,9 @@ def test_install_ps1_rejects_unsupported_opencode_architecture( (powershell_harness.bin_dir / "opencode.cmd").unlink() powershell_harness.env["PROCESSOR_ARCHITEW6432"] = "X86" - result = powershell_harness.run() + result = powershell_harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) assert result.returncode != 0 assert "does not provide a supported Windows release" in result.stderr @@ -3513,11 +3613,14 @@ def test_install_ps1_preserves_existing_rtk_and_configures_selected_agents( def test_install_ps1_prepares_custom_claude_config_directory_for_rtk( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.add_rtk() custom_config = powershell_harness.root / "custom-claude" powershell_harness.env["CLAUDE_CONFIG_DIR"] = str(custom_config) - result = powershell_harness.run("-Rtk") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-RtkClaudeConfigDirectory", "-Rtk" + ) assert result.returncode == 0, result.stderr assert custom_config.is_dir() @@ -3527,9 +3630,12 @@ def test_install_ps1_prepares_custom_claude_config_directory_for_rtk( def test_install_ps1_rejects_conflicting_rtk_command( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.add_unrelated_rtk() - result = powershell_harness.run("-Rtk") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Rtk", "-Rtk" + ) assert result.returncode != 0 assert "not a compatible Rust Token Killer installation" in result.stderr @@ -3550,20 +3656,12 @@ def test_install_ps1_rtk_dry_run_prints_install_and_agent_setup( assert "RTK_TELEMETRY_DISABLED=1 rtk init --global --agent pi" in result.stdout -@pytest.mark.parametrize( - "powershell", - _powershells() or (None,), - ids=lambda path: Path(path).name if path is not None else "unavailable", -) @pytest.mark.parametrize("valid_checksum", [True, False]) def test_install_ps1_installs_only_checksum_verified_rtk_archive( - powershell: str | None, - tmp_path: Path, + powershell_harness: PowerShellHarness, valid_checksum: bool, ) -> None: - if powershell is None or os.name != "nt": - pytest.skip("PowerShell RTK archive installation runs on Windows hosts") - + tmp_path = powershell_harness.root asset_name = "rtk-x86_64-pc-windows-msvc.zip" archive_path = tmp_path / asset_name with zipfile.ZipFile(archive_path, "w") as archive: @@ -3572,37 +3670,22 @@ def test_install_ps1_installs_only_checksum_verified_rtk_archive( if not valid_checksum: checksum = "0" * 64 - installer = (_repo_root() / "scripts" / "install.ps1").read_text(encoding="utf-8") - format_argument = _braced_body(installer, "function Format-Argument") - install_rtk = _braced_body(installer, "function Install-Rtk") script = f"""Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" $DryRun = $false $RtkReleaseBaseUrl = "https://example.test/releases/download/v0.44.2" $RtkWindowsAssetName = "{asset_name}" $RtkWindowsAssetSha256 = "{checksum}" -function Format-Argument {{{format_argument}}} function Invoke-RestMethod {{ [CmdletBinding()] param([string] $Uri, [string] $OutFile) Copy-Item -LiteralPath $env:RTK_TEST_ARCHIVE -Destination $OutFile }} -function Install-Rtk {{{install_rtk}}} Install-Rtk """ - home = tmp_path / "home" - home.mkdir() - env = os.environ | { - "USERPROFILE": str(home), - "RTK_TEST_ARCHIVE": str(archive_path), - } - result = subprocess.run( - [powershell, "-NoProfile", "-Command", script], - check=False, - capture_output=True, - text=True, - env=env, - ) + home = Path(powershell_harness.env["USERPROFILE"]) + powershell_harness.env["RTK_TEST_ARCHIVE"] = str(archive_path) + result = powershell_harness.run_functions(script) installed = home / ".local" / "bin" / "rtk.exe" if valid_checksum: @@ -3617,6 +3700,8 @@ def test_install_ps1_installs_only_checksum_verified_rtk_archive( def test_install_ps1_stops_if_windows_icon_export_fails( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_installed_clients() + powershell_harness.add_uv("0.12.13") result = powershell_harness.run(fail_step="desktop-icon-export") assert result.returncode != 0 @@ -3628,6 +3713,7 @@ def test_install_ps1_stops_if_windows_icon_export_fails( def test_install_ps1_preserves_unowned_desktop_shortcut( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") desktop_shortcut = ( Path(powershell_harness.env["USERPROFILE"]) / "Desktop" / "Free Claude Code.lnk" ) @@ -3640,7 +3726,9 @@ def test_install_ps1_preserves_unowned_desktop_shortcut( ) original_shortcut = desktop_shortcut.read_bytes() - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nInstall-FreeClaudeCode\nConfigure-AndConfirmFreeClaudeCode" + ) assert result.returncode == 0, result.stderr assert "not managed by Free Claude Code" in result.stdout @@ -3689,7 +3777,9 @@ def test_install_ps1_replaces_unrelated_pi_command( result = ( powershell_harness.run_interactive(["y"] + ["n"] * 7) if interactive - else powershell_harness.run() + else powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Pi" + ) ) assert result.returncode == 0, result.stdout + result.stderr @@ -3706,7 +3796,9 @@ def test_install_ps1_discovers_custom_pi_npm_prefix( powershell_harness.add_npm_prefix(powershell_harness.root / "custom-npm") powershell_harness.add_uv("0.12.13") - result = powershell_harness.run() + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-Pi" + ) assert result.returncode == 0, result.stderr calls = powershell_harness.calls() @@ -3718,6 +3810,7 @@ def test_install_ps1_discovers_custom_pi_npm_prefix( def test_install_ps1_continues_when_pi_is_not_installed( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_installed_clients(except_for=("pi",)) result = powershell_harness.run(fail_step="pi-skip") assert result.returncode == 0, result.stderr @@ -3733,6 +3826,7 @@ def test_install_ps1_continues_when_pi_is_not_installed( def test_install_ps1_continues_when_unrelated_pi_is_unchanged( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_installed_clients(except_for=("pi",)) powershell_harness.add_unrelated_pi() result = powershell_harness.run(fail_step="pi-skip") @@ -3749,6 +3843,7 @@ def test_install_ps1_continues_when_unrelated_pi_is_unchanged( def test_install_ps1_continues_when_pi_resolution_changes_to_unrelated_command( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_installed_clients(except_for=("pi",)) powershell_harness.add_unrelated_pi() npm_prefix = powershell_harness.root / "custom-npm" powershell_harness.add_npm_prefix(npm_prefix) @@ -3778,10 +3873,15 @@ def test_install_ps1_replaces_obsolete_uv( powershell_harness.add_client("pi") powershell_harness.add_uv(uv_version) - result = powershell_harness.run() + result = powershell_harness.run_functions("Add-KnownBinDirectories\nEnsure-Uv") assert result.returncode == 0, result.stderr - assert "uv-install" in powershell_harness.calls() + assert powershell_harness.calls() == [ + "uv:--version", + "download:https://astral.sh/uv/install.ps1", + "uv-install", + "uv:--version", + ] assert f"uv {uv_version} does not satisfy stable >=0.12.13" in result.stdout @@ -3792,7 +3892,7 @@ def test_install_ps1_prioritizes_replacement_uv_from_custom_install_directory( powershell_harness.env["UV_INSTALL_DIR"] = str(custom_install_dir) powershell_harness.add_uv("0.5.9") - result = powershell_harness.run() + result = powershell_harness.run_functions("Add-KnownBinDirectories\nEnsure-Uv") assert result.returncode == 0, result.stderr assert "Verified uv 0.12.13." in result.stdout @@ -3813,7 +3913,7 @@ def test_install_ps1_prioritizes_forced_cargo_home_uv_install_layout( ) powershell_harness.add_uv("0.5.9") - result = powershell_harness.run() + result = powershell_harness.run_functions("Add-KnownBinDirectories\nEnsure-Uv") assert result.returncode == 0, result.stderr assert "Verified uv 0.12.13." in result.stdout @@ -3829,7 +3929,7 @@ def test_install_ps1_uv_install_dir_takes_precedence_over_unmanaged_install( powershell_harness.env["UV_UNMANAGED_INSTALL"] = str(unmanaged_bin) powershell_harness.add_uv("0.5.9") - result = powershell_harness.run() + result = powershell_harness.run_functions("Add-KnownBinDirectories\nEnsure-Uv") assert result.returncode == 0, result.stderr assert (install_bin / "uv.cmd").is_file() @@ -3843,7 +3943,7 @@ def test_install_ps1_prioritizes_replacement_uv_from_unmanaged_install_directory powershell_harness.env["UV_UNMANAGED_INSTALL"] = str(unmanaged_bin) powershell_harness.add_uv("0.5.9") - result = powershell_harness.run() + result = powershell_harness.run_functions("Add-KnownBinDirectories\nEnsure-Uv") assert result.returncode == 0, result.stderr assert "Verified uv 0.12.13." in result.stdout @@ -3860,7 +3960,7 @@ def test_install_ps1_replaces_prerelease_uv( powershell_harness.add_client("pi") powershell_harness.add_uv(version) - result = powershell_harness.run() + result = powershell_harness.run_functions("Add-KnownBinDirectories\nEnsure-Uv") assert result.returncode == 0, result.stderr assert f"uv {version} does not satisfy stable >=0.12.13" in result.stdout @@ -3897,9 +3997,36 @@ def test_install_ps1_stops_without_success_on_each_failure( powershell_harness: PowerShellHarness, failure: str, ) -> None: + if failure in {"fcc-install", "path-update", "fcc-missing", "fcc-verify"}: + powershell_harness.add_installed_clients() + powershell_harness.add_uv("0.12.13") if failure in {"opencode-download", "opencode-archive"}: (powershell_harness.bin_dir / "opencode.cmd").unlink() - result = powershell_harness.run(fail_step=failure) + # Full workflows cover each phase's abort; component cases cover its details. + if failure in { + "uv-install", + "claude-verify", + "fcc-install", + "path-update", + "fcc-missing", + "fcc-verify", + }: + result = powershell_harness.run(fail_step=failure) + else: + component = { + "uv": "Ensure-Uv", + "claude": "Ensure-ClaudeCode", + "codex": "Ensure-Codex", + "pi": "Ensure-Pi", + "opencode": "Ensure-OpenCode", + "cline": "Ensure-Cline", + }[failure.split("-", 1)[0]] + setup = '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\n' + if component != "Ensure-Uv": + powershell_harness.add_uv("0.12.13") + setup += "Ensure-Uv\n" + result = powershell_harness.run_functions(setup + component, fail_step=failure) + assert "Installer scenario completed." not in result.stdout assert result.returncode != 0 assert "Free Claude Code is installed and verified." not in result.stdout @@ -3947,9 +4074,13 @@ def test_install_ps1_dry_run_never_executes_commands( def test_install_ps1_rejects_broken_existing_client_without_replacing_it( powershell_harness: PowerShellHarness, ) -> None: + powershell_harness.add_uv("0.12.13") powershell_harness.add_client("claude") - result = powershell_harness.run(fail_step="claude-verify") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nEnsure-ClaudeCode", + fail_step="claude-verify", + ) assert result.returncode != 0 calls = powershell_harness.calls() @@ -3965,7 +4096,7 @@ def test_install_ps1_rejects_unparseable_existing_uv( powershell_harness.add_client("pi") powershell_harness.add_uv("not-a-version") - result = powershell_harness.run() + result = powershell_harness.run_functions("Add-KnownBinDirectories\nEnsure-Uv") assert result.returncode != 0 assert not any("astral.sh" in call for call in powershell_harness.calls()) @@ -3987,7 +4118,10 @@ def test_install_ps1_voice_flags_only_change_fcc_spec( args: tuple[str, ...], package: str, ) -> None: - result = powershell_harness.run(*args) + powershell_harness.add_uv("0.12.13") + result = powershell_harness.run_functions( + "Add-KnownBinDirectories\nEnsure-Uv\nInstall-FreeClaudeCode", *args + ) assert result.returncode == 0, result.stderr install_calls = [ @@ -4027,7 +4161,13 @@ def test_install_ps1_rejects_running_fcc_before_mutation( ) -> None: powershell_harness.env["FCC_RUNNING_COMMAND"] = command_name - result = powershell_harness.run() + # One full workflow checks placement before mutation; the other names test + # the same guard's exact process matching without repeating startup. + result = ( + powershell_harness.run() + if command_name == "fcc-server" + else powershell_harness.run_functions("Assert-NoFccProcessesRunning") + ) assert result.returncode != 0 assert powershell_harness.calls() == [] @@ -4059,7 +4199,7 @@ def test_install_ps1_ignores_similarly_named_process( ) -> None: powershell_harness.env["FCC_RUNNING_COMMAND"] = "fcc-server-helper" - result = powershell_harness.run() + result = powershell_harness.run_functions("Assert-NoFccProcessesRunning") assert result.returncode == 0, result.stderr @@ -4388,6 +4528,9 @@ def _assert_native_opencode_migration( ) -> None: binary = _prepare_native_opencode_v1(harness) original = binary.read_bytes() + if isinstance(harness, PowerShellHarness): + harness.add_installed_clients(except_for=("opencode",)) + harness.add_uv("0.12.13") result = harness.run() assert result.returncode == 0, result.stderr assert binary.read_bytes() != original @@ -4400,7 +4543,13 @@ def _assert_native_opencode_migration( for call in harness.calls() ) harness.log.unlink() - result = harness.run() + result = ( + harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) + if isinstance(harness, PowerShellHarness) + else harness.run() + ) assert result.returncode == 0, result.stderr assert not any("opencode.ai" in call for call in harness.calls()) @@ -4442,7 +4591,9 @@ def test_install_ps1_opencode_rejects_external_incompatible_version( path = powershell_harness.bin_dir / "opencode.cmd" _write_executable(path, _batch_client("opencode", version_output=version)) original = path.read_bytes() - result = powershell_harness.run() + result = powershell_harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) assert result.returncode != 0 assert str(path) in "".join(result.stderr.split()) assert path.read_bytes() == original @@ -4474,7 +4625,13 @@ def _assert_opencode_rtk_cleanup( database = home / ".local" / "share" / "opencode" / "opencode.db" database.parent.mkdir(parents=True) database.write_bytes(b"user-owned database sentinel") - result = harness.run() + result = ( + harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) + if isinstance(harness, PowerShellHarness) + else harness.run() + ) assert database.read_bytes() == b"user-owned database sentinel" assert list(database.parent.iterdir()) == [database] assert previous_backup.read_bytes() == b"prior backup" @@ -4540,7 +4697,13 @@ def _assert_opencode_linked_rtk_parent_is_preserved( else: linked.symlink_to(shared, target_is_directory=True) try: - result = harness.run() + result = ( + harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) + if isinstance(harness, PowerShellHarness) + else harness.run() + ) assert result.returncode != 0 assert "manually" in result.stderr assert plugin.read_bytes() == contents @@ -4588,7 +4751,13 @@ def _assert_opencode_starting_late_blocks_mutation( plugin.write_bytes(contents) harness.env["FCC_RUNNING_COMMAND"] = command harness.env["FCC_RUNNING_PHASE"] = phase - result = harness.run() + result = ( + harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) + if isinstance(harness, PowerShellHarness) + else harness.run() + ) assert Path(harness.env["FCC_PROCESS_MARKER"]).exists() assert result.returncode != 0 assert "Close OpenCode" in result.stderr @@ -4713,7 +4882,13 @@ def _assert_opencode_validates_current_path( plugin.write_bytes(contents) # The external v2 wins initially; adding the native directory puts it first. - result = harness.run() + result = ( + harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) + if isinstance(harness, PowerShellHarness) + else harness.run() + ) assert native.read_bytes() == original assert not any("opencode.ai" in call for call in harness.calls()) if native_version.startswith("1."): @@ -4811,7 +4986,13 @@ def _assert_opencode_link_requires_manual_migration( native.symlink_to(target) except OSError as exc: pytest.skip(f"symlink creation unavailable: {exc}") - result = harness.run() + result = ( + harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) + if isinstance(harness, PowerShellHarness) + else harness.run() + ) assert result.returncode != 0 assert "linked" in result.stderr assert target.read_bytes() == original @@ -4838,7 +5019,9 @@ def test_install_ps1_opencode_rejects_invalid_release_before_replacement( binary = _prepare_native_opencode_v1(powershell_harness) original = binary.read_bytes() powershell_harness.env["FAKE_OPENCODE_RELEASE"] = version - result = powershell_harness.run() + result = powershell_harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) assert result.returncode != 0 assert binary.read_bytes() == original assert not any( @@ -4852,7 +5035,9 @@ def test_install_ps1_opencode_rejects_mismatched_archive_before_replacement( binary = _prepare_native_opencode_v1(powershell_harness) original = binary.read_bytes() powershell_harness.env["FAKE_OPENCODE_ARCHIVE_VERSION"] = "opencode v1.18.31" - result = powershell_harness.run() + result = powershell_harness.run_functions( + '$script:OriginalOpenCode = Get-ApplicationCommand "opencode"\nAdd-KnownBinDirectories\nEnsure-OpenCode' + ) assert result.returncode != 0 assert binary.read_bytes() == original diff --git a/tests/scripts/test_muse_windows_lifecycle.py b/tests/installers/test_muse_windows_lifecycle.py similarity index 99% rename from tests/scripts/test_muse_windows_lifecycle.py rename to tests/installers/test_muse_windows_lifecycle.py index 47de61bd8a..a6f6e2ff5d 100644 --- a/tests/scripts/test_muse_windows_lifecycle.py +++ b/tests/installers/test_muse_windows_lifecycle.py @@ -25,6 +25,12 @@ def _powershells() -> tuple[str, ...]: POWERSHELLS = _powershells() +@pytest.fixture(autouse=True) +def _isolate_modules(powershell, powershell_module_paths, monkeypatch): + if os.name == "nt": + monkeypatch.setenv("PSMODULEPATH", powershell_module_paths[powershell]) + + def _ps_literal(value: str | Path) -> str: return "'" + str(value).replace("'", "''") + "'" diff --git a/tests/scripts/test_powershell_cache.py b/tests/installers/test_powershell_cache.py similarity index 94% rename from tests/scripts/test_powershell_cache.py rename to tests/installers/test_powershell_cache.py index e7aaabcb39..35dabf84f7 100644 --- a/tests/scripts/test_powershell_cache.py +++ b/tests/installers/test_powershell_cache.py @@ -11,7 +11,7 @@ @pytest.mark.skipif(os.name != "nt", reason="Windows PowerShell cache behavior") @pytest.mark.parametrize("shell_name", ["powershell", "pwsh"]) def test_powershell_module_cache_does_not_leak_into_working_directory( - tmp_path: Path, shell_name: str + tmp_path: Path, shell_name: str, powershell_module_paths ) -> None: shell = shutil.which(shell_name) if shell is None: @@ -54,6 +54,7 @@ def test_powershell_module_cache_does_not_leak_into_working_directory( | { "USERPROFILE": str(profile), "LOCALAPPDATA": str(local_app_data), + "PSMODULEPATH": powershell_module_paths[shell], "FCC_TEST_WATCHED_CACHE": str(watched_cache), }, capture_output=True, diff --git a/tests/scripts/test_uninstallers.py b/tests/installers/test_uninstallers.py similarity index 99% rename from tests/scripts/test_uninstallers.py rename to tests/installers/test_uninstallers.py index d3bc455b40..8de3b0c892 100644 --- a/tests/scripts/test_uninstallers.py +++ b/tests/installers/test_uninstallers.py @@ -116,7 +116,7 @@ def use_process_list_fallback(self, process_line: str) -> None: awk = shutil.which("awk", path=self.env["PATH"]) if awk is None: pytest.skip("awk is required for the POSIX process fallback scenario") - shutil.copy2(awk, fallback_bin / "awk") + shutil.copy(awk, fallback_bin / "awk") self.env["FCC_PS_OUTPUT"] = process_line self.env["PATH"] = str(fallback_bin) @@ -515,6 +515,7 @@ def remove_entry_points(self) -> None: def powershell_uninstall_harness( tmp_path: Path, request: pytest.FixtureRequest, + powershell_module_paths, ) -> PowerShellUninstallHarness: powershell = request.param if powershell is None or os.name != "nt": @@ -652,6 +653,7 @@ def powershell_uninstall_harness( [str(bin_dir), str(Path(system_root) / "System32"), system_root] ), "PATHEXT": ".COM;.EXE;.BAT;.CMD", + "PSMODULEPATH": powershell_module_paths[powershell], "HOME": str(home), "USERPROFILE": str(home), "APPDATA": str(app_data), diff --git a/tests/scripts/conftest.py b/tests/scripts/conftest.py index 6bdaca41dc..a183278a65 100644 --- a/tests/scripts/conftest.py +++ b/tests/scripts/conftest.py @@ -1,4 +1,4 @@ -"""Isolate caches created by installer and lifecycle test subprocesses.""" +"""Keep caches from CI-script subprocess tests outside the checkout.""" from pathlib import Path @@ -9,8 +9,6 @@ def _isolate_powershell_module_cache( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - # A redirected USERPROFILE can make Windows PowerShell's default cache path - # relative to the checkout, even when LOCALAPPDATA points at a temporary dir. monkeypatch.setenv( "PSModuleAnalysisCachePath", str(tmp_path / "powershell-module-cache") ) diff --git a/uv.lock b/uv.lock index 6ddc6f321f..a31ba1de4f 100644 --- a/uv.lock +++ b/uv.lock @@ -599,7 +599,7 @@ wheels = [ [[package]] name = "free-claude-code" -version = "6.2.62" +version = "6.2.63" source = { editable = "." } dependencies = [ { name = "aiohttp" }, @@ -645,6 +645,7 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-playwright" }, { name = "pytest-xdist" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "ty" }, ] @@ -691,6 +692,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-playwright", specifier = ">=0.9.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "ruff", specifier = ">=0.16.7" }, { name = "ty", specifier = ">=0.0.80" }, ]