304 lines
15 KiB
JavaScript
Executable File
304 lines
15 KiB
JavaScript
Executable File
#!/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, wsErrorTimer: null, wsOutageStartedAt: 0,
|
|
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-dynamic/dashboard.js'), 'utf8'), context, { filename: 'dashboard.js' });
|
|
vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/bootstrap.js'), 'utf8'), context, { filename: 'bootstrap.js' });
|
|
vm.runInContext(fs.readFileSync(path.join(root, 'web/js-dynamic/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-dynamic/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.equal(calls['connection:reconnecting'], 1, 'short WS outage must show reconnecting state');
|
|
assert(timers.length >= beforeCloseTimers + 3, 'close must schedule fallback, reconnect and delayed error state');
|
|
const errorTimer = timers.find(item => !item.cleared && item.delay === 15000);
|
|
assert(errorTimer, 'persistent WS outage must schedule a delayed connection-error state');
|
|
errorTimer.fn();
|
|
assert.equal(calls['connection:connectionError'], 1, 'persistent WS outage must become a connection error');
|
|
|
|
const reconnectTimer = timers.find(item => !item.cleared && item.delay === 3000);
|
|
assert(reconnectTimer, 'WS close must schedule reconnect');
|
|
reconnectTimer.fn();
|
|
const reconnectedSocket = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
|
reconnectedSocket.readyState = FakeWebSocket.OPEN;
|
|
reconnectedSocket.onopen();
|
|
assert.equal(calls['connection:connected'], 2, 'successful reconnect must restore connected state');
|
|
assert.equal(app.wsOutageStartedAt, 0, 'successful reconnect must clear outage tracking');
|
|
|
|
console.log(`Live realtime test OK (${apiCalls.length} API fallback/resync calls observed)`);
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(error.stack || error);
|
|
process.exit(1);
|
|
});
|