feat: OAuth provider onboarding path — Codex/Copilot no longer blocks setup (#331)
Fixes bug 2 from issue #329. current_is_oauth flag; confirmation card for OAuth providers; KeyError fix in _build_setup_catalog. 15 new tests, 791 total.
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -6,6 +6,16 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## [v0.50.7] OAuth provider onboarding path — Codex/Copilot no longer blocks setup (PR #331, fixes #329 bug 2)
|
||||||
|
|
||||||
|
- **OAuth providers now have a proper onboarding path** (closes bug 2): Users with `openai-codex`, `copilot`, `qwen-oauth`, or any other OAuth-authenticated provider now see a clear confirmation card instead of an unusable API key input form.
|
||||||
|
- If already authenticated (`chat_ready: true`): blue "Provider already authenticated" card with a direct Continue button — no key entry required.
|
||||||
|
- If not yet authenticated: amber card explaining how to run `hermes auth` or `hermes model` in a terminal to complete setup.
|
||||||
|
- Either state includes a collapsible "switch provider" section for users who want to move to an API-key provider instead.
|
||||||
|
- `_build_setup_catalog` now includes `current_is_oauth` boolean; fixed a latent `KeyError` crash when looking up `default_model` for OAuth providers.
|
||||||
|
- 5 new i18n keys in English and Spanish (`onboarding_oauth_*`).
|
||||||
|
- 15 new tests in `tests/test_sprint40.py`; 791 tests total (up from 776)
|
||||||
|
|
||||||
## [v0.50.6] Skip-onboarding env var + synchronous API key reload (PR #330, fixes #329 bugs 1+3)
|
## [v0.50.6] Skip-onboarding env var + synchronous API key reload (PR #330, fixes #329 bugs 1+3)
|
||||||
|
|
||||||
- **`HERMES_WEBUI_SKIP_ONBOARDING=1`** (closes bug 1): Hosting providers can set this env var to bypass the first-run wizard entirely. Only takes effect when `chat_ready` is also true — a misconfigured deployment still shows the wizard. Accepts `1`, `true`, or `yes`.
|
- **`HERMES_WEBUI_SKIP_ONBOARDING=1`** (closes bug 1): Hosting providers can set this env var to bypass the first-run wizard entirely. Only takes effect when `chat_ready` is also true — a misconfigured deployment still shows the wizard. Accepts `1`, `true`, or `yes`.
|
||||||
|
|||||||
@@ -362,13 +362,23 @@ def _build_setup_catalog(cfg: dict) -> dict:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Flag whether the currently-configured provider is OAuth-based (not in the
|
||||||
|
# API-key flow). The frontend uses this to show a confirmation card instead
|
||||||
|
# of a key input when the user has already authenticated via 'hermes auth'.
|
||||||
|
current_is_oauth = current_provider not in _SUPPORTED_PROVIDER_SETUPS and bool(
|
||||||
|
current_provider
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"providers": providers,
|
"providers": providers,
|
||||||
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
"unsupported_note": _UNSUPPORTED_PROVIDER_NOTE,
|
||||||
|
"current_is_oauth": current_is_oauth,
|
||||||
"current": {
|
"current": {
|
||||||
"provider": current_provider,
|
"provider": current_provider,
|
||||||
"model": current_model
|
"model": current_model
|
||||||
or _SUPPORTED_PROVIDER_SETUPS[current_provider]["default_model"],
|
or _SUPPORTED_PROVIDER_SETUPS.get(current_provider, {}).get(
|
||||||
|
"default_model", ""
|
||||||
|
),
|
||||||
"base_url": current_base_url,
|
"base_url": current_base_url,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,11 @@ const LOCALES = {
|
|||||||
onboarding_missing_imports: 'Missing imports:',
|
onboarding_missing_imports: 'Missing imports:',
|
||||||
onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.',
|
onboarding_notice_setup_required: 'Choose a simple provider path here. Advanced OAuth flows still belong in the Hermes CLI for now.',
|
||||||
onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.',
|
onboarding_notice_setup_already_ready: 'A working Hermes provider setup is already detected. You can keep it or replace it here.',
|
||||||
|
onboarding_oauth_provider_ready_title: 'Provider already authenticated',
|
||||||
|
onboarding_oauth_provider_ready_body: 'This instance is configured to use an OAuth provider (<strong>{provider}</strong>) that was set up via the Hermes CLI. No API key is needed here — click Continue to finish setup.',
|
||||||
|
onboarding_oauth_provider_not_ready_title: 'OAuth provider not yet authenticated',
|
||||||
|
onboarding_oauth_provider_not_ready_body: 'This instance is configured to use <strong>{provider}</strong>, which uses OAuth rather than an API key. Run <code>hermes auth</code> or <code>hermes model</code> in a terminal to authenticate, then reload the Web UI.',
|
||||||
|
onboarding_oauth_switch_hint: 'Or choose a different provider below to switch to an API-key setup:',
|
||||||
onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.',
|
onboarding_notice_workspace: 'These values reuse the same settings APIs as the normal app.',
|
||||||
onboarding_workspace_label: 'Workspace',
|
onboarding_workspace_label: 'Workspace',
|
||||||
onboarding_workspace_or_path: 'Or enter a workspace path',
|
onboarding_workspace_or_path: 'Or enter a workspace path',
|
||||||
@@ -497,6 +502,11 @@ const LOCALES = {
|
|||||||
onboarding_missing_imports: 'Importaciones faltantes:',
|
onboarding_missing_imports: 'Importaciones faltantes:',
|
||||||
onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.',
|
onboarding_notice_setup_required: 'Elige aquí una ruta simple de proveedor. Los flujos OAuth avanzados siguen siendo del CLI de Hermes por ahora.',
|
||||||
onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.',
|
onboarding_notice_setup_already_ready: 'Ya se detectó una configuración funcional del proveedor de Hermes. Puedes conservarla o reemplazarla aquí.',
|
||||||
|
onboarding_oauth_provider_ready_title: 'Proveedor ya autenticado',
|
||||||
|
onboarding_oauth_provider_ready_body: 'Esta instancia está configurada para usar un proveedor OAuth (<strong>{provider}</strong>) configurado mediante la CLI de Hermes. No se necesita clave API aquí — haz clic en Continuar para finalizar la configuración.',
|
||||||
|
onboarding_oauth_provider_not_ready_title: 'Proveedor OAuth no autenticado aún',
|
||||||
|
onboarding_oauth_provider_not_ready_body: 'Esta instancia está configurada para usar <strong>{provider}</strong>, que utiliza OAuth en lugar de una clave API. Ejecuta <code>hermes auth</code> o <code>hermes model</code> en una terminal para autenticarte y recarga la interfaz web.',
|
||||||
|
onboarding_oauth_switch_hint: 'O elige un proveedor diferente a continuación para cambiar a la configuración con clave API:',
|
||||||
onboarding_notice_workspace: 'Estos valores reutilizan las mismas APIs de configuración que la app normal.',
|
onboarding_notice_workspace: 'Estos valores reutilizan las mismas APIs de configuración que la app normal.',
|
||||||
onboarding_workspace_label: 'Espacio de trabajo',
|
onboarding_workspace_label: 'Espacio de trabajo',
|
||||||
onboarding_workspace_or_path: 'O introduce la ruta de un espacio de trabajo',
|
onboarding_workspace_or_path: 'O introduce la ruta de un espacio de trabajo',
|
||||||
|
|||||||
@@ -526,7 +526,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.6</span>
|
<span class="settings-version-badge">v0.50.7</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
@@ -112,6 +112,61 @@ function _renderOnboardingBody(){
|
|||||||
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
|
const provider=_getOnboardingSetupProvider(ONBOARDING.form.provider)||providers[0]||null;
|
||||||
const showBaseUrl=provider&&provider.requires_base_url;
|
const showBaseUrl=provider&&provider.requires_base_url;
|
||||||
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
const keyHelp=provider?`${t('onboarding_api_key_help_prefix')} ${esc(provider.env_var)}.`:'';
|
||||||
|
|
||||||
|
// OAuth provider path: configured via CLI, no API key input needed.
|
||||||
|
const currentIsOauth=!!(ONBOARDING.status.setup||{}).current_is_oauth;
|
||||||
|
const currentProviderName=((ONBOARDING.status.setup||{}).current||{}).provider||'';
|
||||||
|
if(currentIsOauth){
|
||||||
|
const isReady=!!(ONBOARDING.status.system||{}).chat_ready;
|
||||||
|
const providerLabel=esc(currentProviderName);
|
||||||
|
if(isReady){
|
||||||
|
_setOnboardingNotice(t('onboarding_notice_setup_already_ready'),'success');
|
||||||
|
body.innerHTML=`
|
||||||
|
<div class="onboarding-oauth-card onboarding-oauth-ready">
|
||||||
|
<div class="onboarding-oauth-icon">✓</div>
|
||||||
|
<div>
|
||||||
|
<strong>${t('onboarding_oauth_provider_ready_title')}</strong>
|
||||||
|
<p>${t('onboarding_oauth_provider_ready_body').replace('{provider}',providerLabel)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_provider_label')}</span>
|
||||||
|
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||||
|
<span>${t('onboarding_api_key_label')}</span>
|
||||||
|
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||||
|
</label>
|
||||||
|
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||||
|
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||||
|
} else {
|
||||||
|
_setOnboardingNotice(t('onboarding_notice_setup_required'),'warn');
|
||||||
|
body.innerHTML=`
|
||||||
|
<div class="onboarding-oauth-card onboarding-oauth-pending">
|
||||||
|
<div class="onboarding-oauth-icon">⚠</div>
|
||||||
|
<div>
|
||||||
|
<strong>${t('onboarding_oauth_provider_not_ready_title')}</strong>
|
||||||
|
<p>${t('onboarding_oauth_provider_not_ready_body').replace('{provider}',providerLabel)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="onboarding-copy" style="margin-top:20px">${t('onboarding_oauth_switch_hint')}</p>
|
||||||
|
<label class="onboarding-field">
|
||||||
|
<span>${t('onboarding_provider_label')}</span>
|
||||||
|
<select id="onboardingProviderSelect" onchange="syncOnboardingProvider(this.value)">${options}</select>
|
||||||
|
</label>
|
||||||
|
<label class="onboarding-field" id="onboardingApiKeyField">
|
||||||
|
<span>${t('onboarding_api_key_label')}</span>
|
||||||
|
<input id="onboardingApiKeyInput" type="password" value="${esc(ONBOARDING.form.apiKey||'')}" placeholder="${t('onboarding_api_key_placeholder')}" oninput="ONBOARDING.form.apiKey=this.value">
|
||||||
|
</label>
|
||||||
|
${showBaseUrl?`<label class="onboarding-field"><span>${t('onboarding_base_url_label')}</span><input id="onboardingBaseUrlInput" value="${esc(ONBOARDING.form.baseUrl||'')}" placeholder="${t('onboarding_base_url_placeholder')}" oninput="ONBOARDING.form.baseUrl=this.value"></label>`:''}
|
||||||
|
<p class="onboarding-copy">${keyHelp}</p>`;
|
||||||
|
}
|
||||||
|
const providerSel=$('onboardingProviderSelect');
|
||||||
|
if(providerSel) providerSel.value=ONBOARDING.form.provider;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
|
_setOnboardingNotice(system.chat_ready?t('onboarding_notice_setup_already_ready'):t('onboarding_notice_setup_required'),system.chat_ready?'success':'info');
|
||||||
body.innerHTML=`
|
body.innerHTML=`
|
||||||
<label class="onboarding-field">
|
<label class="onboarding-field">
|
||||||
|
|||||||
@@ -214,6 +214,15 @@
|
|||||||
.onboarding-summary div{padding:14px;border-radius:14px;background:rgba(255,255,255,.03);border:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
|
.onboarding-summary div{padding:14px;border-radius:14px;background:rgba(255,255,255,.03);border:1px solid var(--border);display:flex;flex-direction:column;gap:5px;}
|
||||||
.onboarding-summary strong{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);}
|
.onboarding-summary strong{font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);}
|
||||||
.onboarding-summary span{font-size:13px;color:var(--text);word-break:break-word;}
|
.onboarding-summary span{font-size:13px;color:var(--text);word-break:break-word;}
|
||||||
|
.onboarding-oauth-card{display:flex;align-items:flex-start;gap:14px;padding:16px 18px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.03);margin-bottom:4px;}
|
||||||
|
.onboarding-oauth-card p{margin:6px 0 0;font-size:13px;color:var(--muted);line-height:1.5;}
|
||||||
|
.onboarding-oauth-card strong{font-size:13px;color:var(--text);}
|
||||||
|
.onboarding-oauth-card code{font-size:12px;background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;}
|
||||||
|
.onboarding-oauth-icon{font-size:18px;flex-shrink:0;margin-top:1px;}
|
||||||
|
.onboarding-oauth-ready{border-color:rgba(124,185,255,.28);background:rgba(124,185,255,.08);}
|
||||||
|
.onboarding-oauth-ready .onboarding-oauth-icon{color:#7cb9ff;}
|
||||||
|
.onboarding-oauth-pending{border-color:rgba(201,168,76,.25);background:rgba(201,168,76,.08);}
|
||||||
|
.onboarding-oauth-pending .onboarding-oauth-icon{color:#c9a84c;}
|
||||||
.onboarding-actions{display:flex;justify-content:space-between;gap:10px;margin-top:auto;}
|
.onboarding-actions{display:flex;justify-content:space-between;gap:10px;margin-top:auto;}
|
||||||
.onboarding-actions .sm-btn{padding:10px 16px;}
|
.onboarding-actions .sm-btn{padding:10px 16px;}
|
||||||
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
.reconnect-banner{display:none;background:var(--surface);border:1px solid rgba(201,168,76,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--gold);display:none;align-items:center;justify-content:space-between;gap:12px;}
|
||||||
|
|||||||
162
tests/test_sprint40.py
Normal file
162
tests/test_sprint40.py
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
Sprint 40 Tests: OAuth provider onboarding path (PR B of issue #329).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- _build_setup_catalog sets current_is_oauth=True for OAuth providers
|
||||||
|
- _build_setup_catalog sets current_is_oauth=False for API-key providers
|
||||||
|
- _build_setup_catalog sets current_is_oauth=False when no provider configured
|
||||||
|
- apply_onboarding_setup with unsupported provider marks onboarding complete directly
|
||||||
|
- i18n.js contains all required OAuth onboarding keys in both English and Spanish
|
||||||
|
"""
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import api.onboarding as mod
|
||||||
|
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).parent.parent
|
||||||
|
I18N_JS = (REPO_ROOT / "static" / "i18n.js").read_text()
|
||||||
|
ONBOARDING_JS = (REPO_ROOT / "static" / "onboarding.js").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Backend: _build_setup_catalog ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildSetupCatalog(unittest.TestCase):
|
||||||
|
|
||||||
|
def _catalog(self, provider, model="gpt-4o", base_url=""):
|
||||||
|
cfg = {}
|
||||||
|
if provider:
|
||||||
|
cfg = {"model": {"provider": provider, "default": model, "base_url": base_url}}
|
||||||
|
with patch.object(mod, "get_config", return_value=cfg):
|
||||||
|
return mod._build_setup_catalog(cfg)
|
||||||
|
|
||||||
|
def test_oauth_provider_sets_current_is_oauth_true(self):
|
||||||
|
"""openai-codex is not in _SUPPORTED_PROVIDER_SETUPS → current_is_oauth=True."""
|
||||||
|
catalog = self._catalog("openai-codex", "gpt-5.4")
|
||||||
|
self.assertTrue(catalog["current_is_oauth"],
|
||||||
|
"current_is_oauth must be True for openai-codex")
|
||||||
|
|
||||||
|
def test_copilot_provider_sets_current_is_oauth_true(self):
|
||||||
|
"""copilot is also OAuth."""
|
||||||
|
catalog = self._catalog("copilot")
|
||||||
|
self.assertTrue(catalog["current_is_oauth"])
|
||||||
|
|
||||||
|
def test_openai_provider_sets_current_is_oauth_false(self):
|
||||||
|
"""openai is in _SUPPORTED_PROVIDER_SETUPS → current_is_oauth=False."""
|
||||||
|
catalog = self._catalog("openai", "gpt-4o")
|
||||||
|
self.assertFalse(catalog["current_is_oauth"],
|
||||||
|
"current_is_oauth must be False for API-key provider openai")
|
||||||
|
|
||||||
|
def test_anthropic_provider_sets_current_is_oauth_false(self):
|
||||||
|
catalog = self._catalog("anthropic", "claude-sonnet-4.6")
|
||||||
|
self.assertFalse(catalog["current_is_oauth"])
|
||||||
|
|
||||||
|
def test_no_provider_sets_current_is_oauth_false(self):
|
||||||
|
"""Empty config → current_is_oauth=False."""
|
||||||
|
catalog = self._catalog("")
|
||||||
|
self.assertFalse(catalog["current_is_oauth"])
|
||||||
|
|
||||||
|
def test_catalog_includes_current_is_oauth_key(self):
|
||||||
|
"""current_is_oauth must always be present in the catalog dict."""
|
||||||
|
catalog = self._catalog("openrouter")
|
||||||
|
self.assertIn("current_is_oauth", catalog)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Backend: apply_onboarding_setup for OAuth providers ────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestApplyOnboardingOAuthPath(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_unsupported_provider_skips_to_complete(self):
|
||||||
|
"""apply_onboarding_setup with an OAuth provider just marks onboarding done."""
|
||||||
|
saved = {}
|
||||||
|
|
||||||
|
def _save(d):
|
||||||
|
saved.update(d)
|
||||||
|
|
||||||
|
mock_status = {"completed": True, "system": {"chat_ready": True}}
|
||||||
|
|
||||||
|
with patch.object(mod, "save_settings", side_effect=_save), \
|
||||||
|
patch.object(mod, "get_onboarding_status", return_value=mock_status):
|
||||||
|
result = mod.apply_onboarding_setup({"provider": "openai-codex", "model": "gpt-5.4"})
|
||||||
|
|
||||||
|
self.assertTrue(saved.get("onboarding_completed"),
|
||||||
|
"save_settings must set onboarding_completed=True for OAuth provider")
|
||||||
|
self.assertEqual(result, mock_status)
|
||||||
|
|
||||||
|
def test_unsupported_provider_does_not_write_config_yaml(self):
|
||||||
|
"""OAuth path must not call _save_yaml_config — no config mutation."""
|
||||||
|
with patch.object(mod, "save_settings"), \
|
||||||
|
patch.object(mod, "get_onboarding_status", return_value={}), \
|
||||||
|
patch.object(mod, "_save_yaml_config") as mock_save_yaml:
|
||||||
|
mod.apply_onboarding_setup({"provider": "copilot", "model": "gpt-4o"})
|
||||||
|
|
||||||
|
mock_save_yaml.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Frontend: i18n keys ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
_REQUIRED_OAUTH_KEYS = [
|
||||||
|
"onboarding_oauth_provider_ready_title",
|
||||||
|
"onboarding_oauth_provider_ready_body",
|
||||||
|
"onboarding_oauth_provider_not_ready_title",
|
||||||
|
"onboarding_oauth_provider_not_ready_body",
|
||||||
|
"onboarding_oauth_switch_hint",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthI18nKeys(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_english_locale_has_all_oauth_keys(self):
|
||||||
|
"""All OAuth onboarding i18n keys must be present in the English locale."""
|
||||||
|
missing = [k for k in _REQUIRED_OAUTH_KEYS if k not in I18N_JS]
|
||||||
|
self.assertFalse(missing,
|
||||||
|
f"English locale missing OAuth keys: {missing}")
|
||||||
|
|
||||||
|
def test_spanish_locale_has_all_oauth_keys(self):
|
||||||
|
"""All OAuth onboarding i18n keys must be present in the Spanish locale."""
|
||||||
|
# Spanish locale is the second occurrence of each key
|
||||||
|
counts = {k: I18N_JS.count(k) for k in _REQUIRED_OAUTH_KEYS}
|
||||||
|
under = [k for k, c in counts.items() if c < 2]
|
||||||
|
self.assertFalse(under,
|
||||||
|
f"Spanish locale missing OAuth keys (need 2 occurrences each): {under}")
|
||||||
|
|
||||||
|
def test_oauth_body_strings_contain_provider_placeholder(self):
|
||||||
|
"""Body strings must contain {provider} so JS can substitute the provider name."""
|
||||||
|
for key in ["onboarding_oauth_provider_ready_body",
|
||||||
|
"onboarding_oauth_provider_not_ready_body"]:
|
||||||
|
self.assertIn("{provider}", I18N_JS,
|
||||||
|
f"{key} must contain {{provider}} placeholder")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Frontend: onboarding.js uses current_is_oauth ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthOnboardingJs(unittest.TestCase):
|
||||||
|
|
||||||
|
def test_onboarding_js_reads_current_is_oauth(self):
|
||||||
|
"""onboarding.js must check current_is_oauth from the status payload."""
|
||||||
|
self.assertIn("current_is_oauth", ONBOARDING_JS,
|
||||||
|
"onboarding.js must read current_is_oauth from ONBOARDING.status.setup")
|
||||||
|
|
||||||
|
def test_onboarding_js_renders_oauth_ready_card(self):
|
||||||
|
"""onboarding.js must render the oauth-ready card class."""
|
||||||
|
self.assertIn("onboarding-oauth-ready", ONBOARDING_JS)
|
||||||
|
|
||||||
|
def test_onboarding_js_renders_oauth_pending_card(self):
|
||||||
|
"""onboarding.js must render the oauth-pending card class."""
|
||||||
|
self.assertIn("onboarding-oauth-pending", ONBOARDING_JS)
|
||||||
|
|
||||||
|
def test_style_css_has_oauth_card_rules(self):
|
||||||
|
"""style.css must contain the .onboarding-oauth-card rules."""
|
||||||
|
css = (REPO_ROOT / "static" / "style.css").read_text()
|
||||||
|
self.assertIn("onboarding-oauth-card", css)
|
||||||
|
self.assertIn("onboarding-oauth-ready", css)
|
||||||
|
self.assertIn("onboarding-oauth-pending", css)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user