Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
17 changes: 14 additions & 3 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,27 @@ 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: 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: |
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: Install JS test dependencies (jsdom, esbuild)
# `npm ci` enforces package-lock.json — same reproducibility
# discipline as uv.lock on the Python side.
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/
53 changes: 53 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,59 @@ 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 time (`setTimeout` / `Date.now` on a virtual clock —
60s probe windows take milliseconds), stubs `fetch` from a URL pattern
map, captures `location.reload` via JSDOM's `jsdomError` channel
(unforgeable IDL property), and provides a `BroadcastChannel` shim that
can be primed with cross-tab events.

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: drop the file, add behavioural tests in
`tests/src/unit/test_*_js_behavior.py` mirroring the existing per-
surface modules; parse coverage is automatic.

## 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
84 changes: 84 additions & 0 deletions tests/js/extract_astro_vars.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// 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 { 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 sanitiseFrontmatter(fm) {
// Drop imports (would fail to resolve in this context) and lines that
// reach into `import.meta` (Astro-only). Leave const / let / function
// declarations intact so consts the test asks for are still in scope.
//
// Then prepend stubs for the most common Astro-injected globals so
// helper functions in the frontmatter (e.g. `withBase` referencing
// `base = import.meta.env.BASE_URL`) don't ReferenceError when re-
// evaluated outside Astro.
const stubs = `const base = "";\n`;
return (
stubs +
fm
.split("\n")
.filter((line) => !/^\s*import\b/.test(line))
Comment thread
kingpanther13 marked this conversation as resolved.
Outdated
.filter((line) => !/\bimport\.meta\b/.test(line))
.join("\n")
);
}

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;process.stdout.write(JSON.stringify({${payload.join(",")}}));`;

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

// Evaluate in this same module — esbuild output is plain JS now.
// Using indirect eval keeps the top-level scope clean.
(0, eval)(transpiled);
Comment thread
kingpanther13 marked this conversation as resolved.
Outdated
}

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