import test from 'node:test'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import vm from 'node:vm'; const source = await readFile(new URL('./public/app.js', import.meta.url), 'utf8'); const html = await readFile(new URL('./public/index.html', import.meta.url), 'utf8'); const plannerSource = await readFile(new URL('./public/netplan-v4.js', import.meta.url), 'utf8'); function section(start, end) { const first = source.indexOf(start), last = source.indexOf(end, first + start.length); assert.ok(first >= 0 && last > first, `integration source boundaries: ${start}`); return source.slice(first, last); } const mountSource = section(' var integratedPlanner = null;', ' function faultDate('); const viewSource = section(' function showView(name)', ' async function loadPortalHealth('); const authSource = section(' function showAuth()', ' function workspaceOptions('); const importPattern = /import\(("\/netplan-v4\.js[^"\n]*")\)/; assert.match(mountSource, importPattern); const executableSource = mountSource.replace(importPattern, 'importPlanner($1)') + '\n' + viewSource + '\n' + authSource; function deferred() { let resolve, reject; const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); return { promise, resolve, reject }; } const settle = () => new Promise(resolve => setImmediate(resolve)); function node(id, active = false) { const classes = new Set(active ? ['active'] : []); return { id, textContent: '', replaceChildren() { this.textContent = ''; }, classList: { contains(name) { return classes.has(name); }, add(name) { classes.add(name); }, remove(name) { classes.delete(name); }, toggle(name, enabled) { if (enabled) classes.add(name); else classes.delete(name); } } }; } function harness(options = {}) { const nodes = new Map(); const byId = id => { if (!nodes.has(id)) nodes.set(id, node(id, id === 'view-forecast' && options.active !== false)); return nodes.get(id); }; const calls = { imports: [], api: [], mounted: [], disposed: [], rendered: [], legacyStatus: [], configuration: [] }; let plant = { id: 'A', installation_id: 'installation-A', manager_variant: 'manager_standard' }; const plannerModule = { mountPlanner(host, config) { calls.mounted.push(config.plantId); host.textContent = `mounted:${config.plantId}`; return { ready: options.ready ? options.ready(config.plantId) : Promise.resolve(), dispose() { calls.disposed.push(config.plantId); host.replaceChildren(); } }; } }; const state = { session: { csrfToken: 'test-only' }, forecastRequest: 0, forecastHistoryDays: 1 }; const context = vm.createContext({ state, byId, encodeURIComponent, document: { querySelectorAll(selector) { return selector === '.view' ? [byId('view-forecast'), byId('view-overview')] : []; } }, history: { replaceState() {} }, location: { pathname: '/', search: '' }, window: { scrollTo() {} }, selectedPlant() { return plant; }, managerLabel(value) { return value; }, resetForecastChart() {}, renderForecastLicenseWarning() {}, setForecastState(message) { calls.legacyStatus.push(message); }, renderForecastResult(result) { calls.rendered.push(result); }, loadForecastConfiguration(selected, requestId) { calls.configuration.push({ plant: selected.id, requestId }); return Promise.resolve(); }, loadInvitations() {}, loadOrders() {}, updateFaults() {}, api(url) { calls.api.push(url); return options.api ? options.api(url) : Promise.resolve({ status: 'available', source: url }); }, importPlanner(url) { calls.imports.push(url); return options.importPlanner ? options.importPlanner(url, plannerModule) : Promise.resolve(plannerModule); } }); vm.runInContext(executableSource + '\nthis.integration = {updateForecast, showView, showAuth, currentPlanner: () => integratedPlanner};', context); return { state, calls, byId, module: plannerModule, ...context.integration, select(id, linked = true) { plant = id ? { id, installation_id: linked ? `installation-${id}` : null, manager_variant: 'manager_standard' } : null; } }; } test('release assets expose the host, stylesheet and relative chart dependency', async () => { assert.equal((html.match(/id="forecastPlanner"/g) || []).length, 1); assert.match(html, /href="\/netplan-v4\.css\?[^"\s]+"/); assert.equal(html.match(/src="\/app\.js[^"\s]*"/)?.[0], 'src="/app.js?v=20261006-forecast"', 'entrypoint version must invalidate the pre-integration cached app'); assert.ok(html.indexOf('id="forecastPlanner"') > html.indexOf('id="view-forecast"')); assert.match(plannerSource, /export function mountPlanner\(/); const dependency = plannerSource.match(/from ['"](\.\/forecast-chart\.js)['"]/); assert.ok(dependency, 'chart import remains relative to the deployed planner module'); assert.ok((await readFile(new URL('./public/' + dependency[1], import.meta.url), 'utf8')).includes('export function renderForecastChart')); }); test('inactive views and unlinked plants do not mount the planner', async () => { const hidden = harness({ active: false }); await hidden.updateForecast(); await settle(); assert.equal(hidden.calls.imports.length, 0); assert.equal(hidden.calls.api.length, 0); const unlinked = harness(); unlinked.select('A', false); await unlinked.updateForecast(); await settle(); assert.equal(unlinked.calls.mounted.length, 0); assert.equal(unlinked.calls.api.length, 1); }); test('changing plants invalidates stale imports and legacy responses', async () => { const imports = [deferred(), deferred()], results = [deferred(), deferred()]; let importIndex = 0, resultIndex = 0; const app = harness({ importPlanner: () => imports[importIndex++].promise, api: () => results[resultIndex++].promise }); const first = app.updateForecast(); app.select('B'); const second = app.updateForecast(); imports[0].resolve(app.module); await settle(); assert.deepEqual(app.calls.mounted, []); imports[1].resolve(app.module); results[1].resolve({ status: 'available', plant: 'B' }); results[0].resolve({ status: 'available', plant: 'A' }); await Promise.all([first, second]); await settle(); assert.deepEqual(app.calls.mounted, ['B']); assert.deepEqual(app.calls.rendered.map(result => result.plant), ['B']); assert.deepEqual(app.calls.configuration.map(entry => entry.plant), ['B']); }); test('leaving the forecast view disposes the mounted planner and invalidates pending imports', async () => { const app = harness(); await app.updateForecast(); await settle(); const revision = app.state.forecastRequest; app.showView('overview'); assert.equal(app.state.forecastRequest, revision + 1); assert.deepEqual(app.calls.disposed, ['A']); assert.equal(app.currentPlanner(), null); const imported = deferred(), pending = harness({ importPlanner: () => imported.promise }); await pending.updateForecast(); pending.showView('overview'); imported.resolve(pending.module); await settle(); assert.deepEqual(pending.calls.mounted, []); }); test('returning to authentication disposes the planner and invalidates pending responses', async () => { const ready = deferred(), app = harness({ ready: () => ready.promise }); await app.updateForecast(); await settle(); const revision = app.state.forecastRequest; app.showAuth(); assert.equal(app.state.forecastRequest, revision + 1); assert.deepEqual(app.calls.disposed, ['A']); assert.equal(app.currentPlanner(), null); assert.equal(app.byId('portalApp').classList.contains('hidden'), true); ready.reject(new Error('late post-logout failure')); await settle(); assert.equal(app.byId('forecastPlanner').textContent, ''); const imported = deferred(), result = deferred(); const pending = harness({ importPlanner: () => imported.promise, api: () => result.promise }); const update = pending.updateForecast(); pending.showAuth(); imported.resolve(pending.module); result.resolve({ status: 'available', plant: 'A' }); await update; await settle(); assert.deepEqual(pending.calls.mounted, []); assert.deepEqual(pending.calls.rendered, []); assert.deepEqual(pending.calls.configuration, []); }); test('legacy API failures do not clear or prevent the independent planner', async () => { const app = harness({ api: () => Promise.reject(new Error('legacy unavailable')) }); await app.updateForecast(); await settle(); assert.deepEqual(app.calls.mounted, ['A']); assert.equal(app.byId('forecastPlanner').textContent, 'mounted:A'); assert.equal(app.calls.legacyStatus.at(-1), 'legacy unavailable'); }); test('planner import failures remain inside the planner host', async () => { const app = harness({ importPlanner: () => Promise.reject(new Error('planner unavailable')) }); await app.updateForecast(); await settle(); assert.equal(app.calls.rendered.length, 1); assert.match(app.byId('forecastPlanner').textContent, /planner unavailable/); assert.ok(!app.calls.legacyStatus.includes('planner unavailable')); }); test('late planner errors cannot overwrite the next plant after disposal', async () => { const ready = deferred(), app = harness({ ready: id => id === 'A' ? ready.promise : Promise.resolve() }); await app.updateForecast(); await settle(); app.select('B'); await app.updateForecast(); await settle(); ready.reject(new Error('late old failure')); await settle(); assert.deepEqual(app.calls.disposed, ['A']); assert.deepEqual(app.calls.mounted, ['A', 'B']); assert.equal(app.byId('forecastPlanner').textContent, 'mounted:B'); });