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