Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 40 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,53 @@ jobs:
timeout-minutes: 5

steps:
- name: Install git for submodule checkout
run: apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1
- name: Restore apt download cache
# Cache /var/cache/apt/archives so the nodejs .deb (~30 MB) and its
# deps don't re-download on every PR — the bulk of "Install git and
# Node.js" was the network download, not the extract. apt-get
# install still runs (unpack is fast); we're just skipping the
# download leg on cache hit.
uses: actions/cache@v5
with:
path: /var/cache/apt/archives
key: apt-trixie-git-nodejs-npm-v1

- name: Install git and Node.js
# Node + npm power the JS behaviour tests under tests/src/unit/
# (harness in tests/js/) — they spawn `node tests/js/harness.mjs`
# to drive rendered <script> bodies through JSDOM. Without node
# the tests skip; install here so coverage actually runs.
run: |
# Keep cached .debs after install so the cache stays warm for
# future runs (default Debian behaviour deletes them).
rm -f /etc/apt/apt.conf.d/docker-clean
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
> /etc/apt/apt.conf.d/keep-downloaded
apt-get update -qq
apt-get install -y -qq git nodejs npm >/dev/null 2>&1

- uses: actions/checkout@v6
with:
submodules: true

- name: Install dependencies
- name: Install Python dependencies
run: uv sync --all-extras --dev

- name: Restore JS test node_modules cache
# Lockfile-keyed so a dependency bump invalidates cleanly. `npm ci`
# is still safe to run on cache hit (it short-circuits when the
# tree already matches the lockfile).
uses: actions/cache@v5
with:
path: tests/js/node_modules
key: tests-js-node-modules-${{ hashFiles('tests/js/package-lock.json') }}

- name: Install JS test dependencies (jsdom, esbuild)
# `npm ci` enforces package-lock.json — same reproducibility
# discipline as uv.lock on the Python side. No-op on cache hit
# if node_modules matches the lockfile.
run: npm ci --prefix tests/js

- name: Run unit tests
run: uv run pytest tests/src/unit/ -n auto --tb=short -v

Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -94,3 +94,6 @@ tests/initial_test_state/custom_components/hacs/hacs_frontend/
.auto-claude/
.worktrees/
.claude_settings.json

# Node modules — site (Astro) and tests/js (JSDOM behaviour harness)
node_modules/
64 changes: 64 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,70 @@ src/ha_mcp/

**Tool Completion Semantics**: Tools should wait for operations to complete before returning, with optional `wait` parameter for control.

## JS Behaviour Testing (`tests/js/`, `tests/src/unit/_js_harness.py`)

Every rendered `<script>` body in the repo (`src/ha_mcp/settings_ui.py`,
`src/ha_mcp/auth/consent_form.py`, every `.astro` page under `site/src/`)
gets parse coverage automatically via
`tests/src/unit/test_rendered_scripts_parse.py`. The discovery walker in
`_js_harness.py::discover_script_surfaces` picks up new surfaces on its
next run — no registration needed when you add a new UI.

For behavioural tests (`restartInProgress` guard, wizard state machine,
copy-button idempotency, etc.), use the JSDOM harness:

```python
from ._js_harness import extract_script_body, run_script

script = extract_script_body(rendered_html)
result = run_script(
script,
initial_html="<!DOCTYPE html>...",
fetch_map={"/api/foo": {"status": 200, "json": {...}}},
broadcast_events=[{"channel": "ch-name", "data": {"type": "..."}}],
invoke="await window.someExposedFn();",
)
assert result.reloads == 1
assert result.broadcasts_of_type("restart-required")
```

The harness fakes `setTimeout` / `setInterval` / `Date.now` on a
virtual clock (a 60 s production probe completes in milliseconds of
wall time), stubs `fetch` from a URL pattern map (with optional
`responses: [...]` sequencing for state-flip flows), captures
`location.reload` via JSDOM's `jsdomError` channel (unforgeable IDL
property), and provides a `BroadcastChannel` shim that can be primed
with cross-tab events. `new Date()` / `performance.now()` continue to
report wall time — only the three sources above are faked.

Astro `<script>` blocks without `define:vars` / `is:inline` are
TypeScript by default — pass `language="ts"` to `run_script` and the
harness strips types via esbuild before evaluation. For Astro pages
that need wizard data (`clientsData`, etc. via `define:vars`), use
`extract_astro_frontmatter_vars` + `astro_vars_prelude` to inject the
real production data:

```python
vars_ = extract_astro_frontmatter_vars(astro_path, ["clientsData", ...])
prelude = astro_vars_prelude(vars_)
result = run_script(script, prelude=prelude, ...)
```

CI installs Node + jsdom in the `unit-tests` job (`.github/workflows/pr.yml`).
Local devs without `tests/js/node_modules/` get clean skips.

When adding a new UI surface:
- Python-rendered HTML: register the renderer in
`_js_harness.py::_PY_RENDERERS` so the auto-discovery walker picks
it up for parse coverage.
- Astro page: drop the `.astro` file under `site/src/`; discovery walks
the tree automatically.
- Behavioural tests: add a `test_<surface>_js_behavior.py` module
alongside the existing ones (`test_settings_ui_js_behavior.py`,
`test_astro_setup_js_behavior.py`, `test_astro_tools_js_behavior.py`,
`test_astro_layout_js_behavior.py`, `test_consent_form_js_behavior.py`)
— pattern is one module per UI surface.

## Setup Wizard (`site/src/pages/setup.astro`)

Single-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.
Expand Down
101 changes: 101 additions & 0 deletions tests/js/extract_astro_vars.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Read an .astro file, strip imports / `import.meta` refs out of its
// frontmatter, evaluate the remaining declarations as TypeScript via
// esbuild, and dump the named identifiers as JSON.
//
// Used by the JS behaviour tests that need to drive Astro pages: those
// pages declare wizard data (clientsData, platformsData, …) in the
// frontmatter and reference them through `<script define:vars={...}>`.
// We rebuild the same injection here so the in-page script sees the
// real production data when run inside JSDOM.
//
// Usage (from Python):
// echo '{"path": "...", "names": ["clientsData", "platformsData"]}' \
// | node tests/js/extract_astro_vars.mjs
// Outputs: {"clientsData": [...], "platformsData": [...]}

import { readFileSync } from "node:fs";
import { createContext, runInContext } from "node:vm";
import { transformSync } from "esbuild";

function readStdin() {
return new Promise((resolve, reject) => {
let buf = "";
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk) => {
buf += chunk;
});
process.stdin.on("end", () => resolve(buf));
process.stdin.on("error", reject);
});
}

function extractFrontmatter(source) {
const m = source.match(/^---\n([\s\S]*?)\n---\n/);
if (!m) throw new Error("no Astro frontmatter (--- ... ---) in source");
return m[1];
}

function stripImports(fm) {
// Drop both single-line (`import X from 'y';`) and multi-line
// (`import {\n a,\n b,\n} from 'y';`) import statements. The
// grammar matches `import` at line start optionally followed by
// anything up to the first semicolon, including newlines. Doesn't
// need to be a perfect TS parser — Astro frontmatter imports always
// sit at the top before any other statements.
return fm.replace(/^[ \t]*import\b[\s\S]*?;[ \t]*\n?/gm, "");
}

function sanitiseFrontmatter(fm) {
// Drop imports (would fail to resolve in this context) and lines
// that reach into `import.meta` (Astro-only). Then prepend stubs for
// the most common Astro-injected globals so frontmatter helpers
// (e.g. `withBase` referencing `base = import.meta.env.BASE_URL`)
// don't ReferenceError when re-evaluated outside Astro.
const stubs = `const base = "";\n`;
const noImports = stripImports(fm);
const noImportMeta = noImports
.split("\n")
.filter((line) => !/\bimport\.meta\b/.test(line))
.join("\n");
return stubs + noImportMeta;
}

async function main() {
const raw = await readStdin();
const req = JSON.parse(raw);
const src = readFileSync(req.path, "utf-8");
const cleaned = sanitiseFrontmatter(extractFrontmatter(src));

// Append a JSON serialiser for each requested name so we get a single
// structured payload back. `stringify` runs after every const in
// `cleaned` is in scope.
const names = req.names || [];
const payload = names.map(
(n) => `"${n}": typeof ${n} !== 'undefined' ? ${n} : null`,
);
const program = `${cleaned}\n;__result = JSON.stringify({${payload.join(",")}});`;

const transpiled = transformSync(program, {
loader: "ts",
target: "es2020",
format: "esm",
}).code;

// vm.runInContext rather than eval so the project's "no eval()" lint
// stays clean. New context per invocation (no globals from the host)
// — `__result` is the only handoff back.
const ctx = createContext({ __result: null });
try {
runInContext(transpiled, ctx, { filename: `astro-vars:${req.path}` });
} catch (e) {
throw new Error(
`evaluating frontmatter of ${req.path}: ${(e && e.stack) || e}`,
);
}
process.stdout.write(ctx.__result ?? "{}");
}

main().catch((e) => {
process.stderr.write(`extract_astro_vars: ${(e && e.stack) || e}\n`);
process.exit(1);
});
Loading
Loading