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" }, ]