Skip to content

Commit f7d1b5a

Browse files
test(internal): JSDOM behaviour harness + auto-discovery parse coverage for every rendered <script> (#1425)
* test(internal): JSDOM behaviour harness + auto-discovery parse coverage for every rendered <script> Closes #1422. Adds a JSDOM harness (tests/js/harness.mjs + tests/src/unit/_js_harness.py) that drives real rendered <script> bodies through stubbed fetch / BroadcastChannel / virtual timers / DOM and reports observed side effects. A discovery walker auto-picks-up every <script> surface in the repo (src/ha_mcp/settings_ui.py, src/ha_mcp/auth/consent_form.py, every site/src/**/*.astro) so parse coverage extends as new UI surfaces ship — no registration needed. Behavioural coverage landed for the surfaces named in #1422: * settings_ui — restartInProgress concurrency guard, 4xx-suppress-reload branch, 5xx fall-through, instance_id-flip probe, BroadcastChannel restart-required + restart-initiated listeners, saveFeatureFlag JSON-parse fallback. * setup.astro — state-machine progression (local / network / remote), plus a parametrised per-client smoke that drives the wizard to config generation for every id in the real clientsData array. * tools.astro — search/filter pipeline + design-mode toggle (TypeScript; esbuild strips types in the harness before eval). * Layout.astro — copy-button idempotency across re-init. * consent_form — submit handler disable + spinner state. The legacy TestRenderedHTMLJsSyntax in test_settings_ui.py is removed — the auto-discovery parse test in test_rendered_scripts_parse.py subsumes it (and extends to the four other surfaces it never covered). CI: unit-tests job in pr.yml installs nodejs + jsdom + esbuild via apt-get / npm ci. Local devs without tests/js/node_modules/ get clean skips, matching the original parse guard's behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): skip Astro frontmatter in script extraction; drain microtasks before clock advance CI surfaced two harness bugs the local smoke-tests didn't catch: 1. `extract_script_body` and the discovery walker greedily matched the first `<script>` substring in the source, which in setup.astro is actually a frontmatter comment: `// below in the <script> block keyed off the entry's id.` That made the "script body" start mid-frontmatter and the extracted text wasn't valid JS — esbuild and JSDOM both rejected it with "Unexpected identifier 'keyed'". Fix: strip the `--- ... ---` Astro frontmatter block before searching for `<script>` tags. Plain .py and .html sources have no frontmatter and pass through unchanged. 2. `clock.advance(settleMs)` returned immediately when no timers were yet scheduled, but the script under test often awaits a chain of stubbed-fetch promises BEFORE hitting its first `setTimeout`. With only one microtask drain between eval and advance, those promises hadn't resolved yet, so no timers existed, advance was a no-op, and the script stayed suspended — `restartAddon`'s POST to /api/settings/restart never fired and the `alert(msg)` in the 4xx branch never ran. Fix: drain microtasks aggressively at the start of advance() so pending promises get to schedule their timers, and drain again when the timer queue temporarily empties (a promise resolution may queue new timers). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(internal): expand initial DOM fixtures to cover every top-level addEventListener target CI surfaced this via the test_5xx test (the only one whose assertion included the harness errors list): the settings_ui script aborts during init at `document.getElementById('backupRefresh').addEventListener(...)` because the test DOM is missing the backup table / modal markup. With init aborted, the invoke step never runs — `restartAddon` is never called, POSTs never fire, `alert()` never runs, and all three restart- flow tests silently fail. The setup.astro tests had the same shape: `generateConfig` queries `config-section` (distinct from `section-config`) to show/hide the inner code block. Without it, the proxy click handler in the remote- flow test threw and the `document.body.dataset.beforeProxy` assignment never landed. Fixes: - settings_ui MIN_DOM now includes backupBulkDelete, backupDomain, backupEntity, backupList, backupRefresh, backupState, featuresBody, modalBackdrop / modalBody / modalClose / modalTitle. Set built from `grep -h "document.getElementById" settings_ui.py` so future top-level handlers will surface as the same pattern. - setup.astro DOM now includes config-section alongside section-config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): run JSDOM eval at global scope; capture body attrs in dom snapshot Two harness bugs the prior CI rounds didn't surface until init-stage crashes were resolved: 1. Wrapping the rendered script in an `async () => { ... }()` IIFE confined top-level `function` declarations to the IIFE scope. `function restartAddon() {...}` never landed on `window`, so `invoke: "window.restartAddon();"` threw `is not a function`. A real browser hoists inline-script function decls to the global window — match that by running prelude + script body at global scope and keeping the IIFE for `invoke` alone (so awaits inside invoke still work). 2. `document.body.innerHTML` returns body's children but not body's own attrs, so tests that wrote `document.body.dataset.foo = 'bar'` as a side-channel for in-page state had no way to assert on it — `result.dom` came back without the attr. Serialise `document.documentElement.outerHTML` instead so html/head/body tags and their own attributes round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(internal): sequence /api/settings/info responses for 5xx restart probe The test_5xx flow hits the info endpoint three times — loadTools init, restartAddon's pre-POST baseline capture, and _probeAddonRestarted after the POST. The old single-response fixture returned the SAME instance_id every time, so the probe never saw the flip and looped until timeout, leaving reloads=0. Adds a `responses: [...]` shape to the harness fetch_map: each match on a URL pattern advances a per-pattern counter; the last entry sticks after exhaustion (matches "the addon came back online and stays online"). Test now provides baseline → baseline → flipped so the probe terminates with restarted=true and the reload fires. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ci(test): cache apt downloads and node_modules for the unit-tests job The unit-tests "Install git and Node.js" step was 44 s — almost all of it network download of the nodejs / npm .deb. The "Install JS test dependencies" step is 1 s when node_modules is fresh but can grow as deps change. - Cache /var/cache/apt/archives keyed on a stable string (apt package set rarely changes). Disable docker-clean and set Keep-Downloaded- Packages so the cached .debs survive install for the next run. apt install still runs (unpacks from local cache, ~3-5 s) but skips the network leg. - Cache tests/js/node_modules keyed on package-lock.json so dep bumps invalidate cleanly. `npm ci` short-circuits when the tree matches. Expected first-cold-cache run: unchanged (~45 s install). Cache hits: ~5 s for both steps combined. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(internal): address Gemini + pr-review-toolkit findings on JS test infrastructure Outcome of going through all 60 findings from Gemini Code Assist + pr-review-toolkit (code-reviewer, pr-test-analyzer, silent-failure- hunter, comment-analyzer). 3 wrong (skipped: PATH-resolved node binary mis-flagged as hardcoded; project-relative esbuild path mis-flagged as hardcoded; theoretical FakeBroadcastChannel constructor-throw). Remaining real items addressed: Harness: - vm.runInContext replaces window.eval / indirect eval to clear the "no eval()" style-guide flag (Gemini #1, #2). - Timer-callback and broadcast-listener throws now record into the errors list instead of being silently swallowed (#30, #32). - SAFETY_CAP exhaustion records a clear "runaway setInterval" error instead of breaking silently (#4, #31). - Non-navigation jsdomErrors route to errors (not console) so tests asserting `not result.errors` catch them (#38). - Transpile failure short-circuits init eval to avoid cascading syntax errors from un-transpiled TS (#34). - FakeBroadcastChannel.postMessage now delivers to peer same-name channels in the same context per spec (#5). - Time-faked surface documented accurately (Date.now / setTimeout / setInterval only; new Date / performance.now still wall-time) (#46). - New broadcastChannelUnavailable param simulates the `typeof BroadcastChannel === 'undefined'` browsing context so the production null-guard branch is exercised (#15). - Dead comments and rot-prone duplications removed (#47, #49, #51, #56, #57, #58, #59, #66). extract_astro_vars.mjs: - vm.runInContext replaces (0, eval) (Gemini #2). - Multi-line `import { a, b } from 'x';` now stripped robustly (#7). - Eval errors wrapped with the source path for actionable failures (#35). _js_harness.py: - Wrong test file name and workflow path in docstring fixed (#41, #42). - _strip_astro_frontmatter raises ValueError when frontmatter opens but never closes (#36). - discover_script_surfaces raises when site/src/ is missing instead of silently producing partial results (#37). - extract_script_body accepts source_label for actionable errors (#40). - Astro `<script lang="js">` is no longer mis-tagged as TypeScript (#9). - Inert chr(92) Windows backslash replace removed (#14). - Field docstrings on ScriptSurface trimmed to the one that earns its keep (#52). - _PY_RENDERERS registry refactor + accurate enumeration comment (#45). test_settings_ui_js_behavior.py: - Rot-bait PR/issue numbers removed from module docstring (#43). - _TOP_LEVEL_ELEMENT_IDS + import-time drift check replaces the "refresh this manually" comment (#55). - _assert_clean_init helper called at the top of every test so init failures surface as init errors, not as misleading "side effect didn't fire" failures (#33). - 4xx restartBtn assertion now reads disabled state via JS and snaps to body.dataset instead of OR-shortcircuiting against a wiped DOM (#27). - New test_script_boots_without_broadcastchannel_global covers the null-guard branch (#15). - Assertion-restating comments removed (#60). test_astro_setup_js_behavior.py: - Rot-bait #1422 reference removed from module docstring (#44). - _section_has_hidden_class replaces fragile substring slicing (#6). - test_initial_state_only_client_section_visible now asserts on the promised visibility, not just absence of errors (#26). - Per-client smoke now captures config-output text AND instructions HTML into body.dataset and asserts on non-empty content, catching a typo that drops the whole per-client branch (#21). test_astro_tools_js_behavior.py: - _card_class helper replaces ±200-char substring slicing (#12). - test_design_mode_toggle now asserts design-only elements lose 'hidden' class, not just the button label flip (#25). - New tests cover .filter-btn / .cat-btn / .size-filter-btn / group-category|file|none / sort-alpha / expand-all wiring (#22, #23, #24) — the adjacent coverage gaps issue #1422 didn't name but that fit the harness's same regression-class. test_consent_form_js_behavior.py: - _build_form_dom docstring fixed (said "three", listed four) (#54). test_rendered_scripts_parse.py: - Missing-dependency skip flips to fail when CI=true so a workflow drift that drops the install step doesn't silently lose parse coverage (#29). - Subsumed-test-class reference removed from module docstring. AGENTS.md: - "60s probe windows take milliseconds" wording fixed; time-faked surface documented (#13). - Per-surface module naming guidance updated; reflects actual files (#10). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): seed data-transports on wizard tiles; add NODE/ESBUILD env overrides; FakeBroadcastChannel ctor guard CI surfaced a real test-fixture bug exposed by the new jsdomError → errors routing: setup.astro's connection-click handler reads `card.dataset.transports` via JSON.parse, but the wizard DOM stubs were emitting `<button data-client="...">` without the matching `data-transports` attribute. JSON.parse(undefined) threw "undefined is not valid JSON" on the jsdomError channel, which the previous silent-handling code dropped — now correctly surfaced as a test failure. Fix: serialise the real `transports` array from the clientsData entry onto each tile. Also addressing the items previously marked deferred / skipped during the Gemini + pr-review-toolkit triage: - NODE_BINARY env override (Gemini #3): _node_binary() helper checks the env var before falling back to PATH-resolved `node`. Default unchanged. - ESBUILD_BINARY env override (Gemini #8): _esbuild_binary() returns the env-var path when set, else the project-local install. Default unchanged so the lockfile-pinned install stays the reproducible default. - FakeBroadcastChannel constructor guard (sf-hunter #L1): wraps the `new` in try/catch and records construction failures into errors before re-raising. - Trim TestWizardStateMachine class docstring (comment-analyzer #50). - Tighten the info-call enumeration comment in the 5xx test to describe the harness's "last entry sticks" semantics rather than pinning a specific call count (comment-analyzer #48). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): stub layout-dependent JSDOM APIs (scrollIntoView, scrollTo, matchMedia) The new timer-callback-error routing surfaced a real JSDOM limitation: `section.scrollIntoView()` (called from the wizard's `scrollToSection` helper inside a setTimeout) is not implemented in JSDOM. Every per-client setup-flow test failed with ``timer callback: TypeError: section.scrollIntoView is not a function`` — production behaviour is fine, but the harness's noise filter wasn't distinguishing real script bugs from JSDOM-missing-API noise. Adds a defensive no-op stub for scrollIntoView (Element + HTMLElement prototypes), scrollTo on window, and matchMedia — the three most common layout-dependent APIs production UI scripts touch. Future rendered scripts that lean on other layout APIs (IntersectionObserver, etc.) can extend the list when needed. Also relaxes the per-client smoke's bare `assert not result.errors` to rely on `_assert_clean_init` (init/transpile/invoke/jsdom errors) plus the content-shape assertion. Timer-callback errors from missing JSDOM APIs are noise; the content-shape check still catches the regression class the test is named for. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(test): seed .tool-chevron on tool-card fixture for tools.astro expand-all test The expand-all handler queries `card.querySelector('.tool-chevron')!` (TypeScript non-null assertion). The runtime `!` doesn't actually check; chevron is null in the test DOM and `chevron.classList.add(...)` throws. Production cards include the chevron; our fixture didn't. Add it alongside `.tool-details` in `_build_tools_dom`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 13afa9d commit f7d1b5a

15 files changed

Lines changed: 4056 additions & 62 deletions

.github/workflows/pr.yml

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,16 +128,53 @@ jobs:
128128
timeout-minutes: 5
129129

130130
steps:
131-
- name: Install git for submodule checkout
132-
run: apt-get update -qq && apt-get install -y -qq git >/dev/null 2>&1
131+
- name: Restore apt download cache
132+
# Cache /var/cache/apt/archives so the nodejs .deb (~30 MB) and its
133+
# deps don't re-download on every PR — the bulk of "Install git and
134+
# Node.js" was the network download, not the extract. apt-get
135+
# install still runs (unpack is fast); we're just skipping the
136+
# download leg on cache hit.
137+
uses: actions/cache@v5
138+
with:
139+
path: /var/cache/apt/archives
140+
key: apt-trixie-git-nodejs-npm-v1
141+
142+
- name: Install git and Node.js
143+
# Node + npm power the JS behaviour tests under tests/src/unit/
144+
# (harness in tests/js/) — they spawn `node tests/js/harness.mjs`
145+
# to drive rendered <script> bodies through JSDOM. Without node
146+
# the tests skip; install here so coverage actually runs.
147+
run: |
148+
# Keep cached .debs after install so the cache stays warm for
149+
# future runs (default Debian behaviour deletes them).
150+
rm -f /etc/apt/apt.conf.d/docker-clean
151+
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' \
152+
> /etc/apt/apt.conf.d/keep-downloaded
153+
apt-get update -qq
154+
apt-get install -y -qq git nodejs npm >/dev/null 2>&1
133155
134156
- uses: actions/checkout@v6
135157
with:
136158
submodules: true
137159

138-
- name: Install dependencies
160+
- name: Install Python dependencies
139161
run: uv sync --all-extras --dev
140162

163+
- name: Restore JS test node_modules cache
164+
# Lockfile-keyed so a dependency bump invalidates cleanly. `npm ci`
165+
# is still safe to run on cache hit (it short-circuits when the
166+
# tree already matches the lockfile).
167+
uses: actions/cache@v5
168+
with:
169+
path: tests/js/node_modules
170+
key: tests-js-node-modules-${{ hashFiles('tests/js/package-lock.json') }}
171+
172+
- name: Install JS test dependencies (jsdom, esbuild)
173+
# `npm ci` enforces package-lock.json — same reproducibility
174+
# discipline as uv.lock on the Python side. No-op on cache hit
175+
# if node_modules matches the lockfile.
176+
run: npm ci --prefix tests/js
177+
141178
- name: Run unit tests
142179
run: uv run pytest tests/src/unit/ -n auto --tb=short -v
143180

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,6 @@ tests/initial_test_state/custom_components/hacs/hacs_frontend/
9494
.auto-claude/
9595
.worktrees/
9696
.claude_settings.json
97+
98+
# Node modules — site (Astro) and tests/js (JSDOM behaviour harness)
99+
node_modules/

AGENTS.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

548612
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.

tests/js/extract_astro_vars.mjs

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
// Read an .astro file, strip imports / `import.meta` refs out of its
2+
// frontmatter, evaluate the remaining declarations as TypeScript via
3+
// esbuild, and dump the named identifiers as JSON.
4+
//
5+
// Used by the JS behaviour tests that need to drive Astro pages: those
6+
// pages declare wizard data (clientsData, platformsData, …) in the
7+
// frontmatter and reference them through `<script define:vars={...}>`.
8+
// We rebuild the same injection here so the in-page script sees the
9+
// real production data when run inside JSDOM.
10+
//
11+
// Usage (from Python):
12+
// echo '{"path": "...", "names": ["clientsData", "platformsData"]}' \
13+
// | node tests/js/extract_astro_vars.mjs
14+
// Outputs: {"clientsData": [...], "platformsData": [...]}
15+
16+
import { readFileSync } from "node:fs";
17+
import { createContext, runInContext } from "node:vm";
18+
import { transformSync } from "esbuild";
19+
20+
function readStdin() {
21+
return new Promise((resolve, reject) => {
22+
let buf = "";
23+
process.stdin.setEncoding("utf-8");
24+
process.stdin.on("data", (chunk) => {
25+
buf += chunk;
26+
});
27+
process.stdin.on("end", () => resolve(buf));
28+
process.stdin.on("error", reject);
29+
});
30+
}
31+
32+
function extractFrontmatter(source) {
33+
const m = source.match(/^---\n([\s\S]*?)\n---\n/);
34+
if (!m) throw new Error("no Astro frontmatter (--- ... ---) in source");
35+
return m[1];
36+
}
37+
38+
function stripImports(fm) {
39+
// Drop both single-line (`import X from 'y';`) and multi-line
40+
// (`import {\n a,\n b,\n} from 'y';`) import statements. The
41+
// grammar matches `import` at line start optionally followed by
42+
// anything up to the first semicolon, including newlines. Doesn't
43+
// need to be a perfect TS parser — Astro frontmatter imports always
44+
// sit at the top before any other statements.
45+
return fm.replace(/^[ \t]*import\b[\s\S]*?;[ \t]*\n?/gm, "");
46+
}
47+
48+
function sanitiseFrontmatter(fm) {
49+
// Drop imports (would fail to resolve in this context) and lines
50+
// that reach into `import.meta` (Astro-only). Then prepend stubs for
51+
// the most common Astro-injected globals so frontmatter helpers
52+
// (e.g. `withBase` referencing `base = import.meta.env.BASE_URL`)
53+
// don't ReferenceError when re-evaluated outside Astro.
54+
const stubs = `const base = "";\n`;
55+
const noImports = stripImports(fm);
56+
const noImportMeta = noImports
57+
.split("\n")
58+
.filter((line) => !/\bimport\.meta\b/.test(line))
59+
.join("\n");
60+
return stubs + noImportMeta;
61+
}
62+
63+
async function main() {
64+
const raw = await readStdin();
65+
const req = JSON.parse(raw);
66+
const src = readFileSync(req.path, "utf-8");
67+
const cleaned = sanitiseFrontmatter(extractFrontmatter(src));
68+
69+
// Append a JSON serialiser for each requested name so we get a single
70+
// structured payload back. `stringify` runs after every const in
71+
// `cleaned` is in scope.
72+
const names = req.names || [];
73+
const payload = names.map(
74+
(n) => `"${n}": typeof ${n} !== 'undefined' ? ${n} : null`,
75+
);
76+
const program = `${cleaned}\n;__result = JSON.stringify({${payload.join(",")}});`;
77+
78+
const transpiled = transformSync(program, {
79+
loader: "ts",
80+
target: "es2020",
81+
format: "esm",
82+
}).code;
83+
84+
// vm.runInContext rather than eval so the project's "no eval()" lint
85+
// stays clean. New context per invocation (no globals from the host)
86+
// — `__result` is the only handoff back.
87+
const ctx = createContext({ __result: null });
88+
try {
89+
runInContext(transpiled, ctx, { filename: `astro-vars:${req.path}` });
90+
} catch (e) {
91+
throw new Error(
92+
`evaluating frontmatter of ${req.path}: ${(e && e.stack) || e}`,
93+
);
94+
}
95+
process.stdout.write(ctx.__result ?? "{}");
96+
}
97+
98+
main().catch((e) => {
99+
process.stderr.write(`extract_astro_vars: ${(e && e.stack) || e}\n`);
100+
process.exit(1);
101+
});

0 commit comments

Comments
 (0)