ouroboros/tests/test_subscription_setup_browser.py
Ouroboros cbf33022bc fix: settle child file and mailbox custody before cleanup
Centralize post-admission drive settlement, preserve captured identities and complete input closures, make metadata reads pure, serve confined nested files and directory archives, and keep maintenance off the supervisor loop. Preserve generation fences at actual mutation boundaries and truthful queued forwarding receipts.
2026-09-26 15:04:49 +03:00

543 lines
30 KiB
Python

"""Real UI modules against controlled subscription API responses, without runtime startup."""
from __future__ import annotations
import copy
import json
import os
from datetime import datetime, timedelta, timezone
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from threading import Thread
from urllib.parse import parse_qs, urlparse
import pytest
from tests.test_onboarding_complete_endpoint import (
LIVE_SNAPSHOT, _profile, _profile_account,
onboarding as onboarding, # explicit re-export of the real atomic settings fixture
)
pytestmark = [pytest.mark.ui_browser, pytest.mark.serial]
WEB = Path(__file__).resolve().parents[1] / "web"
@pytest.fixture
def subscription_ui():
if os.environ.get("OUROBOROS_RUN_UI_SMOKE") != "1":
pytest.skip("Set OUROBOROS_RUN_UI_SMOKE=1 to run the browser flow")
playwright = pytest.importorskip("playwright.sync_api")
bootstrap = json.loads((WEB / "tests/fixtures/onboarding_bootstrap.json").read_text(encoding="utf-8"))
bootstrap["freshInstall"] = True
fixture = json.loads((WEB / "tests/fixtures/subscription_setup.json").read_text(encoding="utf-8"))
fixture['status']['quota'] = [{
'subject': {'harness': 'codex', 'subject_id': 'personal'}, 'freshness': 'fresh',
'constraints': [{'id': 'window', 'used_ratio': 0.38,
'resets_at': (datetime.now(timezone.utc) + timedelta(hours=2)).isoformat()}],
}]
posts = []
reads = []
page_errors = []
backend = {}
settings = {
**fixture["preview"]["model_settings"],
"OUROBOROS_SUBAGENTS": fixture["preview"]["available_subagents"],
"OUROBOROS_REVIEWER_SLOTS": json.dumps(fixture["preview"]["reviewer_slots"]),
"OUROBOROS_RUNTIME_MODE": "advanced",
"OUROBOROS_CONTEXT_MODE": "max",
"_meta": {"setup_contract": bootstrap["contract"]},
}
class Handler(SimpleHTTPRequestHandler):
def log_message(self, *_):
pass
def do_GET(self):
if self.path == "/onboarding":
body = (WEB / "onboarding_template.html").read_text(encoding="utf-8").replace(
"__ONBOARDING_BOOTSTRAP__", json.dumps(bootstrap))
self.send_response(200)
self.send_header("Content-Type", "text/html")
self.end_headers()
self.wfile.write(body.encode())
return
if self.path.startswith("/api/tasks/") and backend.get("task_gateway"):
response = backend["task_gateway"].get(self.path)
self.send_response(response.status_code)
for key in ("content-type", "content-disposition", "content-length"):
if key in response.headers:
self.send_header(key, response.headers[key])
self.end_headers()
self.wfile.write(response.content)
return
self.path = self.path.removeprefix("/static")
super().do_GET()
def respond(route):
request = route.request
path = urlparse(request.url).path
reads.append(request.url)
body = {}
if request.method == "POST":
payload = request.post_data_json or {}
posts.append((path, payload))
if path == "/api/onboarding/subagents/preview":
if backend.get("preview_client"):
result = backend["preview_client"].post(path, json=payload)
route.fulfill(status=result.status_code, content_type="application/json", body=result.text)
return
if backend.get("recovery_error") and payload.get("skipSubscriptionPresets"):
route.fulfill(status=400, content_type="application/json", body=json.dumps({
"ok": False, "error": "Reviewer recovery unavailable.", "detail": backend["recovery_error"], "saved": False,
}))
return
if backend.get("preview_error") and not payload.get("skipSubscriptionPresets"):
route.fulfill(status=503, content_type="application/json", body=json.dumps({
"ok": False, "error": "Automatic assignments unavailable.", "code": "models_unavailable",
"detail": backend["preview_error"], "can_skip": True, "saved": False,
}))
return
body = copy.deepcopy(fixture["preview"])
if not payload.get('subscriptionsConnected'):
body['model_settings'] = {key: payload.get(key, '') for key in body['model_settings']}
body['available_subagents'] = {'enabled': True, 'items': []}
body['reviewer_slots'] = {
'triad': [{'slot_id': 'triad_1', 'route': {'kind': 'api_chat', 'target_id': payload.get('OUROBOROS_MODEL')}}],
'scope': [{'slot_id': 'scope_1', 'route': {'kind': 'api_chat', 'target_id': payload.get('OUROBOROS_MODEL')}}],
'advisory': {'enabled': True, 'route': {'kind': 'api_chat', 'target_id': payload.get('OUROBOROS_MODEL')}},
}
if payload.get("skipSubscriptionPresets"):
body["model_settings"] = {key: payload.get(key, '') for key in body["model_settings"]}
body["available_subagents"] = payload.get("OUROBOROS_SUBAGENTS") or {"enabled": True, "items": []}
slots = json.loads(payload["OUROBOROS_REVIEWER_SLOTS"]) if payload.get("OUROBOROS_REVIEWER_SLOTS") else body["reviewer_slots"]
for value in slots.values():
for row in value if isinstance(value, list) else [value]:
if not isinstance(row, dict):
continue
row.pop("subagent_id", None)
row["route"] = {"kind": "api_chat", "target_id": payload["OUROBOROS_MODEL"]}
pin = payload.get("OUROBOROS_MODEL_ACCOUNTS", {}).get("main")
if pin:
row["route"]["profile_id"] = pin
body["reviewer_slots"] = slots
body["reviewer_slots"] = json.dumps(body["reviewer_slots"])
elif path == "/api/onboarding/complete":
assert isinstance(payload["OUROBOROS_REVIEWER_SLOTS"], str)
assert isinstance(json.loads(payload["OUROBOROS_REVIEWER_SLOTS"]), dict)
if backend.get("client"):
result = backend["client"].post(path, json=payload)
route.fulfill(status=result.status_code, content_type="application/json", body=result.text)
return
body = {"ok": True, "runtime_mode": "advanced", "restart_required": False}
elif path == "/api/settings":
body = {"ok": True, "saved": True}
elif path == "/api/claudexor/status":
body = fixture["status"]
elif path == "/api/settings":
body = settings
elif path == "/api/reviewer-slots":
body = fixture["preview"]["reviewer_slots"]
elif path == "/api/model-catalog":
body = copy.deepcopy(backend.get("catalog_response", fixture["catalog"]))
if backend.get("catalog_status"):
route.fulfill(status=backend["catalog_status"], content_type="application/json", body=json.dumps(body))
return
profile = parse_qs(urlparse(request.url).query).get("credential_profile_id", [""])[0]
if profile == "work":
body["items"][0].update(credential_profile_id="work", max_context_window=500000)
elif path == "/api/onboarding":
route.fulfill(status=204)
return
elif path == "/api/state":
body = {"supervisor_ready": True, "active_chat_activities": [], "projects": []}
elif path == "/api/projects":
body = {"projects": []}
elif path == "/api/chat/history":
body = {"messages": [], "progress": []}
elif path == "/api/health":
body = {"ok": True, "version": "fixture"}
route.fulfill(content_type="application/json", body=json.dumps(body))
server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(WEB)))
thread = Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
with playwright.sync_playwright() as pw:
engine = os.environ.get("OUROBOROS_UI_BROWSER_ENGINE", "chromium")
if engine not in {"chromium", "webkit", "firefox"}:
raise ValueError(f"Unsupported browser engine: {engine}")
browser = getattr(pw, engine).launch(headless=True)
try:
page = browser.new_page(viewport={"width": 1360, "height": 900},
has_touch=os.environ.get("OUROBOROS_UI_HAS_TOUCH") == "1")
page.route_web_socket('**/ws', lambda ws: ws.send(json.dumps({"type": "heartbeat"})))
page.route("**/api/**", respond)
page.on("pageerror", lambda error: page_errors.append(str(error)))
yield {"page": page, "url": f"http://127.0.0.1:{server.server_port}",
"posts": posts, "reads": reads, "fixture": fixture,
"settings": settings, "errors": page_errors, "backend": backend, "bootstrap": bootstrap}
assert not page_errors
finally:
browser.close()
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def capture(page, name):
root = os.environ.get("OUROBOROS_UI_EVIDENCE_DIR")
if root:
target = Path(root)
target.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(target / f"{name}.png"), animations="disabled")
def test_codex_quick_setup_computes_skipped_steps_and_finishes_once(subscription_ui, onboarding):
ui = subscription_ui
ui['backend']['client'] = onboarding.client
onboarding.calls['snapshot_payload'] = {
**LIVE_SNAPSHOT, 'harnesses': [LIVE_SNAPSHOT['harnesses'][1]],
'profiles': {'harnessAccounts': [_profile_account('codex', 'personal')],
'profiles': [_profile('codex', 'personal')]},
'model_catalog': ui['fixture']['catalog']['items'],
}
ui['fixture']['status']['profiles']['profiles'] = ui['fixture']['status']['profiles']['profiles'][:1]
page = ui["page"]
page.goto(ui["url"] + "/onboarding")
page.wait_for_selector('#quick-start-btn:not([hidden])')
assert page.locator('.wizard-step').count() == 5
capture(page, "accounts-connected")
page.click('#quick-start-btn')
page.wait_for_selector('.summary-card')
assert "gpt-test" in page.locator('.summary-card').inner_text()
assert "Deep self-review" in page.locator('.summary-card').inner_text()
capture(page, "summary-quick")
page.click('#next-btn')
page.wait_for_url(ui["url"] + "/")
writes = [body for path, body in ui["posts"] if path == '/api/onboarding/complete']
assert len(writes) == 1
assert writes[0]["OPENROUTER_API_KEY"] == ""
assert writes[0]["OUROBOROS_MODEL"] == 'claudexor::codex=gpt-test'
assert json.loads(writes[0]["OUROBOROS_REVIEWER_SLOTS"])["deep_review"]["subagent_id"] == "codex"
assert not any(path == '/api/settings' for path, _ in ui["posts"])
saved = onboarding.saved()
assert saved['OUROBOROS_MODEL'] == writes[0]['OUROBOROS_MODEL']
assert saved['OUROBOROS_REVIEWER_SLOTS'] == writes[0]['OUROBOROS_REVIEWER_SLOTS']
assert not saved['OPENAI_API_KEY'] and not saved['OPENROUTER_API_KEY']
assert onboarding.calls['supervisor'] == 1
@pytest.mark.parametrize("credential_harness, expected", [("codex", True), ("claude", False)])
def test_quick_start_requires_a_model_source_for_the_connected_harness(subscription_ui, credential_harness, expected):
ui, page = subscription_ui, subscription_ui["page"]
ui["fixture"]["catalog"]["model_sources"] = [
{"id": "opaque-source", "label": "Managed models", "credentialHarness": credential_harness},
]
with page.expect_response("**/api/model-catalog"):
page.goto(ui["url"] + "/onboarding")
page.wait_for_function(
"expected => document.querySelector('#quick-start-btn').hidden !== expected", arg=expected)
assert page.locator("#quick-start-btn").is_visible() is expected
def test_model_roles_pin_context_fallback_and_manual_draft_survive_preview(subscription_ui):
ui = subscription_ui
page = ui["page"]
page.goto(ui["url"] + "/onboarding")
page.wait_for_selector('#quick-start-btn:not([hidden])')
page.click('#next-btn')
page.wait_for_selector('[data-model-role="main"]')
main = page.locator('[data-model-role="main"]')
light = page.locator('[data-model-role="light"]')
main.locator('[data-model-role-account]').select_option('personal')
held_catalog = []
page.route('**/api/model-catalog?*credential_profile_id=work', lambda route: held_catalog.append(route))
light.locator('[data-model-role-account]').select_option('work')
main.locator('summary').click()
light.locator('summary').click()
assert 'not known' in light.locator('[data-model-context-note]').inner_text()
page.wait_for_function("() => document.querySelector('[data-model-role=light] [data-model-role-account]').value === 'work'")
assert held_catalog
work_catalog = copy.deepcopy(ui['fixture']['catalog'])
work_catalog['items'][0].update(credential_profile_id='work', max_context_window=500000)
for route in held_catalog:
route.fulfill(content_type='application/json', body=json.dumps(work_catalog))
page.unroute('**/api/model-catalog?*credential_profile_id=work')
page.wait_for_function("() => document.querySelector('[data-model-role=light] [data-model-context-note]').textContent.includes('500,000')")
assert '872,000' in main.locator('[data-model-context-note]').inner_text()
main.locator('[data-model-role-context]').fill('1000000')
assert 'set by you' in main.locator('[data-model-context-note]').inner_text()
page.locator('[data-model-role="vision"] [data-model-role-account]').select_option('work')
page.click('[data-model-add]')
fallback = page.locator('[data-model-role-group="fallback"]')
fallback.locator('[data-model-role-source]').select_option('subscription:codex')
fallback.locator('[data-model-role-model]').fill('first')
fallback.locator('[data-model-role-account]').select_option('work')
page.click('[data-model-add]')
fallback.locator('[data-model-role-model]').last.fill('openai::second')
fallback.locator('[data-model-up]').last.click()
main.locator('[data-model-role-model]').fill('owner-model')
page.evaluate('window.scrollTo(0, 0)')
capture(page, "models-desktop")
page.click('#next-btn')
page.wait_for_selector('#reviewer-slots-section', state='attached')
page.locator('details').filter(has=page.locator('#reviewer-slots-section')).locator(':scope > summary').click()
assert page.locator('#reviewer-deep-review-row select').count() > 0
capture(page, "review-editable")
deep_review = page.locator('#reviewer-deep-review-row')
deep_review.locator('[data-deep-review-effort]').select_option('high')
deep_review.scroll_into_view_if_needed()
capture(page, "deep-review-editable")
page.click('#next-btn')
page.wait_for_selector('[data-collapse="api-budget"]')
assert not page.locator('[data-collapse="api-budget"]').evaluate('(el) => el.open')
capture(page, "budget-subscription")
page.click('#next-btn')
page.wait_for_selector('.summary-card')
page.wait_for_function("() => !document.querySelector('.wizard-error').textContent")
summary = page.locator('.summary-card')
assert '1,000,000 tokens, set by you' in summary.inner_text()
vision = summary.locator('.summary-kv').filter(has=page.get_by_text('Vision', exact=True))
assert 'Uses Main' in vision.inner_text()
assert 'Account: work' in vision.inner_text()
deep_summary = summary.locator('.summary-kv').filter(has=page.get_by_text('Deep self-review', exact=True))
assert 'Effort: high' in deep_summary.inner_text()
capture(page, "summary-manual")
page.click('#next-btn')
page.wait_for_url(ui["url"] + "/")
body = [body for path, body in ui["posts"] if path == '/api/onboarding/complete'][0]
assert body['OUROBOROS_MODEL'] == 'claudexor::codex=owner-model'
assert body['OUROBOROS_MODEL_LIGHT'] == 'claudexor::codex=gpt-test'
assert body['OUROBOROS_MODEL_ACCOUNTS']['main'] == 'personal'
assert body['OUROBOROS_MODEL_ACCOUNTS']['light'] == 'work'
assert body['OUROBOROS_MODEL_ACCOUNTS']['vision'] == 'work'
assert body['OUROBOROS_MODEL_ACCOUNTS']['fallback'] == ['', 'work']
assert body['OUROBOROS_MODEL_CONTEXT_WINDOWS']['main'] == 1000000
assert body['OUROBOROS_MODEL_FALLBACKS'] == 'openai::second, claudexor::codex=first'
assert json.loads(body['OUROBOROS_REVIEWER_SLOTS'])['deep_review']['effort'] == 'high'
def test_settings_accounts_and_model_roles_use_the_same_compact_components(subscription_ui):
ui = subscription_ui
page = ui['page']
page.goto(ui['url'] + '/#settings')
page.wait_for_selector('#settings-model-roles .model-role-row', state='attached')
assert page.locator('[data-settings-tab="providers"]').inner_text() == 'Accounts'
assert page.locator('[data-settings-panel="providers"] #harness-accounts-section').count() == 1
assert page.locator('[data-settings-panel="agents"] #harness-accounts-section').count() == 0
page.click('[data-settings-tab="models"]')
page.wait_for_function("() => document.querySelector('[data-settings-tab=models]').getAttribute('aria-selected') === 'true'")
main = page.locator('[data-model-role="main"]')
main.locator('[data-model-role-account]').select_option('work')
inputs = main.locator('.model-role-controls > input, .model-role-controls > select')
tops = inputs.evaluate_all('(els) => els.filter(el => !el.hidden).map(el => el.getBoundingClientRect().top)')
assert max(tops) - min(tops) <= 2
assert main.locator('input').first.evaluate('(el) => getComputedStyle(el).fontSize') == '14px'
capture(page, 'settings-models-desktop')
page.set_viewport_size({'width': 390, 'height': 844})
page.wait_for_function("() => document.querySelector('#primary-sidebar').getBoundingClientRect().right <= 1")
assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')
capture(page, 'settings-models-narrow')
def test_cursor_only_does_not_claim_model_access_and_api_only_finishes(subscription_ui):
ui = subscription_ui
ui['fixture']['status']['profiles']['profiles'] = [{
'profile': {'profile_id': 'cursor-only', 'harness_id': 'cursor', 'enabled': True},
'status': {'verification': 'passed'},
}]
page = ui['page']
page.goto(ui['url'] + '/onboarding')
page.wait_for_function("() => document.querySelector('[data-agent-family=cursor]').textContent.includes('Connected')")
assert page.locator('#quick-start-btn').is_hidden()
assert page.locator('#next-btn').is_disabled()
assert 'Main still needs Codex' in page.locator('#agents-outcome').inner_text()
# Removing the optional agent gives the API-only path the same five steps.
ui['fixture']['status']['profiles']['profiles'] = []
page.reload()
page.locator('[data-collapse="api-access"] > summary').click()
page.locator('#openai-key').fill('fixture-api-credential')
page.click('#next-btn')
page.wait_for_selector('#main-model')
assert page.locator('#main-model').input_value()
for _ in range(3):
page.click('#next-btn')
page.wait_for_selector('.summary-card')
assert 'no API key' not in page.locator('.summary-card').inner_text()
page.click('#next-btn')
page.wait_for_url(ui['url'] + '/')
body = [body for path, body in ui['posts'] if path == '/api/onboarding/complete'][0]
assert body['OPENAI_API_KEY'] == 'fixture-api-credential'
assert body['OUROBOROS_MODEL'].startswith('openai::')
assert not body['subscriptionsConnected']
@pytest.mark.parametrize('edit_after_recovery', [False, True, 'main'])
def test_failed_preview_allows_manual_main_and_visible_reviewer_recovery_before_save(subscription_ui, onboarding, edit_after_recovery):
ui, page = subscription_ui, subscription_ui['page']
ui['backend'].update(preview_client=onboarding.client, client=onboarding.client)
onboarding.calls['snapshot_payload'] = {
**LIVE_SNAPSHOT,
'harnesses': [{**LIVE_SNAPSHOT['harnesses'][1], 'models': []}],
'profiles': {'harnessAccounts': [_profile_account('codex', 'personal')],
'profiles': [_profile('codex', 'personal')]},
'model_catalog': ui['fixture']['catalog']['items'],
}
page.goto(ui['url'] + '/onboarding')
page.wait_for_selector('#next-btn:not([disabled])')
page.wait_for_function("() => document.querySelector('#onboarding-access-note').textContent.includes('listed no models')")
assert not any(path == '/api/onboarding/complete' for path, _ in ui['posts'])
page.set_viewport_size({'width': 390, 'height': 844})
assert page.evaluate('document.documentElement.scrollWidth <= window.innerWidth')
capture(page, 'accounts-preview-failure-narrow')
page.set_viewport_size({'width': 1360, 'height': 900})
page.click('#next-btn')
main = page.locator('[data-model-role="main"]')
assert main.locator('[data-model-role-model]').input_value() == ''
assert page.locator('#next-btn').is_disabled()
main.locator('[data-model-role-source]').select_option('subscription:codex')
main.locator('[data-model-role-model]').fill('owner-main')
main.locator('[data-model-role-account]').select_option('personal')
for _ in range(3):
page.click('#next-btn')
page.wait_for_selector('.summary-card')
page.wait_for_selector('#skip-presets-btn:not([hidden])')
page.click('#skip-presets-btn')
page.wait_for_function("() => document.querySelector('.wizard-inline-note')?.textContent.includes('Reviewers were assigned to Main')")
assert not any(path == '/api/onboarding/complete' for path, _ in ui['posts']), 'Recovery is a preview, not a write'
assert not onboarding.settings_path.exists()
for label in ['Triad review', 'Scope review', 'Advisory review', 'Deep self-review']:
row = page.locator('.summary-kv').filter(has=page.get_by_text(label, exact=True))
assert 'claudexor::codex=owner-main' in row.inner_text()
capture(page, 'manual-main-reviewer-recovery')
if edit_after_recovery == 'main':
for _ in range(3):
page.click('#back-btn')
page.locator('[data-model-role="main"] [data-model-role-model]').fill('new-main')
for _ in range(3):
page.click('#next-btn')
page.wait_for_selector('#skip-presets-btn:not([hidden])')
assert page.locator('#next-btn').is_enabled()
assert 'Main changed; reviewers keep the assignments shown above' in page.locator('.wizard-inline-note').inner_text()
assert 'owner-main' in page.locator('.summary-kv').filter(has=page.get_by_text('Triad review', exact=True)).inner_text()
elif edit_after_recovery:
page.click('#back-btn')
page.click('#back-btn')
page.locator('[data-collapse="reviewers"] > summary').click()
page.locator('[data-deep-review-api-model]').fill('owner-deep')
page.click('#next-btn')
page.click('#next-btn')
page.wait_for_function("() => !document.querySelector('.wizard-error').textContent")
deep = page.locator('.summary-kv').filter(has=page.get_by_text('Deep self-review', exact=True))
assert 'owner-deep' in deep.inner_text()
page.click('#next-btn')
page.wait_for_url(ui['url'] + '/')
bodies = [body for path, body in ui['posts'] if path == '/api/onboarding/complete']
assert len(bodies) == 1 and bodies[0]['skipSubscriptionPresets'] is True
assert bodies[0]['OUROBOROS_MODEL'] == ('claudexor::codex=new-main' if edit_after_recovery == 'main' else 'claudexor::codex=owner-main')
assert json.loads(onboarding.saved()['OUROBOROS_REVIEWER_SLOTS']) == json.loads(bodies[0]['OUROBOROS_REVIEWER_SLOTS'])
assert onboarding.calls['supervisor'] == 1
for kind, value in json.loads(bodies[0]['OUROBOROS_REVIEWER_SLOTS']).items():
for row in value if isinstance(value, list) else [value]:
if isinstance(row, dict):
expected = 'owner-deep' if edit_after_recovery is True and kind == 'deep_review' else 'owner-main'
assert row['route']['target_id'] == f'claudexor::codex={expected}'
assert row['route']['profile_id'] == 'personal'
def test_failed_main_reviewer_recovery_does_not_latch_finish(subscription_ui):
# A refused recovery preview must leave the wizard on its ordinary path:
# the packaged setup window has no reload, so the owner needs both the
# normal Finish and a working Use Main retry after the backend refuses once.
ui, page = subscription_ui, subscription_ui['page']
ui['backend'].update(preview_error='Agent model discovery unavailable.',
recovery_error='A Main account pin requires a managed model source.')
page.goto(ui['url'] + '/onboarding')
page.wait_for_selector('#next-btn:not([disabled])')
page.click('#next-btn')
main = page.locator('[data-model-role="main"]')
main.locator('[data-model-role-source]').select_option('subscription:codex')
main.locator('[data-model-role-model]').fill('owner-main')
for _ in range(3):
page.click('#next-btn')
page.wait_for_selector('#skip-presets-btn:not([hidden])')
page.click('#skip-presets-btn')
page.wait_for_function("() => document.querySelector('.wizard-error').textContent.includes('managed model source')")
page.wait_for_selector('#skip-presets-btn:not([hidden])')
page.click('#next-btn')
page.wait_for_function("() => document.querySelector('.wizard-error').textContent")
assert 'Use Main for reviewers to prepare' not in page.locator('.wizard-error').inner_text()
assert not any(path == '/api/onboarding/complete' for path, _ in ui['posts'])
ui['backend'].pop('recovery_error')
page.click('#skip-presets-btn')
page.wait_for_function("() => document.querySelector('.wizard-inline-note')?.textContent.includes('Reviewers were assigned to Main')")
page.click('#next-btn')
page.wait_for_url(ui['url'] + '/')
bodies = [body for path, body in ui['posts'] if path == '/api/onboarding/complete']
assert len(bodies) == 1 and bodies[0]['skipSubscriptionPresets'] is True
assert bodies[0]['OUROBOROS_MODEL'] == 'claudexor::codex=owner-main'
@pytest.mark.parametrize('http_status', [200, 500])
def test_accounts_catalog_failure_is_visible_and_retry_keeps_the_draft(subscription_ui, http_status):
ui, page = subscription_ui, subscription_ui['page']
ui['backend'].update(preview_error='Agent model discovery unavailable.', catalog_status=http_status,
catalog_response={'error': 'Raw model service unavailable.'})
page.goto(ui['url'] + '/onboarding')
page.wait_for_function("() => document.querySelector('#onboarding-access-note').textContent.includes('Raw model service unavailable')")
assert page.locator('#next-btn').is_disabled()
page.locator('[data-collapse="api-access"] > summary').click()
page.locator('#openai-key').fill('owner-draft-credential')
ui['backend'].pop('catalog_status')
ui['backend'].pop('catalog_response')
page.click('#onboarding-access-retry')
page.wait_for_selector('#quick-start-btn:not([hidden])')
assert page.locator('#openai-key').input_value() == 'owner-draft-credential'
assert page.locator('[data-collapse="api-access"]').evaluate('(el) => el.open')
assert not any(path == '/api/onboarding/complete' for path, _ in ui['posts'])
@pytest.mark.parametrize('fresh_install', [False, True])
def test_declared_source_with_failed_inventory_preserves_stored_or_owner_authored_defaults(subscription_ui, fresh_install):
ui, page = subscription_ui, subscription_ui['page']
ui['bootstrap']['freshInstall'] = fresh_install
initial = ui['bootstrap']['initialState']['mainModel']
ui['backend'].update(preview_error='Agent inventory unavailable.', catalog_response={
'items': [], 'model_sources': ui['fixture']['catalog']['model_sources'],
'read_state': 'partial', 'errors': [{'error': 'Account model inventory unavailable.'}],
})
page.goto(ui['url'] + '/onboarding')
page.wait_for_selector('#next-btn:not([disabled])')
page.click('#next-btn')
main = page.locator('[data-model-role="main"]')
if fresh_install:
assert main.locator('[data-model-role-model]').input_value() == ''
main.locator('[data-model-role-model]').fill(initial)
page.click('#back-btn')
page.click('#onboarding-access-retry')
page.wait_for_selector('#onboarding-access-retry:not([disabled])')
page.click('#next-btn')
assert main.locator('[data-model-role-model]').input_value() == initial
assert page.locator('#next-btn').is_disabled()
assert 'Main uses OpenRouter' in page.locator('.wizard-error').inner_text()
main.locator('[data-model-role-source]').select_option('subscription:codex')
main.locator('[data-model-role-model]').fill('typed-without-inventory')
assert page.locator('#next-btn').is_enabled()
def test_late_catalog_does_not_restore_access_after_the_account_disconnects(subscription_ui):
ui, page = subscription_ui, subscription_ui['page']
ui['backend']['preview_error'] = 'Agent inventory unavailable.'
held = []
page.route('**/api/model-catalog', lambda route: held.append(route))
page.goto(ui['url'] + '/onboarding')
page.wait_for_function("() => document.querySelector('#onboarding-access-note').textContent.includes('Checking model sources')")
assert held
ui['fixture']['status']['profiles']['profiles'] = []
page.evaluate("async () => (await import('/static/modules/claudexor_status_store.js')).claudexorStatus.refresh()")
page.wait_for_function("() => !document.querySelector('[data-agent-family=codex]').textContent.includes('Connected')")
for route in held:
route.fulfill(content_type='application/json', body=json.dumps(ui['fixture']['catalog']))
page.unroute('**/api/model-catalog')
assert page.locator('#next-btn').is_disabled()
assert page.locator('#quick-start-btn').is_hidden()