#!/usr/bin/env node 'use strict'; const assert = require('assert'); const fs = require('fs'); const path = require('path'); const vm = require('vm'); const root = path.resolve(__dirname, '..'); const timers = []; let timerId = 0; let apiCalls = []; let apiResponder = async requestPath => requestPath === '/api/schedules' ? [] : {}; const calls = {}; const hit = name => () => { calls[name] = (calls[name] || 0) + 1; }; class FakeWebSocket { static OPEN = 1; static CONNECTING = 0; static CLOSED = 3; static instances = []; constructor(url) { this.url = url; this.readyState = FakeWebSocket.CONNECTING; FakeWebSocket.instances.push(this); } } const app = { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], accessTokens: [], settings: { house_mode: 'cool', debug: { overlay_enabled: false }, home_assistant: {} }, system: {}, outdoorTemperature: null, controlPlan: null, controlPlanRevision: null, controlPlanPushReady: false, controlPlanTimer: null, currentView: 'dashboard', sensorAliases: {}, flowSharedInputs: [], flowDraft: null, ws: null, wsTimer: null, token: '', debugBacklogLoaded: false, }; const context = { console, JSON, Number, Date, Intl, Promise, app, WebSocket: FakeWebSocket, location: { protocol: 'http:', host: 'controller.test' }, setTimeout: (fn, delay) => { const id = ++timerId; timers.push({ id, fn, delay, cleared: false }); return id; }, clearTimeout: id => { const timer = timers.find(item => item.id === id); if (timer) timer.cleared = true; }, api: async (...args) => { apiCalls.push(args); return apiResponder(...args); }, withBase: value => value, $: () => null, isFormDirty: () => false, applySettingsSection: (name, data) => { app.settings[name] = data; }, loadDebugBacklog: hit('loadDebugBacklog'), loadBootstrap: async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; }, updateConnectionIndicator: status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; }, updateDevice: device => { const index = app.devices.findIndex(item => item.id === device.id); if (index >= 0) app.devices[index] = device; else app.devices.push(device); }, toast: () => {}, tr: key => key, esc: value => String(value), locale: () => 'en-GB', logCategory: () => 'test', debugLine: hit('debugLine'), }; const renderNames = [ 'renderAll', 'renderSummary', 'renderDevices', 'renderGroups', 'renderHouseClimate', 'renderZones', 'renderFlows', 'renderSchedules', 'renderAutomations', 'renderSettings', 'renderSimulationModeBanner', 'renderSystemInfo', 'renderLogRetention', 'renderNightSettings', 'renderHomeAssistantSettings', 'renderDebugOverlay', 'renderFlowEditor', 'renderGreeFrameStats', 'renderControlPlan', 'renderSimulationPage', 'fillSelects', ]; for (const name of renderNames) context[name] = hit(name); vm.createContext(context); vm.runInContext(fs.readFileSync(path.join(root, 'web/js/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' }); vm.runInContext(fs.readFileSync(path.join(root, 'web/js/bootstrap.js'), 'utf8'), context, { filename: 'bootstrap.js' }); vm.runInContext(fs.readFileSync(path.join(root, 'web/js/realtime.js'), 'utf8'), context, { filename: 'realtime.js' }); // Source files define DOM-heavy renderers. Replace those implementations for this state-machine test. for (const name of renderNames) context[name] = hit(name); context.updateDevice = device => { const index = app.devices.findIndex(item => item.id === device.id); if (index >= 0) app.devices[index] = device; else app.devices.push(device); }; context.applySettingsSection = (name, data) => { app.settings[name] = data; }; context.loadDebugBacklog = hit('loadDebugBacklog'); context.loadBootstrap = async () => { calls.loadBootstrap = (calls.loadBootstrap || 0) + 1; }; context.isFormDirty = () => false; context.updateConnectionIndicator = status => { calls[`connection:${status}`] = (calls[`connection:${status}`] || 0) + 1; }; context.withBase = value => value; async function send(event, data) { await context.handleWebSocketMessage({ data: JSON.stringify({ event, timestamp: new Date().toISOString(), data }), }); } async function testBootstrapReloadQueue() { const queuedTimers = []; let firstResolve; let bootstrapCalls = 0; const firstResponse = new Promise(resolve => { firstResolve = resolve; }); const c = { console, Promise, Date, app: { loading: false, bootstrapReloadPending: false, controlPlan: null, controlPlanRevision: null, settings: {}, sensorAliases: {}, flowSharedInputs: {}, system: {}, debugBacklogLoaded: false }, api: async requestPath => { assert.equal(requestPath, '/api/bootstrap'); bootstrapCalls += 1; if (bootstrapCalls === 1) return firstResponse; return { devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, house: { mode: 'cool' }, system: {}, control_plan: { marker: 'second' }, control_plan_revision: 2 }; }, renderAll: () => {}, scheduleControlPlanLoad: () => {}, loadDebugBacklog: () => {}, toast: () => {}, tr: key => key, $: () => ({ open: false }), connectWebSocket: () => {}, setTimeout: (fn, delay) => { queuedTimers.push({ fn, delay }); return queuedTimers.length; }, }; vm.createContext(c); vm.runInContext(fs.readFileSync(path.join(root, 'web/js/bootstrap.js'), 'utf8'), c, { filename: 'bootstrap.js' }); const first = c.loadBootstrap(); assert.equal(c.app.loading, true); await c.loadBootstrap(); assert.equal(c.app.bootstrapReloadPending, true, 'a bootstrap request during loading must be queued'); firstResolve({ devices: [], zones: [], groups: [], schedules: [], automations: [], flows: [], access_tokens: [], settings: {}, house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 }); await first; assert.equal(c.app.outdoorTemperature, null, 'null/missing outdoor temperature must stay unknown, not become 0 C'); assert.equal(c.app.bootstrapReloadPending, false); assert.equal(queuedTimers.length, 1); assert.equal(queuedTimers[0].delay, 0); queuedTimers[0].fn(); await new Promise(resolve => setImmediate(resolve)); assert.equal(bootstrapCalls, 2, 'queued bootstrap reload must run after the first request finishes'); } async function main() { await testBootstrapReloadQueue(); const apiCallsBeforeBootstrap = apiCalls.length; await send('bootstrap', { devices: [{ id: 'd1', power: false }], zones: [{ id: 'z1', demand: false }], groups: [{ id: 'g1', name: 'G1' }], schedules: [{ id: 's1', name: 'S1' }], automations: [{ id: 'a1', name: 'A1' }], flows: [{ id: 'f1', name: 'F1' }], access_tokens: [{ id: 't1', name: 'HA', token_prefix: 'abc', created_at: '2026-09-07T08:00:00Z' }], settings: { application: { simulator_enabled: true }, gree: { controller_id: 'test-controller' }, history: { retention_days: 30 }, influxdb: {}, notifications: {}, night: {}, home_assistant: { sensor_aliases: { 'sensor.bootstrap': 'Bootstrap' }, flow_inputs: [], outdoor_assist_enabled: true }, debug: { overlay_enabled: false }, }, house: { mode: 'heat' }, system: { ok: true }, outdoor_temperature: 10, control_plan: { marker: 'p1' }, control_plan_revision: 1, }); assert.equal(apiCalls.length, apiCallsBeforeBootstrap, 'WebSocket bootstrap must not fetch split settings endpoints'); assert.equal(app.accessTokens[0].id, 't1', 'WebSocket bootstrap must refresh access-token metadata'); assert.equal(app.settings.controller_id, 'test-controller'); assert.equal(app.sensorAliases['sensor.bootstrap'], 'Bootstrap'); assert.equal(app.controlPlan.marker, 'p1'); assert.equal(app.controlPlanRevision, 1); assert.equal(app.controlPlanPushReady, true); await send('control_plan.updated', { revision: 2, plan: { marker: 'p2' } }); await send('control_plan.updated', { revision: 1, plan: { marker: 'stale' } }); assert.equal(app.controlPlan.marker, 'p2', 'stale control-plan revision must be ignored'); app.ws = { readyState: FakeWebSocket.OPEN }; const timerCountWhilePushReady = timers.length; await send('device.updated', { id: 'd1', power: true, online: true }); assert.equal(app.devices[0].power, true); await send('device.created', { id: 'd2', power: false }); assert(app.devices.some(item => item.id === 'd2')); await send('devices.discovered', { devices: [{ id: 'd3', power: false }] }); assert(app.devices.some(item => item.id === 'd3')); await send('device.deleted', { id: 'd2' }); assert(!app.devices.some(item => item.id === 'd2')); await send('zone.updated', { id: 'z1', demand: true, current_temperature: 22.1 }); assert.equal(app.zones[0].demand, true); await send('zone.created', { id: 'z2', demand: false }); assert(app.zones.some(item => item.id === 'z2')); await send('zone.deleted', { id: 'z2' }); assert(!app.zones.some(item => item.id === 'z2')); await send('group.updated', { id: 'g1', name: 'G1 updated' }); assert.equal(app.groups[0].name, 'G1 updated'); await send('group.created', { id: 'g2', name: 'G2' }); assert(app.groups.some(item => item.id === 'g2')); await send('group.deleted', { id: 'g2' }); assert(!app.groups.some(item => item.id === 'g2')); await send('schedule.updated', { id: 's1', name: 'S1 updated' }); assert.equal(app.schedules[0].name, 'S1 updated'); await send('schedule.created', { id: 's2', name: 'S2' }); assert(app.schedules.some(item => item.id === 's2')); await send('schedule.deleted', { id: 's2' }); assert(!app.schedules.some(item => item.id === 's2')); apiResponder = async requestPath => requestPath === '/api/schedules' ? [{ id: 's3', name: 'Template' }] : {}; await send('schedule.template_applied', { zone_id: 'z1', template: 'family', count: 2 }); assert.equal(app.schedules[0].id, 's3'); await send('automation.updated', { id: 'a1', name: 'A1 updated', last_fired_at: '2026-09-04T09:00:00Z' }); assert.equal(app.automations[0].name, 'A1 updated'); await send('automation.created', { id: 'a2', name: 'A2' }); assert(app.automations.some(item => item.id === 'a2')); await send('automation.deleted', { id: 'a2' }); assert(!app.automations.some(item => item.id === 'a2')); await send('flow.updated', { id: 'f1', name: 'F1 updated' }); assert.equal(app.flows[0].name, 'F1 updated'); await send('flow.created', { id: 'f2', name: 'F2' }); assert(app.flows.some(item => item.id === 'f2')); await send('flow.deleted', { id: 'f2' }); assert(!app.flows.some(item => item.id === 'f2')); await send('settings.application.updated', { poll_interval_seconds: 15 }); await send('settings.gree.updated', { command_timeout_ms: 1000 }); await send('settings.history.updated', { retention_days: 30 }); await send('settings.influxdb.updated', { enabled: false }); await send('settings.notifications.updated', { enabled: false }); await send('settings.debug.updated', { overlay_enabled: false }); await send('settings.night.updated', { enabled: true, start_time: '22:00', end_time: '06:00' }); await send('settings.home_assistant.updated', { sensor_aliases: { 'sensor.room': 'Room' }, flow_inputs: [] }); assert.equal(app.settings.night.enabled, true); assert.equal(app.sensorAliases['sensor.room'], 'Room'); await send('house.mode_changed', { mode: 'off' }); assert.equal(app.settings.house_mode, 'off'); await send('outdoor.updated', { temperature: 12.3 }); assert.equal(app.outdoorTemperature, 12.3); await send('gree.frame_received', { total: 5, device_id: 'd1', device_count: 3 }); assert.equal(app.system.gree_received_frames, 5); assert.equal(app.system.gree_received_frames_by_device.d1, 3); app.settings.debug = { overlay_enabled: true }; await send('gree.frame', { direction: 'rx', payload: {} }); await send('api.request', { method: 'GET', status: 200, path: '/api/health', duration_ms: 1 }); await send('log.created', { level: 'info', kind: 'test', message: 'ok', metadata: {} }); await send('configuration.imported', { at: new Date().toISOString() }); assert.equal(calls.loadBootstrap, 1, 'configuration import must full-resync connected UIs'); // With a valid pushed control plan, ordinary state events must not re-enable HTTP control-plan polling. assert.equal(timers.length, timerCountWhilePushReady, 'push-ready live events must not schedule control-plan HTTP fallback'); // When push is unavailable, state changes must schedule the existing HTTP fallback. app.controlPlanPushReady = false; app.ws = { readyState: FakeWebSocket.CLOSED }; const fallbackTimerCount = timers.length; await send('zone.updated', { id: 'z1', demand: false }); assert(timers.length > fallbackTimerCount); assert.equal(timers[timers.length - 1].delay, 180); // An HTTP fallback started while disconnected must never overwrite a newer pushed revision after reconnect. let resolveApi; apiResponder = () => new Promise(resolve => { resolveApi = resolve; }); const pendingHttp = context.loadControlPlan(); app.ws = { readyState: FakeWebSocket.OPEN }; await send('control_plan.updated', { revision: 3, plan: { marker: 'p3' } }); resolveApi({ marker: 'old-http' }); await pendingHttp; assert.equal(app.controlPlan.marker, 'p3'); // Connection lifecycle still switches to fallback and schedules reconnect. app.ws = null; app.controlPlanPushReady = true; context.connectWebSocket(); const socket = FakeWebSocket.instances[FakeWebSocket.instances.length - 1]; assert.equal(socket.url, 'ws://controller.test/ws'); socket.readyState = FakeWebSocket.OPEN; socket.onopen(); assert.equal(calls['connection:connected'], 1); const beforeCloseTimers = timers.length; socket.readyState = FakeWebSocket.CLOSED; socket.onclose(); assert.equal(app.controlPlanPushReady, false); assert(timers.length >= beforeCloseTimers + 2, 'close must schedule fallback and reconnect'); console.log(`Live realtime test OK (${apiCalls.length} API fallback/resync calls observed)`); } main().catch(error => { console.error(error.stack || error); process.exit(1); });