from __future__ import annotations from collections.abc import Awaitable, Callable from dataclasses import dataclass from datetime import UTC, datetime, timedelta from urllib.parse import urlencode from vibe.setup.auth import ( BrowserSignInError, BrowserSignInErrorCode, BrowserSignInGateway, BrowserSignInPollResult, BrowserSignInProcess, ) from vibe.setup.auth.browser_sign_in import BrowserSignInService @dataclass class ExchangeRequestPayload: process_id: str exchange_token: str code_verifier: str class StubBrowserSignInGateway(BrowserSignInGateway): def __init__( self, *, process: BrowserSignInProcess | None = None, processes: list[BrowserSignInProcess] | None = None, poll_results: list[BrowserSignInPollResult | BrowserSignInError] | None = None, exchange_result: str = "sk-browser-key", exchange_error: BrowserSignInError | None = None, ) -> None: if process is not None and processes is not None: msg = "StubBrowserSignInGateway accepts either process or processes." raise AssertionError(msg) self._processes = list(processes or ([] if process is None else [process])) self._poll_results = list(poll_results or []) self.exchange_result = exchange_result self.exchange_error = exchange_error self.code_challenges: list[str] = [] self.polled_urls: list[str] = [] self.exchange_requests: list[ExchangeRequestPayload] = [] self.closed = False self.poll_calls = 0 self.process_number = 0 async def create_process(self, code_challenge: str) -> BrowserSignInProcess: self.code_challenges.append(code_challenge) if not self._processes: msg = "StubBrowserSignInGateway requires at least one scripted process." raise AssertionError(msg) self.process_number += 1 return self._processes.pop(0) async def poll(self, poll_url: str) -> BrowserSignInPollResult: self.polled_urls.append(poll_url) self.poll_calls += 1 if not self._poll_results: msg = "StubBrowserSignInGateway requires scripted poll results." raise AssertionError(msg) result = self._poll_results.pop(0) if isinstance(result, BrowserSignInError): raise result return result async def exchange( self, process_id: str, exchange_token: str, code_verifier: str ) -> str: self.exchange_requests.append( ExchangeRequestPayload( process_id=process_id, exchange_token=exchange_token, code_verifier=code_verifier, ) ) if self.exchange_error is not None: raise self.exchange_error return self.exchange_result async def aclose(self) -> None: self.closed = True def build_sign_in_process( now: datetime, process_id: str = "process-1" ) -> BrowserSignInProcess: fragment = urlencode({ "process_id": process_id, "complete_token": f"complete-token-{process_id}", "state": f"state-{process_id}", }) return BrowserSignInProcess( process_id=process_id, sign_in_url=( f"https://console.mistral.ai/codestral/cli/authenticate#{fragment}" ), poll_url=( f"https://console.mistral.ai/api/vibe/sign-in/poll/poll-token-{process_id}" ), expires_at=now + timedelta(minutes=5), ) def build_poll_failed_error() -> BrowserSignInError: return BrowserSignInError( "Browser sign-in status could not be retrieved.", code=BrowserSignInErrorCode.POLL_FAILED, ) def build_poll_results_from_outcomes( outcomes: list[str], ) -> tuple[ list[BrowserSignInProcess], list[BrowserSignInPollResult | BrowserSignInError] ]: processes: list[BrowserSignInProcess] = [] poll_results: list[BrowserSignInPollResult | BrowserSignInError] = [] now = datetime(2026, 3, 16, tzinfo=UTC) for process_index, outcome in enumerate(outcomes, start=1): process_id = f"process-{process_index}" processes.append(build_sign_in_process(now, process_id=process_id)) match outcome: case "completed": poll_results.extend([ BrowserSignInPollResult(status="pending"), BrowserSignInPollResult( status="completed", exchange_token=f"exchange-{process_id}" ), ]) case "expired": poll_results.append(BrowserSignInPollResult(status="expired")) case "poll_failed": poll_results.extend([ BrowserSignInPollResult(status="pending"), build_poll_failed_error(), build_poll_failed_error(), build_poll_failed_error(), ]) case _: msg = f"Unsupported browser sign-in outcome: {outcome}" raise AssertionError(msg) return processes, poll_results async def noop_sleep(_: float) -> None: return None def build_browser_sign_in_service_factory( outcomes: list[str], *, exchange_result: str = "sk-browser-onboarding-test-key", open_browser: Callable[[str], bool] | None = None, sleep: Callable[[float], Awaitable[None]] = noop_sleep, now: Callable[[], datetime] | None = None, ) -> tuple[ StubBrowserSignInGateway, Callable[[], BrowserSignInService], list[BrowserSignInService], ]: processes, poll_results = build_poll_results_from_outcomes(outcomes) gateway = StubBrowserSignInGateway( processes=processes, poll_results=poll_results, exchange_result=exchange_result ) created_services: list[BrowserSignInService] = [] def build_service() -> BrowserSignInService: service = BrowserSignInService( gateway, open_browser=open_browser or (lambda _: True), sleep=sleep, now=now or ( lambda: ( datetime(2026, 3, 16, tzinfo=UTC) + timedelta(seconds=gateway.poll_calls) ) ), poll_interval=0, ) created_services.append(service) return service return gateway, build_service, created_services