"""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": "