Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
28 changes: 23 additions & 5 deletions hailbytes-vuln-calculator.js
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,7 @@ const STYLES = `

// ─── Template ─────────────────────────────────────────────────────────────────

const TMPL = document.createElement('template');
TMPL.innerHTML = `<style>${STYLES}</style>
const TEMPLATE_HTML = `<style>${STYLES}</style>
<div class="header">
<h1><span class="icon">🔍</span> Vulnerability Scanner Infrastructure Calculator <span class="hailbytes-badge">HailBytes</span></h1>
<p>Size your scanning infrastructure — estimate VM requirements, cloud costs, and ROI entirely in the browser</p>
Expand Down Expand Up @@ -644,13 +643,28 @@ TMPL.innerHTML = `<style>${STYLES}</style>

// ─── Web Component ────────────────────────────────────────────────────────────

class HailbytesVulnCalculator extends HTMLElement {
// The component depends on the DOM, but the exported `calculate()` does not.
// Build the <template> 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;
}

Expand Down Expand Up @@ -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 };
28 changes: 28 additions & 0 deletions test/smoke.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(){} }; }
Expand Down Expand Up @@ -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));
Expand Down
Loading