Overflow: the persisted-cell note badge is white-space:nowrap, so the classifier.degraded_warn_min note set the column's min-content width and the Knobs table measured 821px in a 797px frame (Layer column cut off). The badge now wraps inside its own pill: 733px at 800, the pre-taxonomy figure. The previous rule targeted .setting-control .ms-2, but d-inline-flex is !important and the cell is a plain td, so it never matched. Seeding: Advanced accordions were seeded collapsed once, on the first render. The runtime payload lands before the config payload and watchdog.detector.* are persisted-only, so Watchdog had no advanced rows on that render, was marked seeded anyway, and loaded EXPANDED. seedAdvancedCollapsed now seeds a category the first time it renders an advanced row. It is a pure function between SEED_ADV_COLLAPSED markers, covered by three node tests (a config render after a runtime-only render stays collapsed; a toggled-open section survives re-render; a plain-only category is not marked seeded). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KkCGRantZsSwmcFpet6FTa
690 lines
29 KiB
Python
690 lines
29 KiB
Python
"""Shared harness for running admin JS units through node.
|
|
|
|
Why execute the file instead of re-deriving its behaviour in Python: a
|
|
Python re-implementation of the JS is a second copy waiting to drift from
|
|
the code it claims to test -- the exact failure mode navbar.js was
|
|
extracted to end. These tests slice the marker-delimited blocks out of
|
|
``admin/frontend/navbar.js`` and feed the real source to ``node -e``, so
|
|
a regression is caught where it lives.
|
|
|
|
Every test that shells out to node is skipped when node is absent, so
|
|
the suite stays green on hosts without it; where node IS installed the
|
|
contract is exercised for real. Items later in the cockpit plan append
|
|
further marker-based node tests to the helpers below.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
NAVBAR_JS = ROOT / "admin" / "frontend" / "navbar.js"
|
|
|
|
NAV_LINKS_BEGIN = "/* NAV_LINKS:BEGIN */"
|
|
NAV_LINKS_END = "/* NAV_LINKS:END */"
|
|
ACTIVE_NAV_HREF_BEGIN = "/* ACTIVE_NAV_HREF:BEGIN */"
|
|
ACTIVE_NAV_HREF_END = "/* ACTIVE_NAV_HREF:END */"
|
|
|
|
skip_without_node = pytest.mark.skipif(
|
|
shutil.which("node") is None, reason="node not installed"
|
|
)
|
|
|
|
|
|
def _extract_between(path: Path, begin_marker: str, end_marker: str) -> str:
|
|
"""The text of ``path`` between two marker comments, markers excluded.
|
|
|
|
Both markers must be present and in order; anything else is a broken
|
|
extraction (the markers are load-bearing for this harness), not a
|
|
test failure to report softly.
|
|
"""
|
|
text = path.read_text(encoding="utf-8")
|
|
begin = text.find(begin_marker)
|
|
end = text.find(end_marker)
|
|
assert begin != -1, f"{path.name}: marker {begin_marker!r} not found"
|
|
assert end > begin, f"{path.name}: marker {end_marker!r} absent or before begin"
|
|
return text[begin + len(begin_marker) : end]
|
|
|
|
|
|
def _run_node(js_source: str) -> subprocess.CompletedProcess[str]:
|
|
"""Run ``js_source`` through ``node -e``; fail loudly on a nonzero exit."""
|
|
result = subprocess.run(
|
|
["node", "-e", js_source],
|
|
capture_output=True,
|
|
text=True,
|
|
check=False,
|
|
timeout=30,
|
|
)
|
|
assert result.returncode == 0, (
|
|
f"node exited {result.returncode}\nstdout: {result.stdout}\nstderr: {result.stderr}"
|
|
)
|
|
return result
|
|
|
|
|
|
def _navbar_units_source() -> str:
|
|
"""NAV_LINKS + activeNavHref from navbar.js, in file order, runnable as-is."""
|
|
return (
|
|
_extract_between(NAVBAR_JS, NAV_LINKS_BEGIN, NAV_LINKS_END)
|
|
+ "\n"
|
|
+ _extract_between(NAVBAR_JS, ACTIVE_NAV_HREF_BEGIN, ACTIVE_NAV_HREF_END)
|
|
)
|
|
|
|
|
|
def _assert_against_navbar_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
|
|
"""Assert ``assertion_js`` against the real NAV_LINKS/activeNavHref source."""
|
|
return _run_node(
|
|
"const assert = require('assert');\n" + _navbar_units_source() + "\n" + assertion_js
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_active_nav_href_lights_up_exactly_the_page_it_is_on():
|
|
"""Every served page but the landing page is in NAV_LINKS, so each one's
|
|
own pathname resolves to its own href."""
|
|
assertions = [
|
|
"assert.strictEqual(activeNavHref('/admin/models', NAV_LINKS), '/admin/models');",
|
|
"assert.strictEqual(activeNavHref('/admin/profiles', NAV_LINKS), '/admin/profiles');",
|
|
"assert.strictEqual(activeNavHref('/admin/proficiency', NAV_LINKS), '/admin/proficiency');",
|
|
"assert.strictEqual(activeNavHref('/admin/decisions', NAV_LINKS), '/admin/decisions');",
|
|
"assert.strictEqual(activeNavHref('/admin/quota', NAV_LINKS), '/admin/quota');",
|
|
"assert.strictEqual(activeNavHref('/admin/controls', NAV_LINKS), '/admin/controls');",
|
|
"assert.strictEqual(activeNavHref('/admin/providers', NAV_LINKS), '/admin/providers');",
|
|
]
|
|
_assert_against_navbar_units("\n".join(assertions))
|
|
|
|
|
|
@skip_without_node
|
|
@pytest.mark.parametrize(
|
|
"pathname",
|
|
[
|
|
"/admin", # prefix of everything, equal to nothing
|
|
"/admin/", # the landing page has no nav entry -- the logo is its door
|
|
"/admin/model", # near-miss singular
|
|
"/models", # missing the /admin prefix entirely
|
|
"/definitely-not-a-page",
|
|
"",
|
|
],
|
|
)
|
|
def test_active_nav_href_is_an_exact_match_or_null(pathname):
|
|
"""No prefix, no substring: a pathname that is not literally one of the
|
|
rendered hrefs highlights nothing, even though every href starts with
|
|
/admin/."""
|
|
_assert_against_navbar_units(
|
|
f"assert.strictEqual(activeNavHref({pathname!r}, NAV_LINKS), null);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_active_nav_href_takes_its_links_from_the_argument():
|
|
"""activeNavHref is pure: it reads the links it is handed, nothing global."""
|
|
_assert_against_navbar_units(
|
|
"assert.strictEqual(activeNavHref('/x', [{ href: '/x', label: 'X' }]), '/x');\n"
|
|
"assert.strictEqual(activeNavHref('/x', []), null);\n"
|
|
"assert.strictEqual(activeNavHref('/y', [{ href: '/x', label: 'X' }]), null);"
|
|
)
|
|
|
|
|
|
DECISIONS_HTML = ROOT / "admin" / "frontend" / "decisions.html"
|
|
RESOLVE_URL_FILTER_VALUES_BEGIN = "/* RESOLVE_URL_FILTER_VALUES:BEGIN */"
|
|
RESOLVE_URL_FILTER_VALUES_END = "/* RESOLVE_URL_FILTER_VALUES:END */"
|
|
|
|
|
|
def _assert_against_decisions_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
|
|
"""Assert ``assertion_js`` against the real resolveUrlFilterValues source."""
|
|
return _run_node(
|
|
"const assert = require('assert');\n"
|
|
+ _extract_between(
|
|
DECISIONS_HTML, RESOLVE_URL_FILTER_VALUES_BEGIN, RESOLVE_URL_FILTER_VALUES_END
|
|
)
|
|
+ "\n"
|
|
+ assertion_js
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_resolve_url_filter_values_known_size_passes_through():
|
|
"""A size within the page-size options is applied as-is."""
|
|
_assert_against_decisions_units(
|
|
"const r = resolveUrlFilterValues(\n"
|
|
" { size: '250' },\n"
|
|
" { kind: [], category: [], profile: [], tier: [], size: ['50', '100', '250'] },\n"
|
|
");\n"
|
|
"assert.strictEqual(r.values.size, '250');\n"
|
|
"assert.strictEqual(r.sizeFallback, false);\n"
|
|
"assert.deepStrictEqual(r.addOptions, []);\n"
|
|
"assert.strictEqual(r.values.kind, null);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_resolve_url_filter_values_unknown_size_falls_back_and_is_not_added():
|
|
"""size=999 falls back to the default 100 and is never added as an option."""
|
|
_assert_against_decisions_units(
|
|
"const r = resolveUrlFilterValues(\n"
|
|
" { size: '999' },\n"
|
|
" { kind: [], category: [], profile: [], tier: [], size: ['50', '100', '250'] },\n"
|
|
");\n"
|
|
"assert.strictEqual(r.values.size, '100');\n"
|
|
"assert.strictEqual(r.sizeFallback, true);\n"
|
|
"assert.deepStrictEqual(r.addOptions, []);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_resolve_url_filter_values_unknown_category_is_added_as_an_option():
|
|
"""An unknown category is flagged add-as-option; tier follows the same rule."""
|
|
_assert_against_decisions_units(
|
|
"const r = resolveUrlFilterValues(\n"
|
|
" { category: 'diff_checking' },\n"
|
|
" { category: ['routing', 'triage'], tier: ['1', '2', '3'] },\n"
|
|
");\n"
|
|
"assert.strictEqual(r.values.category, 'diff_checking');\n"
|
|
"assert.deepStrictEqual(\n"
|
|
" r.addOptions, [{ id: 'f-category', value: 'diff_checking' }]\n"
|
|
");\n"
|
|
"const t = resolveUrlFilterValues(\n"
|
|
" { tier: '4' },\n"
|
|
" { category: ['routing'], tier: ['1', '2', '3'] },\n"
|
|
");\n"
|
|
"assert.deepStrictEqual(t.addOptions, [{ id: 'f-tier', value: '4' }]);\n"
|
|
"assert.strictEqual(t.values.tier, '4');\n"
|
|
"assert.strictEqual(t.values.category, null);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_resolve_url_filter_values_empty_or_absent_params_leave_controls_untouched():
|
|
"""Empty and absent params produce null values everywhere - no control moves."""
|
|
_assert_against_decisions_units(
|
|
"const untouched = { kind: null, category: null, profile: null, tier: null, search: null, size: null };\n"
|
|
"const empty = resolveUrlFilterValues(\n"
|
|
" { kind: '', category: '', profile: '', tier: '', q: '', size: '' },\n"
|
|
" { kind: ['a'], size: ['50', '100', '250'] },\n"
|
|
");\n"
|
|
"assert.deepStrictEqual(empty.values, untouched);\n"
|
|
"assert.deepStrictEqual(empty.addOptions, []);\n"
|
|
"assert.strictEqual(empty.sizeFallback, false);\n"
|
|
"const absent = resolveUrlFilterValues({}, {});\n"
|
|
"assert.deepStrictEqual(absent.values, untouched);\n"
|
|
"assert.deepStrictEqual(absent.addOptions, []);\n"
|
|
"assert.strictEqual(absent.sizeFallback, false);"
|
|
)
|
|
|
|
|
|
INDEX_HTML = ROOT / "admin" / "frontend" / "index.html"
|
|
WARN_SEVERITY_BEGIN = "/* WARN_SEVERITY:BEGIN */"
|
|
WARN_SEVERITY_END = "/* WARN_SEVERITY:END */"
|
|
DISMISS_UNITS_BEGIN = "/* DISMISS_UNITS:BEGIN */"
|
|
DISMISS_UNITS_END = "/* DISMISS_UNITS:END */"
|
|
|
|
|
|
def _assert_against_dismiss_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
|
|
"""Assert ``assertion_js`` against the real dismissal key units.
|
|
|
|
The units (normalizeDigits, dismissKey, migrateDismissedKeys,
|
|
pruneDismissed, openWarnings) live in index.html between the
|
|
DISMISS_UNITS markers and call two neighbours: warningSeverity (its own
|
|
marker pair) and escapeHtml (a one-liner in the UTILITIES section,
|
|
pulled by regex). Feeding all three together reproduces the page's
|
|
runtime environment without a DOM.
|
|
"""
|
|
text = INDEX_HTML.read_text(encoding="utf-8")
|
|
escape = re.search(r"function escapeHtml\(s\) \{.*?\n\}", text, re.DOTALL)
|
|
assert escape, "index.html: escapeHtml source not found"
|
|
return _run_node(
|
|
"const assert = require('assert');\n"
|
|
+ escape.group(0)
|
|
+ "\n"
|
|
+ _extract_between(INDEX_HTML, WARN_SEVERITY_BEGIN, WARN_SEVERITY_END)
|
|
+ "\n"
|
|
+ _extract_between(INDEX_HTML, DISMISS_UNITS_BEGIN, DISMISS_UNITS_END)
|
|
+ "\n"
|
|
+ assertion_js
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_dismiss_key_includes_severity():
|
|
"""The severity is the key's first component, ahead of the normalized,
|
|
escaped text."""
|
|
_assert_against_dismiss_units(
|
|
"assert.strictEqual(\n"
|
|
" dismissKey('plan pace 1.2x sustainable', 'high'),\n"
|
|
" 'high:plan pace Nx sustainable'\n"
|
|
");\n"
|
|
"assert.strictEqual(dismissKey('classifier degraded', 'medium'), 'medium:classifier degraded');"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_dismiss_key_differs_by_severity():
|
|
"""Severity-keying is what keeps digit normalization from fusing
|
|
different warnings: '0 eligible' is high and '3 eligible' is medium even
|
|
though both normalize to 'N eligible'."""
|
|
_assert_against_dismiss_units(
|
|
"assert.strictEqual(dismissKey('0 eligible models', 'high'), 'high:N eligible models');\n"
|
|
"assert.notStrictEqual(\n"
|
|
" dismissKey('0 eligible models', 'high'),\n"
|
|
" dismissKey('0 eligible models', 'medium')\n"
|
|
");"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_migrate_digit_normalizes_to_one_key():
|
|
"""A count that moved between polls must not move the stored key: the
|
|
3-count and 50-count forms of one warning migrate to the same
|
|
digit-normalized key, and an entry already in the new format passes
|
|
through untouched so re-migration cannot mangle the store."""
|
|
_assert_against_dismiss_units(
|
|
"const three = migrateDismissedKeys(['3/52 routable models have no proficiency data']);\n"
|
|
"const fifty = migrateDismissedKeys(['50/52 routable models have no proficiency data']);\n"
|
|
"assert.deepStrictEqual([...three], [...fifty]);\n"
|
|
"assert.strictEqual([...three].length, 1);\n"
|
|
"assert.strictEqual(\n"
|
|
" [...three][0],\n"
|
|
" 'medium:N/N routable models have no proficiency data'\n"
|
|
");\n"
|
|
"assert.deepStrictEqual(\n"
|
|
" [...migrateDismissedKeys(['medium:N/N routable models have no proficiency data'])],\n"
|
|
" ['medium:N/N routable models have no proficiency data']\n"
|
|
");"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_same_warning_different_count_both_suppressed():
|
|
"""Dismissing one count of a warning suppresses the others: once the
|
|
3-count version is dismissed, the 50-count re-scan is already hidden,
|
|
and the key stays live so pruning does not drop it."""
|
|
_assert_against_dismiss_units(
|
|
"const dismissed = migrateDismissedKeys(['3/52 routable models have no proficiency data']);\n"
|
|
"assert.strictEqual(openWarnings(['3/52 routable models have no proficiency data'], dismissed).length, 0);\n"
|
|
"assert.strictEqual(openWarnings(['50/52 routable models have no proficiency data'], dismissed).length, 0);\n"
|
|
"assert.strictEqual(\n"
|
|
" pruneDismissed(['50/52 routable models have no proficiency data'], dismissed),\n"
|
|
" false\n"
|
|
");"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_stale_dismissal_pruned_and_new_warnings_open():
|
|
"""A dismissed warning whose text is gone has been resolved: the stale
|
|
key is pruned on the next render, nothing stays dismissed, and all 50
|
|
fresh warnings are open."""
|
|
_assert_against_dismiss_units(
|
|
"const dismissed = migrateDismissedKeys(['3 eligible models']);\n"
|
|
"const fresh = Array.from({ length: 50 }, (_, i) =>\n"
|
|
" 'issue ' + String.fromCharCode(65 + Math.floor(i / 26))\n"
|
|
" + String.fromCharCode(97 + (i % 26)));\n"
|
|
"assert.strictEqual(pruneDismissed(fresh, dismissed), true);\n"
|
|
"assert.strictEqual(dismissed.size, 0);\n"
|
|
"assert.strictEqual(openWarnings(fresh, dismissed).length, 50);"
|
|
)
|
|
|
|
|
|
CONTROLS_HTML = ROOT / "admin" / "frontend" / "controls.html"
|
|
MERGE_KNOB_ROWS_BEGIN = "/* MERGE_KNOB_ROWS:BEGIN */"
|
|
MERGE_KNOB_ROWS_END = "/* MERGE_KNOB_ROWS:END */"
|
|
|
|
|
|
def _assert_against_merge_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
|
|
"""Assert ``assertion_js`` against the real mergeKnobRows source.
|
|
|
|
The units (normalizeKnobValue, knobValuesDrift, mergeKnobRows) live in
|
|
controls.html between the MERGE_KNOB_ROWS markers and are self-contained:
|
|
they take the two API payloads as arguments and touch no DOM, so the
|
|
extraction runs as-is under node.
|
|
"""
|
|
return _run_node(
|
|
"const assert = require('assert');\n"
|
|
+ _extract_between(CONTROLS_HTML, MERGE_KNOB_ROWS_BEGIN, MERGE_KNOB_ROWS_END)
|
|
+ "\n"
|
|
+ assertion_js
|
|
)
|
|
|
|
|
|
RUNTIME_BOTH_AND_ONLY = (
|
|
"{\n"
|
|
" log_route_decisions: { persisted: true, runtime: false, config_key: 'logging.log_route_decisions' },\n"
|
|
" circuit_breaker_enabled: { persisted: true, runtime: true, config_key: 'circuit_breaker.enabled' },\n"
|
|
"}"
|
|
)
|
|
PERSISTED_BOTH_AND_ONLY = (
|
|
"{\n"
|
|
" 'logging.level': { value: 'info', source: 'base' },\n"
|
|
" 'circuit_breaker.enabled': { value: true, source: 'base' },\n"
|
|
" 'objective.quality_tolerance': { value: 0.7, source: 'base' },\n"
|
|
"}"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_merge_knob_rows_row_set_is_the_union_of_both_key_sets():
|
|
"""Runtime rows pair by the config_key the API carries; persisted rows by
|
|
their own key. The row set is the union: a one-sided knob still gets a
|
|
row, with the other column empty."""
|
|
_assert_against_merge_units(
|
|
f"const rows = mergeKnobRows({RUNTIME_BOTH_AND_ONLY}, {PERSISTED_BOTH_AND_ONLY});\n"
|
|
"assert.deepStrictEqual(Object.keys(rows).sort(), [\n"
|
|
" 'circuit_breaker.enabled',\n"
|
|
" 'logging.level',\n"
|
|
" 'logging.log_route_decisions',\n"
|
|
" 'objective.quality_tolerance',\n"
|
|
"]);\n"
|
|
"assert.strictEqual(rows['logging.log_route_decisions'].persistedItem, null);\n"
|
|
"assert.strictEqual(rows['logging.log_route_decisions'].runtimeItem.knob, 'log_route_decisions');\n"
|
|
"assert.strictEqual(rows['objective.quality_tolerance'].runtimeItem, null);\n"
|
|
"assert.strictEqual(rows['objective.quality_tolerance'].persistedItem.value, 0.7);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_merge_knob_rows_one_sided_inputs_yield_one_sided_rows():
|
|
"""Only one payload present (a failed fetch, or the runtime fetch landing
|
|
first): rows render from what is available, never from nothing."""
|
|
_assert_against_merge_units(
|
|
"const runtimeOnly = mergeKnobRows(\n"
|
|
" { k: { persisted: true, runtime: false, config_key: 'a.enabled' } },\n"
|
|
" null\n"
|
|
");\n"
|
|
"assert.deepStrictEqual(Object.keys(runtimeOnly), ['a.enabled']);\n"
|
|
"assert.strictEqual(runtimeOnly['a.enabled'].persistedItem, null);\n"
|
|
"const persistedOnly = mergeKnobRows(null, { 'a.enabled': { value: true, source: 'base' } });\n"
|
|
"assert.deepStrictEqual(Object.keys(persistedOnly), ['a.enabled']);\n"
|
|
"assert.strictEqual(persistedOnly['a.enabled'].runtimeItem, null);\n"
|
|
"assert.deepStrictEqual(mergeKnobRows(null, null), {});"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_merge_knob_rows_drift_rule_follows_the_runtime_payload_only():
|
|
"""A row WITH a runtime item drifts when its runtime value differs from
|
|
the payload's persisted value -- both-sides rows and runtime-only rows
|
|
alike, because a restart reverts them both. A persisted-only row never
|
|
drifts: no live value exists."""
|
|
_assert_against_merge_units(
|
|
f"const rows = mergeKnobRows({RUNTIME_BOTH_AND_ONLY}, {PERSISTED_BOTH_AND_ONLY});\n"
|
|
"assert.strictEqual(rows['logging.log_route_decisions'].drift, true);\n"
|
|
"assert.strictEqual(rows['circuit_breaker.enabled'].drift, false);\n"
|
|
"assert.strictEqual(rows['objective.quality_tolerance'].drift, false);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_merge_knob_rows_ignores_the_persisted_columns_input_value():
|
|
"""Drift is computed from the runtime payload alone. A persisted column
|
|
holding an unsaved dirty edit ('debug') must not read as drift when the
|
|
router is running exactly what a restart would load ('info')."""
|
|
_assert_against_merge_units(
|
|
"const rows = mergeKnobRows(\n"
|
|
" { k: { persisted: 'info', runtime: 'info', config_key: 'logging.level' } },\n"
|
|
" { 'logging.level': { value: 'info', source: 'base' } }\n"
|
|
");\n"
|
|
"assert.strictEqual(rows['logging.level'].drift, false);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
@pytest.mark.parametrize(
|
|
"persisted,runtime,expect_drift",
|
|
[
|
|
("0.10", 0.1, False), # numbers compare numerically
|
|
(0.1, "0.10", False),
|
|
(2, 2.0, False),
|
|
(True, False, True),
|
|
(False, True, True),
|
|
("info", "debug", True),
|
|
(None, 5, False), # null/absent on either side is not drift
|
|
(5, None, False),
|
|
(None, None, False),
|
|
],
|
|
)
|
|
def test_merge_knob_rows_value_normalization(persisted, runtime, expect_drift):
|
|
"""Booleans as booleans, numbers numerically, null/absent never drifting."""
|
|
def js(v: object) -> str:
|
|
# Python repr is not JavaScript: True/False/None have other names.
|
|
return {True: "true", False: "false", None: "null"}.get(v, repr(v))
|
|
|
|
_assert_against_merge_units(
|
|
"const rows = mergeKnobRows(\n"
|
|
f" {{ k: {{ persisted: {js(persisted)}, runtime: {js(runtime)}, config_key: 'a.knob' }} }},\n"
|
|
" {}\n"
|
|
");\n"
|
|
f"assert.strictEqual(rows['a.knob'].drift, {str(expect_drift).lower()});"
|
|
)
|
|
|
|
|
|
SEED_ADV_COLLAPSED_BEGIN = "/* SEED_ADV_COLLAPSED:BEGIN */"
|
|
SEED_ADV_COLLAPSED_END = "/* SEED_ADV_COLLAPSED:END */"
|
|
|
|
|
|
def _assert_against_seed_units(assertion_js: str) -> subprocess.CompletedProcess[str]:
|
|
"""Assert ``assertion_js`` against the real seedAdvancedCollapsed source.
|
|
|
|
The function lives in controls.html between the SEED_ADV_COLLAPSED markers
|
|
and is self-contained: it takes the grouped rows and the two Sets as
|
|
arguments and touches no DOM. ``group`` below mirrors the grouping step in
|
|
renderKnobsTable so each test can drive it payload by payload.
|
|
"""
|
|
return _run_node(
|
|
"const assert = require('assert');\n"
|
|
+ _extract_between(CONTROLS_HTML, SEED_ADV_COLLAPSED_BEGIN, SEED_ADV_COLLAPSED_END)
|
|
+ "\n"
|
|
"const IDS = ['caching_context', 'watchdog'];\n"
|
|
"function group(rows) {\n"
|
|
" const byCategory = {};\n"
|
|
" for (const [key, row] of Object.entries(rows)) {\n"
|
|
" const item = row.runtimeItem || row.persistedItem;\n"
|
|
" (byCategory[item.category] = byCategory[item.category] || []).push(key);\n"
|
|
" }\n"
|
|
" return byCategory;\n"
|
|
"}\n"
|
|
"function render(rows, seeded, collapsed) {\n"
|
|
" seedAdvancedCollapsed(group(rows), rows, IDS, seeded, collapsed);\n"
|
|
"}\n"
|
|
# First render: runtime payload only. Watchdog has a plain runtime knob
|
|
# and NO advanced row (its advanced knobs are persisted-only);
|
|
# Caching has an advanced runtime knob.
|
|
"const runtimeOnly = {\n"
|
|
" 'session_cache.staleness_seconds': { runtimeItem: { category: 'caching_context', advanced: true }, persistedItem: null },\n"
|
|
" 'watchdog.enabled': { runtimeItem: { category: 'watchdog', advanced: false }, persistedItem: null },\n"
|
|
"};\n"
|
|
# Second render: the config payload lands and adds the persisted-only
|
|
# advanced watchdog knobs.
|
|
"const withConfig = {\n"
|
|
" ...runtimeOnly,\n"
|
|
" 'watchdog.detector.dup_min': { runtimeItem: null, persistedItem: { category: 'watchdog', advanced: true } },\n"
|
|
"};\n"
|
|
+ assertion_js
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_seed_advanced_collapsed_seeds_each_category_when_its_advanced_rows_first_appear():
|
|
"""A config-only render after a runtime-only render must leave the Watchdog
|
|
Advanced section collapsed. Seeding once on the first render marked
|
|
Watchdog seeded while it had no advanced rows, so it loaded EXPANDED."""
|
|
_assert_against_seed_units(
|
|
"const seeded = new Set(), collapsed = new Set();\n"
|
|
"render(runtimeOnly, seeded, collapsed);\n"
|
|
"assert.deepStrictEqual([...collapsed], ['caching_context.adv']);\n"
|
|
"assert.ok(!seeded.has('watchdog'), 'no advanced rows yet: watchdog must stay unseeded');\n"
|
|
"render(withConfig, seeded, collapsed);\n"
|
|
"assert.deepStrictEqual([...collapsed].sort(), ['caching_context.adv', 'watchdog.adv']);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_seed_advanced_collapsed_leaves_a_user_toggled_section_alone():
|
|
"""Once a category is seeded, a toggled-open section stays open across
|
|
every later re-render (the page re-renders on a 30s poll)."""
|
|
_assert_against_seed_units(
|
|
"const seeded = new Set(), collapsed = new Set();\n"
|
|
"render(runtimeOnly, seeded, collapsed);\n"
|
|
"render(withConfig, seeded, collapsed);\n"
|
|
"collapsed.delete('watchdog.adv');\n"
|
|
"collapsed.delete('caching_context.adv');\n"
|
|
"render(withConfig, seeded, collapsed);\n"
|
|
"render(withConfig, seeded, collapsed);\n"
|
|
"assert.strictEqual(collapsed.size, 0);"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_seed_advanced_collapsed_ignores_a_category_with_no_advanced_rows():
|
|
"""A category with only plain knobs gets no Advanced entry and is never
|
|
marked seeded, so an advanced row arriving later is still seeded."""
|
|
_assert_against_seed_units(
|
|
"const seeded = new Set(), collapsed = new Set();\n"
|
|
"const plain = { 'watchdog.enabled': runtimeOnly['watchdog.enabled'] };\n"
|
|
"render(plain, seeded, collapsed);\n"
|
|
"assert.strictEqual(collapsed.size, 0);\n"
|
|
"assert.strictEqual(seeded.size, 0);"
|
|
)
|
|
|
|
|
|
INDEX_HTML = ROOT / "admin" / "frontend" / "index.html"
|
|
LIFT_A2_HELPERS_BEGIN = "/* LIFT_A2_HELPERS:BEGIN */"
|
|
LIFT_A2_HELPERS_END = "/* LIFT_A2_HELPERS:END */"
|
|
|
|
|
|
def _assert_against_lift_a2_helpers(assertion_js: str) -> subprocess.CompletedProcess[str]:
|
|
"""Assert ``assertion_js`` against the real lift-a2 formatting helpers source."""
|
|
return _run_node(
|
|
"const assert = require('assert');\n"
|
|
+ _extract_between(INDEX_HTML, LIFT_A2_HELPERS_BEGIN, LIFT_A2_HELPERS_END)
|
|
+ "\n"
|
|
+ assertion_js
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_format_money():
|
|
_assert_against_lift_a2_helpers(
|
|
"assert.strictEqual(formatMoney(1.5), '$1.50');\n"
|
|
"assert.strictEqual(formatMoney(0), '$0.00');\n"
|
|
"assert.strictEqual(formatMoney(null), 'n/a');\n"
|
|
"assert.strictEqual(formatMoney(undefined), 'n/a');"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_format_relative_time():
|
|
_assert_against_lift_a2_helpers(
|
|
"// just now (within 10s)\n"
|
|
"assert.strictEqual(formatRelativeTime(new Date().toISOString()), 'just now');\n"
|
|
"// a few minutes ago\n"
|
|
"const fiveMinAgo = new Date(Date.now() - 5 * 60 * 1000).toISOString();\n"
|
|
"assert.strictEqual(formatRelativeTime(fiveMinAgo), '5m ago');\n"
|
|
"// null/empty\n"
|
|
"assert.strictEqual(formatRelativeTime(null), '');\n"
|
|
"assert.strictEqual(formatRelativeTime(''), '');"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_truncate():
|
|
_assert_against_lift_a2_helpers(
|
|
"assert.strictEqual(truncate('hello world', 40), 'hello world');\n"
|
|
"assert.strictEqual(truncate('hello world', 5), 'hello...');\n"
|
|
"assert.strictEqual(truncate(null, 5), '');\n"
|
|
"assert.strictEqual(truncate(undefined, 5), '');"
|
|
)
|
|
|
|
|
|
MODELS_HTML = ROOT / "admin" / "frontend" / "models.html"
|
|
BLOCK_MODEL_BEGIN = "/* BLOCK_CONFIRM:BEGIN */"
|
|
BLOCK_MODEL_END = "/* BLOCK_CONFIRM:END */"
|
|
|
|
|
|
def _assert_against_block_model(assertion_js: str) -> subprocess.CompletedProcess[str]:
|
|
return _run_node(
|
|
"const assert = require('assert');\n"
|
|
+ _extract_between(MODELS_HTML, BLOCK_MODEL_BEGIN, BLOCK_MODEL_END)
|
|
+ "\n"
|
|
+ assertion_js
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_block_model_exists():
|
|
_assert_against_block_model(
|
|
"assert.strictEqual(typeof blockModelConfirm, 'function');"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_block_button_uses_data_attrs_not_onclick_interpolation():
|
|
"""The Block button in both pages must carry data-model/data-provider/
|
|
data-reason attributes and wire via onclick=\"blockModelConfirm(event)\"
|
|
rather than interpolating runtime values into JS — interpolation broke
|
|
on titles containing a double quote."""
|
|
for path in (INDEX_HTML, MODELS_HTML):
|
|
text = path.read_text(encoding="utf-8")
|
|
assert 'onclick="blockModelConfirm(event)"' in text, (
|
|
f"{path.name}: Block button missing onclick handler"
|
|
)
|
|
assert "data-model=" in text, f"{path.name}: missing data-model attr"
|
|
assert "data-provider=" in text, f"{path.name}: missing data-provider attr"
|
|
assert "data-reason=" in text, f"{path.name}: missing data-reason attr"
|
|
|
|
|
|
@skip_without_node
|
|
def test_block_data_reason_escapes_double_quotes():
|
|
"""A looping-session title containing both a double and a single quote is
|
|
safe inside data-reason=\"...\": escapeHtml turns the double quote into
|
|
" so it cannot terminate the attribute, and a bare single quote is
|
|
harmless inside a double-quoted HTML attribute."""
|
|
escape = re.search(r"function escapeHtml\(s\) \{.*?\n\}", INDEX_HTML.read_text(encoding="utf-8"), re.DOTALL)
|
|
assert escape, "index.html: escapeHtml source not found"
|
|
js = (
|
|
"const assert = require('assert');\n"
|
|
+ escape.group(0)
|
|
+ "\n"
|
|
"const title = 'he said \"hello\\'s\"';\n"
|
|
"const dataReason = 'looping session ' + title;\n"
|
|
"const html = 'data-reason=\"' + escapeHtml(dataReason) + '\"';\n"
|
|
"assert.ok(html.includes('"'));\n"
|
|
"assert.ok(!html.includes('\"hello'));\n"
|
|
"assert.strictEqual(escapeHtml(dataReason), "
|
|
" 'looping session he said "hello\\'s"');\n"
|
|
)
|
|
_run_node(js)
|
|
|
|
|
|
@skip_without_node
|
|
def test_block_button_onclick_wires_to_blockModelConfirm():
|
|
"""The Block button carries onclick=\"blockModelConfirm(event)\" so the
|
|
click actually invokes the handler. Without this the data-* attributes
|
|
are inert and clicking Block does nothing."""
|
|
for path in (INDEX_HTML, MODELS_HTML):
|
|
text = path.read_text(encoding="utf-8")
|
|
assert 'onclick="blockModelConfirm(event)"' in text, (
|
|
f"{path.name}: Block button missing onclick handler"
|
|
)
|
|
|
|
|
|
@skip_without_node
|
|
def test_blockModelConfirm_reads_from_event_currentTarget_dataset():
|
|
"""blockModelConfirm(event) must read model/provider/reason from
|
|
event.currentTarget.dataset — the old hybrid signature that expected
|
|
positional args is broken because onclick passes a single Event."""
|
|
js = (
|
|
"const assert = require('assert');\n"
|
|
+ _extract_between(MODELS_HTML, BLOCK_MODEL_BEGIN, BLOCK_MODEL_END)
|
|
+ "\n"
|
|
"assert.strictEqual(typeof blockModelConfirm, 'function');\n"
|
|
"const src = blockModelConfirm.toString();\n"
|
|
"assert.ok(src.includes('event.currentTarget'), 'must use event.currentTarget');\n"
|
|
"assert.ok(src.includes('dataset.model'), 'must read dataset.model');\n"
|
|
"assert.ok(src.includes('dataset.provider'), 'must read dataset.provider');\n"
|
|
"assert.ok(src.includes('dataset.reason'), 'must read dataset.reason');\n"
|
|
)
|
|
_run_node(js)
|