v0.13.2
This commit is contained in:
@@ -7,6 +7,7 @@ All operator-facing scripts live in this directory.
|
||||
- `service.sh` — start, stop, restart, status, logs and health helper.
|
||||
- `dev.sh` — development build/run/check workflow.
|
||||
- `smoke.sh` — HTTP/API smoke test used by `dev.sh --check`.
|
||||
- `live_realtime_test.js` — Node-based regression test for WebSocket live-state updates, control-plan push/fallback and reconnect behavior.
|
||||
- `generate_ha_migration.py` — legacy/manual Home Assistant entity mapping helper.
|
||||
- `install-lxc.sh` — compatibility alias for `install.sh`.
|
||||
- `common.sh` — shared shell functions; normally not executed directly.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Developer smoke/integration tests for GREE Controller API 0.13.1.
|
||||
"""Developer smoke/integration tests for GREE Controller API 0.13.2.
|
||||
|
||||
Default mode is read-only and safe to run against a real controller.
|
||||
Use --settings-write to additionally round-trip all split settings resources and
|
||||
@@ -26,7 +26,7 @@ from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
DEFAULT_BASE_URL = os.environ.get("GREE_API_URL", "http://127.0.0.1:8787")
|
||||
DEFAULT_TOKEN = os.environ.get("GREE_API_TOKEN", "")
|
||||
DEFAULT_EXPECTED_VERSION = "0.13.1"
|
||||
DEFAULT_EXPECTED_VERSION = "0.13.2"
|
||||
|
||||
SETTINGS_PATHS: dict[str, set[str]] = {
|
||||
"/api/settings/application": {"simulator_enabled"},
|
||||
@@ -519,7 +519,7 @@ def run_suite(args: argparse.Namespace) -> int:
|
||||
print(f"Settings write tests: {'ENABLED' if args.settings_write else 'disabled'}\n")
|
||||
|
||||
runner.run("public health", lambda: test_health(client, args.expected_version))
|
||||
runner.run("OpenAPI 0.13.1 contract", lambda: test_openapi(client, args.expected_version))
|
||||
runner.run("OpenAPI 0.13.2 contract", lambda: test_openapi(client, args.expected_version))
|
||||
runner.run("protected API requires auth", lambda: test_protected_auth(client))
|
||||
|
||||
for path in SAFE_GET_PATHS:
|
||||
@@ -587,7 +587,7 @@ def run_suite(args: argparse.Namespace) -> int:
|
||||
|
||||
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Developer smoke/integration tests for GREE Controller API 0.13.1.",
|
||||
description="Developer smoke/integration tests for GREE Controller API 0.13.2.",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""Examples:
|
||||
python3 scripts/api_dev_test.py
|
||||
|
||||
Executable
+278
@@ -0,0 +1,278 @@
|
||||
#!/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: [],
|
||||
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,
|
||||
loadSettingsSections: async () => {},
|
||||
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/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.loadSettingsSections = async () => {};
|
||||
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: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'second' }, control_plan_revision: 2 };
|
||||
},
|
||||
fetchSettingsSections: async () => ({ application: {}, gree: {}, history: {}, influxdb: {}, notifications: {}, night: {}, home_assistant: {}, debug: {} }),
|
||||
aggregateSettingsSections: () => ({ home_assistant: {}, debug: { overlay_enabled: false } }),
|
||||
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: [], house: { mode: 'cool' }, system: {}, control_plan: { marker: 'first' }, control_plan_revision: 1 });
|
||||
await first;
|
||||
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();
|
||||
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' }],
|
||||
house: { mode: 'heat' }, system: { ok: true }, outdoor_temperature: 10,
|
||||
control_plan: { marker: 'p1' }, control_plan_revision: 1,
|
||||
});
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user