"""Node-driven tests for the DOM-free ChatGPT usage card module + admin wiring.""" import json import shutil import subprocess from pathlib import Path import pytest _REPO = Path(__file__).resolve().parent.parent _MODULE = _REPO / "static" / "js" / "chatgptSubscriptionUsage.js" _ADMIN = (_REPO / "static" / "js" / "admin.js").read_text(encoding="utf-8") _STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8") pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH") def _run_node(script: str): proc = subprocess.run( ["node", "--input-type=module"], input=script, capture_output=True, text=True, cwd=str(_REPO), timeout=30, ) assert proc.returncode == 0, proc.stderr return json.loads(proc.stdout.strip()) _PAYLOAD_A = { "available": True, "account": {"auth_id": "auth-a", "label": "codex00", "name": "ChatGPT · codex00"}, "usage": { "auth_id": "auth-a", "plan_type": "plus", "account_id": "acct_a", "ordinary_usage_allowed": True, "rate_limit_reached_type": None, "fetched_at": 1_800_000_000, "cached": False, "limits": [ {"limit_id": "codex", "limit_name": None, "normal_model_slug": None, "allowed": True, "limit_reached": False, "windows": [ {"kind": "primary", "name": "5H", "used_percent": 71, "remaining_percent": 29, "window_minutes": 300, "resets_at": 1_800_000_000 + 2 * 3600 + 14 * 60, "reset_after_seconds": 8040}, {"kind": "secondary", "name": "WEEK", "used_percent": 28, "remaining_percent": 72, "window_minutes": 10080, "resets_at": 1_800_000_000 + 4 * 86400 + 18 * 3600, "reset_after_seconds": 1}, ]}, {"limit_id": "codex_pro", "limit_name": "GPT-5.5 Pro", "normal_model_slug": "gpt-5.5-pro", "allowed": True, "limit_reached": False, "windows": [{"kind": "primary", "name": "1H", "used_percent": 5, "remaining_percent": 95, "window_minutes": 60, "resets_at": None, "reset_after_seconds": None}]}, {"limit_id": "mystery", "limit_name": "Mystery", "normal_model_slug": None, "allowed": None, "limit_reached": None, "windows": []}, ], }, } _PAYLOAD_B = { "available": True, "account": {"auth_id": "auth-b", "label": "codex01", "name": "ChatGPT · codex01"}, "usage": {"plan_type": "pro", "limits": [{"limit_id": "codex", "windows": [{"kind": "primary", "name": "5H", "used_percent": 100, "window_minutes": 300}]}]}, } def test_view_model_normalizes_windows_and_reset_countdowns(): js = f""" import {{ buildUsageViewModel }} from '{_MODULE.as_posix()}'; const vm = buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000); console.log(JSON.stringify(vm)); """ vm = _run_node(js) assert vm["available"] is True assert vm["authId"] == "auth-a" assert vm["plan"] == "Plus" codex, pro, mystery = vm["limits"] assert codex["title"] == "" primary, secondary = codex["windows"] assert primary["name"] == "5H" assert primary["usedLabel"] == "71% used" assert primary["remainingLabel"] == "29% remaining" assert primary["resetLabel"] == "resets in 2h 14m" assert secondary["name"] == "WEEK" assert secondary["remainingLabel"] == "72% remaining" assert secondary["resetLabel"] == "resets in 4d 18h" # Additional bucket is kept with its own title/model; missing reset is not invented. assert pro["title"] == "GPT-5.5 Pro" and pro["modelSlug"] == "gpt-5.5-pro" assert pro["windows"][0]["resetLabel"] == "" assert mystery["windows"] == [] def test_view_model_is_defensive_about_bad_values(): payload = { "available": True, "account": {"auth_id": "auth-x"}, "usage": {"plan_type": 42, "limits": [ {"limit_id": "codex", "windows": [{"kind": "primary", "used_percent": "abc", "window_minutes": "300", "resets_at": "soon"}, None, "str"]}, "garbage", {"limit_id": "over", "windows": [{"used_percent": 250, "resets_at": 5}]}, ]}, } js = f""" import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}'; const vm = buildUsageViewModel({json.dumps(payload)}, 10); const html = renderUsageCardHtml(vm, {{ endpointId: 'ep-x' }}); console.log(JSON.stringify({{ vm, html }})); """ out = _run_node(js) vm = out["vm"] assert vm["plan"] == "" codex, over = vm["limits"] assert codex["windows"][0]["usedPercent"] is None assert codex["windows"][0]["usedLabel"] == "usage unknown" assert codex["windows"][0]["resetLabel"] == "" assert over["windows"][0]["usedPercent"] == 100 assert over["windows"][0]["remainingPercent"] == 0 assert over["windows"][0]["resetLabel"] == "resets now" assert 'aria-valuenow' not in out["html"].split('data-usage-limit="over"')[0] assert 'aria-valuenow="100"' in out["html"] def test_unavailable_states_render_message_and_refresh_button(): cases = { "reauth": {"available": False, "reason": "reauth", "reconnect_suggested": True, "account": {"auth_id": "auth-a"}}, "rate_limited": {"available": False, "reason": "rate_limited", "account": {"auth_id": "auth-a"}}, "timeout": {"available": False, "reason": "timeout", "account": {"auth_id": "auth-a"}}, "malformed": None, "empty": {}, } js = f""" import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}'; const cases = {json.dumps(cases)}; const out = {{}}; for (const [k, payload] of Object.entries(cases)) {{ const vm = buildUsageViewModel(payload, 0); out[k] = {{ vm, html: renderUsageCardHtml(vm, {{ endpointId: 'ep-a' }}) }}; }} console.log(JSON.stringify(out)); """ out = _run_node(js) assert out["reauth"]["vm"]["message"] == "Usage unavailable — account may need reconnecting" assert out["reauth"]["vm"]["reconnectSuggested"] is True assert "rate limited" in out["rate_limited"]["vm"]["message"] assert "timed out" in out["timeout"]["vm"]["message"] assert out["malformed"]["vm"]["available"] is False assert out["empty"]["vm"]["message"] == "Usage unavailable" for case in out.values(): assert "adm-chatgpt-usage-unavailable" in case["html"] assert 'data-adm-chatgpt-usage-refresh=' in case["html"] assert ">Refresh usage<" in case["html"] def test_two_account_cards_render_independently_with_exact_ids(): js = f""" import {{ buildUsageViewModel, renderUsageCardHtml }} from '{_MODULE.as_posix()}'; const a = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_A)}, 1800000000), {{ endpointId: 'ep-a' }}); const b = renderUsageCardHtml(buildUsageViewModel({json.dumps(_PAYLOAD_B)}, 1800000000), {{ endpointId: 'ep-b' }}); console.log(JSON.stringify({{ a, b }})); """ out = _run_node(js) a, b = out["a"], out["b"] assert 'data-adm-chatgpt-usage="auth-a"' in a and 'data-adm-chatgpt-usage="auth-b"' in b assert 'data-adm-chatgpt-usage-refresh="auth-a" data-chatgpt-endpoint-id="ep-a"' in a assert 'data-adm-chatgpt-reconnect="auth-a" data-chatgpt-endpoint-id="ep-a"' in a assert 'data-adm-chatgpt-usage-refresh="auth-b" data-chatgpt-endpoint-id="ep-b"' in b assert 'data-adm-chatgpt-reconnect="auth-b" data-chatgpt-endpoint-id="ep-b"' in b assert "auth-b" not in a and "auth-a" not in b assert ">Plus<" in a and ">Pro<" in b assert "29% remaining" in a and "72% remaining" in a assert "resets in 2h 14m" in a and "resets in 4d 18h" in a assert "GPT-5.5 Pro" in a and "gpt-5.5-pro" in a assert "0% remaining" in b and "adm-chatgpt-usage-critical" in b assert a.count("adm-chatgpt-usage-row") == 3 # 5H + WEEK + additional bucket def test_rendered_html_escapes_and_contains_no_credentials(): payload = { "available": True, "account": {"auth_id": "auth-a", "label": ""}, "usage": {"plan_type": "plus", "limits": [{"limit_id": "codex", "limit_name": "