diff --git a/hailbytes-vuln-calculator.js b/hailbytes-vuln-calculator.js index 1d2c2aa..d875174 100644 --- a/hailbytes-vuln-calculator.js +++ b/hailbytes-vuln-calculator.js @@ -550,8 +550,7 @@ const STYLES = ` // ─── Template ───────────────────────────────────────────────────────────────── -const TMPL = document.createElement('template'); -TMPL.innerHTML = ` +const TEMPLATE_HTML = `
Size your scanning infrastructure — estimate VM requirements, cloud costs, and ROI entirely in the browser
@@ -644,13 +643,28 @@ TMPL.innerHTML = ` // ─── Web Component ──────────────────────────────────────────────────────────── -class HailbytesVulnCalculator extends HTMLElement { +// The component depends on the DOM, but the exported `calculate()` does not. +// Build the lazily (on first construction) and fall back to a plain +// base class when HTMLElement is unavailable, so this module can be imported in +// Node / SSR environments without throwing — see calculate() below. +let TMPL = null; +function getTemplate() { + if (!TMPL) { + TMPL = document.createElement('template'); + TMPL.innerHTML = TEMPLATE_HTML; + } + return TMPL; +} + +const BaseElement = typeof HTMLElement !== 'undefined' ? HTMLElement : class {}; + +class HailbytesVulnCalculator extends BaseElement { static get observedAttributes() { return ['theme', 'branding']; } constructor() { super(); this._shadow = this.attachShadow({ mode: 'open' }); - this._shadow.appendChild(TMPL.content.cloneNode(true)); + this._shadow.appendChild(getTemplate().content.cloneNode(true)); this._lastResult = null; } @@ -856,7 +870,11 @@ class HailbytesVulnCalculator extends HTMLElement { } } -customElements.define('hailbytes-vuln-calculator', HailbytesVulnCalculator); +// Only register the custom element when running in a browser-like environment. +// Importing the module purely for calculate() (Node, SSR) must not throw. +if (typeof customElements !== 'undefined' && typeof HTMLElement !== 'undefined') { + customElements.define('hailbytes-vuln-calculator', HailbytesVulnCalculator); +} export default HailbytesVulnCalculator; export { HailbytesVulnCalculator, calculateResources as calculate }; diff --git a/test/smoke.test.mjs b/test/smoke.test.mjs index 5573ccf..dcbb390 100644 --- a/test/smoke.test.mjs +++ b/test/smoke.test.mjs @@ -3,6 +3,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; class FakeElement { constructor() { this.children = []; this.style = {}; this.classList = { add(){}, remove(){}, toggle(){} }; } @@ -78,6 +80,32 @@ test('calculate() recognises HailBytes ASM tool', () => { assert.equal(r.has_asm, true); }); +// Regression guard: the module must be importable — and calculate() usable — +// in a pure Node / SSR environment with no DOM globals. The tests above install +// a DOM shim on globalThis, so we spawn a fresh, unshimmed Node process to prove +// importing the module no longer touches `document`/`HTMLElement` at load time. +test('module imports and calculate() runs in a pure Node environment (no DOM shim)', () => { + const moduleUrl = new URL('../hailbytes-vuln-calculator.js', import.meta.url).href; + const script = ` + import { calculate, default as Component } from ${JSON.stringify(moduleUrl)}; + if (typeof calculate !== 'function') { console.error('calculate not a function'); process.exit(1); } + if (typeof Component !== 'function') { console.error('default export not a class'); process.exit(1); } + const r = calculate({ + target_hosts: 1000, scan_intensity: 'medium', scan_frequency: 'weekly', + scan_window: 8, scanning_tools: ['hailbytes_asm'], compliance_needs: [], + }); + if (!r || !r.vm_resources || typeof r.vm_resources.cpu_cores !== 'number') { + console.error('unexpected result shape'); process.exit(1); + } + console.log('PURE_OK'); + `; + const out = execFileSync(process.execPath, ['--input-type=module', '-e', script], { + encoding: 'utf8', + cwd: fileURLToPath(new URL('.', import.meta.url)), + }); + assert.match(out, /PURE_OK/); +}); + test('class declares observedAttributes including theme and branding', () => { const observed = mod.default.observedAttributes; assert.ok(Array.isArray(observed));