@@ -543,6 +543,70 @@ src/ha_mcp/
543543
544544** Tool Completion Semantics** : Tools should wait for operations to complete before returning, with optional ` wait ` parameter for control.
545545
546+ ## JS Behaviour Testing (` tests/js/ ` , ` tests/src/unit/_js_harness.py ` )
547+
548+ Every rendered ` <script> ` body in the repo (` src/ha_mcp/settings_ui.py ` ,
549+ ` src/ha_mcp/auth/consent_form.py ` , every ` .astro ` page under ` site/src/ ` )
550+ gets parse coverage automatically via
551+ ` tests/src/unit/test_rendered_scripts_parse.py ` . The discovery walker in
552+ ` _js_harness.py::discover_script_surfaces ` picks up new surfaces on its
553+ next run — no registration needed when you add a new UI.
554+
555+ For behavioural tests (` restartInProgress ` guard, wizard state machine,
556+ copy-button idempotency, etc.), use the JSDOM harness:
557+
558+ ``` python
559+ from ._js_harness import extract_script_body, run_script
560+
561+ script = extract_script_body(rendered_html)
562+ result = run_script(
563+ script,
564+ initial_html = " <!DOCTYPE html>..." ,
565+ fetch_map = {" /api/foo" : {" status" : 200 , " json" : {... }}},
566+ broadcast_events = [{" channel" : " ch-name" , " data" : {" type" : " ..." }}],
567+ invoke = " await window.someExposedFn();" ,
568+ )
569+ assert result.reloads == 1
570+ assert result.broadcasts_of_type(" restart-required" )
571+ ```
572+
573+ The harness fakes ` setTimeout ` / ` setInterval ` / ` Date.now ` on a
574+ virtual clock (a 60 s production probe completes in milliseconds of
575+ wall time), stubs ` fetch ` from a URL pattern map (with optional
576+ ` responses: [...] ` sequencing for state-flip flows), captures
577+ ` location.reload ` via JSDOM's ` jsdomError ` channel (unforgeable IDL
578+ property), and provides a ` BroadcastChannel ` shim that can be primed
579+ with cross-tab events. ` new Date() ` / ` performance.now() ` continue to
580+ report wall time — only the three sources above are faked.
581+
582+ Astro ` <script> ` blocks without ` define:vars ` / ` is:inline ` are
583+ TypeScript by default — pass ` language="ts" ` to ` run_script ` and the
584+ harness strips types via esbuild before evaluation. For Astro pages
585+ that need wizard data (` clientsData ` , etc. via ` define:vars ` ), use
586+ ` extract_astro_frontmatter_vars ` + ` astro_vars_prelude ` to inject the
587+ real production data:
588+
589+ ``` python
590+ vars_ = extract_astro_frontmatter_vars(astro_path, [" clientsData" , ... ])
591+ prelude = astro_vars_prelude(vars_)
592+ result = run_script(script, prelude = prelude, ... )
593+ ```
594+
595+ CI installs Node + jsdom in the ` unit-tests ` job (` .github/workflows/pr.yml ` ).
596+ Local devs without ` tests/js/node_modules/ ` get clean skips.
597+
598+ When adding a new UI surface:
599+ - Python-rendered HTML: register the renderer in
600+ ` _js_harness.py::_PY_RENDERERS ` so the auto-discovery walker picks
601+ it up for parse coverage.
602+ - Astro page: drop the ` .astro ` file under ` site/src/ ` ; discovery walks
603+ the tree automatically.
604+ - Behavioural tests: add a ` test_<surface>_js_behavior.py ` module
605+ alongside the existing ones (` test_settings_ui_js_behavior.py ` ,
606+ ` test_astro_setup_js_behavior.py ` , ` test_astro_tools_js_behavior.py ` ,
607+ ` test_astro_layout_js_behavior.py ` , ` test_consent_form_js_behavior.py ` )
608+ — pattern is one module per UI surface.
609+
546610## Setup Wizard (` site/src/pages/setup.astro ` )
547611
548612Single-file Astro page that drives the on-site setup flow. Both the metadata (which clients/platforms/connections/deployments exist) and the per-client instruction prose live in this one file.
0 commit comments