- Fix WebSocket mock to use factory function so resetAllMocks doesn't break it (state.ws.close is not a function errors) - Fix TextEncoderStream mock to provide functional readable/writable for pipeTo (needed by provisioning serial send tests) - Fix flash_firmware test to check wizard-nav for "Skip Flashing" button instead of wizard-content - Fix provisionAndSend "no port" test to use mockResolvedValue instead of mockResolvedValueOnce so both primary and fallback paths fail consistently All 60 tests now pass. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1113 lines
37 KiB
JavaScript
1113 lines
37 KiB
JavaScript
/**
|
|
* Tests for the Spaxel Onboarding Wizard.
|
|
*/
|
|
|
|
// Load the wizard script (IIFE attaches to window.SpaxelOnboard)
|
|
require('./onboard.js');
|
|
|
|
const { SpaxelOnboard } = global;
|
|
const { _CONFIG, _STEPS, _parseCSIFrame, _state, _UserError, _isUserError, _provisionAndSend } = SpaxelOnboard;
|
|
|
|
// Reset state between tests
|
|
function resetWizardState() {
|
|
_state.currentStepIndex = -1;
|
|
_state.port = null;
|
|
_state.nodeMAC = null;
|
|
_state.knownMACs = [];
|
|
_state.wifiSSID = '';
|
|
_state.wifiPass = '';
|
|
_state.mothershipHost = '';
|
|
_state.mothershipPort = 8080;
|
|
_state.pollTimer = null;
|
|
_state.calibrateTimer = null;
|
|
_state.calibratePhase = 'idle';
|
|
_state.ws = null;
|
|
_state.csiHistory = [];
|
|
_state.container = null;
|
|
|
|
// Clear sessionStorage
|
|
sessionStorage.clear();
|
|
// resetAllMocks clears mockRejectedValueOnce/mockResolvedValueOnce queues
|
|
// that clearAllMocks misses, then re-apply default implementations
|
|
jest.resetAllMocks();
|
|
fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue([]),
|
|
});
|
|
navigator.serial.requestPort.mockResolvedValue(__mockPort);
|
|
navigator.serial.getPorts.mockResolvedValue([__mockPort]);
|
|
crypto.randomUUID.mockReturnValue('test-uuid-1234');
|
|
// Re-apply port mock implementations (resetAllMocks clears them)
|
|
__mockPort.open.mockResolvedValue(undefined);
|
|
__mockPort.close.mockResolvedValue(undefined);
|
|
__mockPort.readable.pipeTo.mockResolvedValue(undefined);
|
|
// Re-apply WebSocket mock (resetAllMocks clears mockImplementation)
|
|
WebSocket.mockImplementation(function () {
|
|
return {
|
|
binaryType: 'arraybuffer',
|
|
close: jest.fn(),
|
|
send: jest.fn(),
|
|
readyState: 1,
|
|
onopen: null,
|
|
onclose: null,
|
|
onerror: null,
|
|
onmessage: null,
|
|
};
|
|
});
|
|
}
|
|
|
|
// ============================================
|
|
// Configuration and Step Definitions
|
|
// ============================================
|
|
describe('Onboard configuration', () => {
|
|
test('has correct poll interval', () => {
|
|
expect(_CONFIG.nodePollInterval).toBe(3000);
|
|
});
|
|
|
|
test('has correct poll timeout', () => {
|
|
expect(_CONFIG.nodePollTimeout).toBe(120000);
|
|
});
|
|
|
|
test('has correct endpoints', () => {
|
|
expect(_CONFIG.provisioningEndpoint).toBe('/api/provision');
|
|
expect(_CONFIG.nodesEndpoint).toBe('/api/nodes');
|
|
});
|
|
});
|
|
|
|
describe('Step definitions', () => {
|
|
test('has 8 steps in correct order', () => {
|
|
expect(_STEPS.length).toBe(8);
|
|
expect(_STEPS.map(s => s.id)).toEqual([
|
|
'browser_check',
|
|
'connect_device',
|
|
'flash_firmware',
|
|
'provision_wifi',
|
|
'detect_node',
|
|
'calibrate',
|
|
'placement',
|
|
'complete',
|
|
]);
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Browser Check
|
|
// ============================================
|
|
describe('Browser check step', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
test('detects Web Serial API is available', () => {
|
|
// navigator.serial is mocked in setup
|
|
expect(navigator.serial).toBeDefined();
|
|
expect(typeof navigator.serial.requestPort).toBe('function');
|
|
});
|
|
|
|
test('UserError is correctly identified', () => {
|
|
var err = new _UserError('test message');
|
|
expect(_isUserError(err)).toBe(true);
|
|
expect(err.message).toBe('test message');
|
|
});
|
|
|
|
test('regular Error is not identified as UserError', () => {
|
|
var err = new Error('regular error');
|
|
expect(_isUserError(err)).toBe(false);
|
|
});
|
|
|
|
test('error with UserError name is identified', () => {
|
|
var err = new Error('test');
|
|
err.name = 'UserError';
|
|
expect(_isUserError(err)).toBe(true);
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Session Storage Persistence
|
|
// ============================================
|
|
describe('State persistence', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
test('saves and loads state from sessionStorage', () => {
|
|
_state.currentStepIndex = 3;
|
|
_state.nodeMAC = 'AA:BB:CC:DD:EE:FF';
|
|
_state.wifiSSID = 'TestWiFi';
|
|
_state.mothershipPort = 9090;
|
|
|
|
// Trigger save (via goToStep or directly)
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: _state.currentStepIndex,
|
|
nodeMAC: _state.nodeMAC,
|
|
knownMACs: _state.knownMACs,
|
|
wifiSSID: _state.wifiSSID,
|
|
wifiPass: _state.wifiPass,
|
|
mothershipHost: _state.mothershipHost,
|
|
mothershipPort: _state.mothershipPort,
|
|
}));
|
|
|
|
// Simulate load
|
|
var raw = sessionStorage.getItem(_CONFIG.storageKey);
|
|
var loaded = JSON.parse(raw);
|
|
|
|
expect(loaded.currentStepIndex).toBe(3);
|
|
expect(loaded.nodeMAC).toBe('AA:BB:CC:DD:EE:FF');
|
|
expect(loaded.wifiSSID).toBe('TestWiFi');
|
|
expect(loaded.mothershipPort).toBe(9090);
|
|
});
|
|
|
|
test('clearState removes sessionStorage entry', () => {
|
|
sessionStorage.setItem(_CONFIG.storageKey, '{"currentStepIndex":2}');
|
|
sessionStorage.removeItem(_CONFIG.storageKey);
|
|
expect(sessionStorage.getItem(_CONFIG.storageKey)).toBeNull();
|
|
});
|
|
|
|
test('returns null for missing state', () => {
|
|
expect(sessionStorage.getItem(_CONFIG.storageKey)).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Serial Port Handling
|
|
// ============================================
|
|
describe('Serial port handling', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
test('requestPort calls navigator.serial.requestPort', async () => {
|
|
var port = await navigator.serial.requestPort();
|
|
expect(navigator.serial.requestPort).toHaveBeenCalled();
|
|
expect(port).toBeDefined();
|
|
});
|
|
|
|
test('getPorts returns previously authorized ports', async () => {
|
|
var ports = await navigator.serial.getPorts();
|
|
expect(navigator.serial.getPorts).toHaveBeenCalled();
|
|
expect(ports.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test('requestPort throws UserError on NotFoundError', async () => {
|
|
navigator.serial.requestPort.mockRejectedValueOnce({ name: 'NotFoundError' });
|
|
|
|
// The wizard's requestPort wraps errors, but we test the raw mock here
|
|
await expect(navigator.serial.requestPort()).rejects.toEqual(
|
|
expect.objectContaining({ name: 'NotFoundError' })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Provisioning Payload
|
|
// ============================================
|
|
describe('Provisioning payload', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
test('POST /api/provision with WiFi credentials', async () => {
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue({
|
|
version: 1,
|
|
wifi_ssid: 'TestWiFi',
|
|
wifi_pass: 'secret123',
|
|
node_id: 'uuid-123',
|
|
node_token: 'token-abc',
|
|
ms_mdns: 'spaxel-mothership.local',
|
|
ms_port: 8080,
|
|
debug: false,
|
|
}),
|
|
});
|
|
|
|
var resp = await fetch('/api/provision', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ wifi_ssid: 'TestWiFi', wifi_pass: 'secret123' }),
|
|
});
|
|
|
|
expect(fetch).toHaveBeenCalledWith('/api/provision', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ wifi_ssid: 'TestWiFi', wifi_pass: 'secret123' }),
|
|
});
|
|
|
|
var payload = await resp.json();
|
|
expect(payload.wifi_ssid).toBe('TestWiFi');
|
|
expect(payload.node_id).toBe('uuid-123');
|
|
});
|
|
|
|
test('falls back to client-side payload when provisioning server fails', async () => {
|
|
fetch.mockRejectedValueOnce(new Error('server unavailable'));
|
|
fetch.mockRejectedValueOnce(new Error('server unavailable')); // for the nodes fetch fallback
|
|
|
|
// The wizard's provisionAndSend falls back to client-side assembly.
|
|
// We verify the fallback UUID generation works.
|
|
var uuid = crypto.randomUUID();
|
|
expect(uuid).toBe('test-uuid-1234');
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Node Detection Polling
|
|
// ============================================
|
|
describe('Node detection', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
test('polls /api/nodes and detects new node', async () => {
|
|
// Simulate initial state: no known nodes
|
|
_state.knownMACs = [];
|
|
|
|
// First poll: returns new node
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue([
|
|
{ mac: 'AA:BB:CC:DD:EE:FF', role: 'tx', online: true },
|
|
]),
|
|
});
|
|
|
|
var resp = await fetch(_CONFIG.nodesEndpoint);
|
|
var nodes = await resp.json();
|
|
|
|
// Simulate detection logic
|
|
var currentMACs = nodes.map(function (n) { return n.mac; });
|
|
var newMAC = null;
|
|
for (var i = 0; i < currentMACs.length; i++) {
|
|
if (_state.knownMACs.indexOf(currentMACs[i]) === -1) {
|
|
newMAC = currentMACs[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Also accept first node if no known MACs
|
|
if (!newMAC && _state.knownMACs.length === 0 && currentMACs.length > 0) {
|
|
newMAC = currentMACs[0];
|
|
}
|
|
|
|
expect(newMAC).toBe('AA:BB:CC:DD:EE:FF');
|
|
});
|
|
|
|
test('does not detect existing node as new', async () => {
|
|
_state.knownMACs = ['AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66'];
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue([
|
|
{ mac: 'AA:BB:CC:DD:EE:FF', role: 'tx', online: true },
|
|
{ mac: '11:22:33:44:55:66', role: 'rx', online: true },
|
|
]),
|
|
});
|
|
|
|
var resp = await fetch(_CONFIG.nodesEndpoint);
|
|
var nodes = await resp.json();
|
|
|
|
var currentMACs = nodes.map(function (n) { return n.mac; });
|
|
var newMAC = null;
|
|
for (var i = 0; i < currentMACs.length; i++) {
|
|
if (_state.knownMACs.indexOf(currentMACs[i]) === -1) {
|
|
newMAC = currentMACs[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(newMAC).toBeNull();
|
|
});
|
|
|
|
test('detects new node among existing ones', async () => {
|
|
_state.knownMACs = ['AA:BB:CC:DD:EE:FF'];
|
|
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue([
|
|
{ mac: 'AA:BB:CC:DD:EE:FF', role: 'tx', online: true },
|
|
{ mac: '11:22:33:44:55:66', role: 'rx', online: true },
|
|
]),
|
|
});
|
|
|
|
var resp = await fetch(_CONFIG.nodesEndpoint);
|
|
var nodes = await resp.json();
|
|
|
|
var currentMACs = nodes.map(function (n) { return n.mac; });
|
|
var newMAC = null;
|
|
for (var i = 0; i < currentMACs.length; i++) {
|
|
if (_state.knownMACs.indexOf(currentMACs[i]) === -1) {
|
|
newMAC = currentMACs[i];
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(newMAC).toBe('11:22:33:44:55:66');
|
|
});
|
|
|
|
test('handles network error during polling gracefully', async () => {
|
|
fetch.mockRejectedValueOnce(new Error('network error'));
|
|
|
|
// The wizard catches this and retries, so no exception propagates
|
|
await expect(fetch(_CONFIG.nodesEndpoint)).rejects.toThrow('network error');
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// CSI Frame Parsing
|
|
// ============================================
|
|
describe('CSI frame parser', () => {
|
|
test('parses a valid CSI frame', () => {
|
|
// Build a minimal CSI frame: 24-byte header + 4 subcarriers * 2 bytes = 32 bytes
|
|
var buffer = new ArrayBuffer(32);
|
|
var bytes = new Uint8Array(buffer);
|
|
|
|
// Node MAC: AA:BB:CC:DD:EE:FF
|
|
bytes[0] = 0xAA; bytes[1] = 0xBB; bytes[2] = 0xCC;
|
|
bytes[3] = 0xDD; bytes[4] = 0xEE; bytes[5] = 0xFF;
|
|
|
|
// Peer MAC: 11:22:33:44:55:66
|
|
bytes[6] = 0x11; bytes[7] = 0x22; bytes[8] = 0x33;
|
|
bytes[9] = 0x44; bytes[10] = 0x55; bytes[11] = 0x66;
|
|
|
|
// Timestamp: 8 bytes (little-endian) - just fill with zeros
|
|
bytes[12] = 0; bytes[13] = 0; bytes[14] = 0; bytes[15] = 0;
|
|
bytes[16] = 0; bytes[17] = 0; bytes[18] = 0; bytes[19] = 0;
|
|
|
|
// RSSI: -40 dBm (as signed byte)
|
|
bytes[20] = 0xD8; // -40 as uint8
|
|
|
|
// Noise floor: -80 dBm
|
|
bytes[21] = 0xB0; // -80 as uint8
|
|
|
|
// Channel: 6
|
|
bytes[22] = 6;
|
|
|
|
// NSub: 4
|
|
bytes[23] = 4;
|
|
|
|
// Payload: 4 I/Q pairs (int8 values)
|
|
bytes[24] = 10; bytes[25] = 5; // subcarrier 0: I=10, Q=5
|
|
bytes[26] = 20; bytes[27] = 8; // subcarrier 1: I=20, Q=8
|
|
bytes[28] = 15; bytes[29] = 12; // subcarrier 2: I=15, Q=12
|
|
bytes[30] = 25; bytes[31] = 3; // subcarrier 3: I=25, Q=3
|
|
|
|
var frame = _parseCSIFrame(buffer);
|
|
|
|
expect(frame).not.toBeNull();
|
|
expect(frame.nodeMAC).toBe('AA:BB:CC:DD:EE:FF');
|
|
expect(frame.peerMAC).toBe('11:22:33:44:55:66');
|
|
expect(frame.rssi).toBe(-40);
|
|
|
|
// Mean amplitude: avg of sqrt(I^2+Q^2) for each subcarrier
|
|
var expected = (Math.sqrt(100 + 25) + Math.sqrt(400 + 64) +
|
|
Math.sqrt(225 + 144) + Math.sqrt(625 + 9)) / 4;
|
|
expect(frame.meanAmplitude).toBeCloseTo(expected, 5);
|
|
});
|
|
|
|
test('returns null for frame too short', () => {
|
|
var buffer = new ArrayBuffer(10);
|
|
var frame = _parseCSIFrame(buffer);
|
|
expect(frame).toBeNull();
|
|
});
|
|
|
|
test('returns null for invalid channel', () => {
|
|
var buffer = new ArrayBuffer(26); // 24 header + 1 subcarrier
|
|
var bytes = new Uint8Array(buffer);
|
|
bytes[22] = 0; // invalid channel
|
|
bytes[23] = 1;
|
|
|
|
var frame = _parseCSIFrame(buffer);
|
|
expect(frame).toBeNull();
|
|
});
|
|
|
|
test('returns null for payload length mismatch', () => {
|
|
var buffer = new ArrayBuffer(30); // 24 header but wrong payload
|
|
var bytes = new Uint8Array(buffer);
|
|
bytes[22] = 6; // valid channel
|
|
bytes[23] = 4; // says 4 subcarriers = 8 bytes payload, but we have 6
|
|
|
|
var frame = _parseCSIFrame(buffer);
|
|
expect(frame).toBeNull();
|
|
});
|
|
|
|
test('handles negative I/Q values (signed bytes)', () => {
|
|
var buffer = new ArrayBuffer(26); // 24 header + 1 subcarrier
|
|
var bytes = new Uint8Array(buffer);
|
|
|
|
// MACs
|
|
bytes[0] = 0x01; bytes[1] = 0x02; bytes[2] = 0x03;
|
|
bytes[3] = 0x04; bytes[4] = 0x05; bytes[5] = 0x06;
|
|
bytes[6] = 0x07; bytes[7] = 0x08; bytes[8] = 0x09;
|
|
bytes[9] = 0x0A; bytes[10] = 0x0B; bytes[11] = 0x0C;
|
|
|
|
// Channel and NSub
|
|
bytes[22] = 1;
|
|
bytes[23] = 1;
|
|
|
|
// I=-10 (0xF6), Q=-5 (0xFB)
|
|
bytes[24] = 0xF6;
|
|
bytes[25] = 0xFB;
|
|
|
|
var frame = _parseCSIFrame(buffer);
|
|
expect(frame).not.toBeNull();
|
|
expect(frame.meanAmplitude).toBeCloseTo(Math.sqrt(100 + 25), 5);
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Wizard Lifecycle
|
|
// ============================================
|
|
describe('Wizard lifecycle', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
test('SpaxelOnboard is defined on window', () => {
|
|
expect(SpaxelOnboard).toBeDefined();
|
|
expect(typeof SpaxelOnboard.start).toBe('function');
|
|
expect(typeof SpaxelOnboard.close).toBe('function');
|
|
});
|
|
|
|
test('start creates wizard overlay in DOM', () => {
|
|
expect(document.getElementById('wizard-overlay')).toBeNull();
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
expect(document.getElementById('wizard-overlay')).not.toBeNull();
|
|
expect(document.getElementById('wizard-card')).not.toBeNull();
|
|
expect(document.getElementById('wizard-steps')).not.toBeNull();
|
|
expect(document.getElementById('wizard-content')).not.toBeNull();
|
|
|
|
SpaxelOnboard.close();
|
|
});
|
|
|
|
test('close removes wizard overlay from DOM', () => {
|
|
SpaxelOnboard.start();
|
|
expect(document.getElementById('wizard-overlay')).not.toBeNull();
|
|
|
|
SpaxelOnboard.close();
|
|
expect(document.getElementById('wizard-overlay')).toBeNull();
|
|
});
|
|
|
|
test('resume from saved state', () => {
|
|
// Simulate saved state at step 4 (detect_node)
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 4,
|
|
nodeMAC: 'AA:BB:CC:DD:EE:FF',
|
|
knownMACs: ['11:22:33:44:55:66'],
|
|
wifiSSID: 'TestWiFi',
|
|
wifiPass: 'secret',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
// State should be restored
|
|
expect(_state.currentStepIndex).toBe(4);
|
|
expect(_state.nodeMAC).toBe('AA:BB:CC:DD:EE:FF');
|
|
expect(_state.wifiSSID).toBe('TestWiFi');
|
|
|
|
SpaxelOnboard.close();
|
|
});
|
|
|
|
test('duplicate wizard instances are prevented', () => {
|
|
SpaxelOnboard.start();
|
|
var firstOverlay = document.getElementById('wizard-overlay');
|
|
expect(firstOverlay).not.toBeNull();
|
|
|
|
SpaxelOnboard.start(); // Should replace the first
|
|
var secondOverlay = document.getElementById('wizard-overlay');
|
|
expect(secondOverlay).not.toBeNull();
|
|
|
|
SpaxelOnboard.close();
|
|
expect(document.getElementById('wizard-overlay')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Wizard Step Indicator
|
|
// ============================================
|
|
describe('Step indicator rendering', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
test('renders correct number of step dots', () => {
|
|
SpaxelOnboard.start();
|
|
var dots = document.querySelectorAll('.wizard-step-dot');
|
|
expect(dots.length).toBe(8);
|
|
SpaxelOnboard.close();
|
|
});
|
|
|
|
test('first step is active on fresh start', () => {
|
|
SpaxelOnboard.start();
|
|
var dots = document.querySelectorAll('.wizard-step-dot');
|
|
// Step 0 (browser_check) auto-advances, so step 1 should be active
|
|
// But in the test, navigator.serial is mocked, so it should auto-advance
|
|
// We just verify the indicator is rendered
|
|
expect(dots.length).toBe(8);
|
|
SpaxelOnboard.close();
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Error Message Mapping
|
|
// ============================================
|
|
describe('Error message mapping', () => {
|
|
test('NotFoundError maps to user-friendly message', () => {
|
|
var err = new _UserError(
|
|
'No device detected. Make sure the USB cable is connected ' +
|
|
'and hold the BOOT button while plugging in.'
|
|
);
|
|
expect(_isUserError(err)).toBe(true);
|
|
expect(err.message).toContain('USB cable');
|
|
expect(err.message).toContain('BOOT button');
|
|
});
|
|
|
|
test('never exposes stack traces or technical details', () => {
|
|
var techErr = new Error('ENOENT: no such file or directory');
|
|
expect(_isUserError(techErr)).toBe(false);
|
|
// The wizard should wrap this in a UserError before displaying
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Browser Check — Missing Serial API
|
|
// ============================================
|
|
describe('Browser check without serial API', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
afterEach(() => {
|
|
// Restore serial API for other tests
|
|
Object.defineProperty(navigator, 'serial', {
|
|
value: {
|
|
requestPort: jest.fn().mockResolvedValue(__mockPort),
|
|
getPorts: jest.fn().mockResolvedValue([__mockPort]),
|
|
},
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
});
|
|
|
|
test('shows error when navigator.serial is missing', () => {
|
|
// Remove serial API to simulate Firefox/Safari
|
|
Object.defineProperty(navigator, 'serial', {
|
|
value: undefined,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Browser Not Supported');
|
|
expect(content.innerHTML).toContain('Google Chrome');
|
|
expect(content.innerHTML).toContain('Microsoft Edge');
|
|
expect(content.innerHTML).toContain('Firefox and Safari');
|
|
|
|
SpaxelOnboard.close();
|
|
});
|
|
|
|
test('shows no navigation buttons when serial API is missing', () => {
|
|
Object.defineProperty(navigator, 'serial', {
|
|
value: undefined,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
var nav = document.getElementById('wizard-nav');
|
|
expect(nav.innerHTML).toBe('');
|
|
|
|
SpaxelOnboard.close();
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Wizard State Transitions (without hardware)
|
|
// ============================================
|
|
describe('Wizard state transitions', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
afterEach(() => {
|
|
SpaxelOnboard.close();
|
|
});
|
|
|
|
test('auto-advances from browser_check to connect_device when serial is available', (done) => {
|
|
jest.useFakeTimers();
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
// browser_check auto-advances after 400ms
|
|
jest.advanceTimersByTime(400);
|
|
|
|
// Should be on step 1 (connect_device)
|
|
expect(_state.currentStepIndex).toBe(1);
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Connect Your ESP32-S3');
|
|
|
|
jest.useRealTimers();
|
|
done();
|
|
});
|
|
|
|
test('connect_device step renders ESP32 illustration with BOOT button highlighted', () => {
|
|
jest.useFakeTimers();
|
|
SpaxelOnboard.start();
|
|
jest.advanceTimersByTime(400); // Skip browser_check
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('esp32-illustration');
|
|
expect(content.innerHTML).toContain('BOOT');
|
|
|
|
// "Select Device" button is in the nav area
|
|
var nextBtn = document.getElementById('wizard-next');
|
|
expect(nextBtn).not.toBeNull();
|
|
expect(nextBtn.textContent).toBe('Select Device');
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('connect_device shows error when requestPort fails with NotFoundError', async () => {
|
|
jest.useFakeTimers();
|
|
navigator.serial.requestPort.mockRejectedValueOnce({ name: 'NotFoundError' });
|
|
|
|
SpaxelOnboard.start();
|
|
jest.advanceTimersByTime(400); // Skip browser_check
|
|
|
|
// Click "Select Device"
|
|
var nextBtn = document.getElementById('wizard-next');
|
|
expect(nextBtn).not.toBeNull();
|
|
nextBtn.click();
|
|
|
|
// Flush microtasks
|
|
await jest.advanceTimersByTimeAsync(0);
|
|
|
|
var errEl = document.getElementById('connect-error');
|
|
expect(errEl.style.display).toBe('block');
|
|
expect(errEl.textContent).toContain('USB cable');
|
|
expect(errEl.textContent).toContain('BOOT button');
|
|
|
|
// Button should be re-enabled
|
|
nextBtn = document.getElementById('wizard-next');
|
|
expect(nextBtn.disabled).toBe(false);
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('connect_device shows generic error for non-NotFoundError', async () => {
|
|
jest.useFakeTimers();
|
|
navigator.serial.requestPort.mockRejectedValueOnce(new Error('some error'));
|
|
|
|
SpaxelOnboard.start();
|
|
jest.advanceTimersByTime(400);
|
|
|
|
var nextBtn = document.getElementById('wizard-next');
|
|
nextBtn.click();
|
|
|
|
await jest.advanceTimersByTimeAsync(0);
|
|
|
|
var errEl = document.getElementById('connect-error');
|
|
expect(errEl.style.display).toBe('block');
|
|
expect(errEl.textContent).toContain('Could not select a device');
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('flash_firmware shows skip option when esp-web-tools is not loaded', () => {
|
|
jest.useFakeTimers();
|
|
// Make customElements.get return null to simulate missing esp-web-tools
|
|
customElements.get = jest.fn(() => null);
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 2,
|
|
nodeMAC: null,
|
|
knownMACs: [],
|
|
wifiSSID: '',
|
|
wifiPass: '',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Firmware flashing component failed to load');
|
|
// "Skip Flashing" is in the nav area, not the content area
|
|
var nav = document.getElementById('wizard-nav');
|
|
expect(nav.innerHTML).toContain('Skip Flashing');
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('provision_wifi step renders form with WiFi fields', () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 3,
|
|
nodeMAC: null,
|
|
knownMACs: [],
|
|
wifiSSID: 'MyWiFi',
|
|
wifiPass: 'password123',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Configure WiFi');
|
|
expect(content.innerHTML).toContain('wifi-ssid');
|
|
expect(content.innerHTML).toContain('wifi-pass');
|
|
expect(content.innerHTML).toContain('MyWiFi');
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('provision_wifi shows error for empty SSID', () => {
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 3,
|
|
nodeMAC: null,
|
|
knownMACs: [],
|
|
wifiSSID: '',
|
|
wifiPass: '',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
// Submit form with empty SSID — validation is synchronous
|
|
var form = document.getElementById('wifi-form');
|
|
var ssidInput = document.getElementById('wifi-ssid');
|
|
ssidInput.value = '';
|
|
|
|
form.dispatchEvent(new Event('submit'));
|
|
|
|
var errEl = document.getElementById('provision-error');
|
|
expect(errEl.style.display).toBe('block');
|
|
expect(errEl.textContent).toContain('WiFi network name');
|
|
});
|
|
|
|
test('placement step renders placement guidance', () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 6,
|
|
nodeMAC: 'AA:BB:CC:DD:EE:FF',
|
|
knownMACs: [],
|
|
wifiSSID: 'TestWiFi',
|
|
wifiPass: 'pass',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Node Placement');
|
|
expect(content.innerHTML).toContain('opposite corners');
|
|
expect(content.innerHTML).toContain('2 meters');
|
|
expect(content.innerHTML).toContain('chest height');
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('complete step shows node MAC and dashboard button', () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 7,
|
|
nodeMAC: 'AA:BB:CC:DD:EE:FF',
|
|
knownMACs: [],
|
|
wifiSSID: 'TestWiFi',
|
|
wifiPass: 'pass',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Setup Complete');
|
|
expect(content.innerHTML).toContain('AA:BB:CC:DD:EE:FF');
|
|
expect(content.innerHTML).toContain('Go to Dashboard');
|
|
|
|
var gotoBtn = document.getElementById('goto-dashboard');
|
|
expect(gotoBtn).not.toBeNull();
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('complete step hides navigation buttons', () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 7,
|
|
nodeMAC: 'AA:BB:CC:DD:EE:FF',
|
|
knownMACs: [],
|
|
wifiSSID: '',
|
|
wifiPass: '',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
var nav = document.getElementById('wizard-nav');
|
|
expect(nav.innerHTML).toBe('');
|
|
|
|
jest.useRealTimers();
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Provisioning Payload Assembly and Serial Send
|
|
// ============================================
|
|
describe('Provisioning payload assembly and serial send', () => {
|
|
beforeEach(resetWizardState);
|
|
afterEach(() => { __clearLastEncodedData(); });
|
|
|
|
test('provisionAndSend calls POST /api/provision with correct body', async () => {
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue({
|
|
version: 1,
|
|
wifi_ssid: 'TestWiFi',
|
|
wifi_pass: 'secret123',
|
|
node_id: 'uuid-123',
|
|
node_token: 'token-abc',
|
|
ms_mdns: 'spaxel-mothership.local',
|
|
ms_port: 8080,
|
|
debug: false,
|
|
}),
|
|
});
|
|
|
|
await _provisionAndSend('TestWiFi', 'secret123', '', 8080);
|
|
|
|
expect(fetch).toHaveBeenCalledWith('/api/provision', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ wifi_ssid: 'TestWiFi', wifi_pass: 'secret123' }),
|
|
});
|
|
|
|
// Port should have been opened
|
|
expect(__mockPort.open).toHaveBeenCalledWith({ baudRate: 115200 });
|
|
|
|
// Verify data was sent over serial
|
|
var sent = __getLastEncodedData();
|
|
expect(sent).toContain('"wifi_ssid":"TestWiFi"');
|
|
expect(sent).toContain('"node_id":"uuid-123"');
|
|
});
|
|
|
|
test('provisionAndSend applies mothership host override', async () => {
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue({
|
|
version: 1,
|
|
wifi_ssid: 'TestWiFi',
|
|
wifi_pass: 'pass',
|
|
node_id: 'uuid',
|
|
node_token: 'tok',
|
|
ms_mdns: 'spaxel-mothership.local',
|
|
ms_port: 8080,
|
|
debug: false,
|
|
}),
|
|
});
|
|
|
|
await _provisionAndSend('TestWiFi', 'pass', '192.168.1.100', 9090);
|
|
|
|
var sent = __getLastEncodedData();
|
|
expect(sent).toContain('"ms_mdns":"192.168.1.100"');
|
|
expect(sent).toContain('"ms_port":9090');
|
|
});
|
|
|
|
test('provisionAndSend falls back to client-side payload when server fails', async () => {
|
|
fetch.mockRejectedValueOnce(new Error('server unavailable'));
|
|
|
|
await _provisionAndSend('TestWiFi', 'pass', '', 8080);
|
|
|
|
// Port should still be opened with client-side payload
|
|
expect(__mockPort.open).toHaveBeenCalledWith({ baudRate: 115200 });
|
|
|
|
// Writer should have been called with client-side assembled payload
|
|
var sent = __getLastEncodedData();
|
|
expect(sent).toContain('"wifi_ssid":"TestWiFi"');
|
|
expect(sent).toContain('"node_id":"test-uuid-1234"');
|
|
});
|
|
|
|
test('provisionAndSend throws UserError when no port is available', async () => {
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue({
|
|
version: 1,
|
|
wifi_ssid: 'W',
|
|
wifi_pass: 'p',
|
|
node_id: 'u',
|
|
node_token: 't',
|
|
ms_mdns: 'h',
|
|
ms_port: 8080,
|
|
debug: false,
|
|
}),
|
|
});
|
|
|
|
// No authorized ports — use mockResolvedValue (not Once) so fallback path also fails
|
|
navigator.serial.getPorts.mockResolvedValue([]);
|
|
|
|
await expect(_provisionAndSend('W', 'p', '', 8080)).rejects.toEqual(
|
|
expect.objectContaining({ name: 'UserError' })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Node Detection Polling — Full Wizard Transition
|
|
// ============================================
|
|
describe('Node detection wizard transition', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
afterEach(() => {
|
|
SpaxelOnboard.close();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
test('detect_node polls and transitions to calibrate when new node appears', async () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 4,
|
|
nodeMAC: null,
|
|
knownMACs: ['AA:BB:CC:DD:EE:FF'],
|
|
wifiSSID: 'TestWiFi',
|
|
wifiPass: 'pass',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
// Verify we're on detect_node step
|
|
expect(_state.currentStepIndex).toBe(4);
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Detecting Your Node');
|
|
|
|
// Simulate a poll returning a new node
|
|
fetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue([
|
|
{ mac: 'AA:BB:CC:DD:EE:FF', role: 'tx', online: true },
|
|
{ mac: '11:22:33:44:55:66', role: 'rx', online: true },
|
|
]),
|
|
});
|
|
|
|
// Advance time by the poll interval to trigger the first poll
|
|
await jest.advanceTimersByTimeAsync(3000);
|
|
|
|
// Node should be detected
|
|
expect(_state.nodeMAC).toBe('11:22:33:44:55:66');
|
|
|
|
// After 1s timeout, it should transition to calibrate
|
|
await jest.advanceTimersByTimeAsync(1000);
|
|
expect(_state.currentStepIndex).toBe(5);
|
|
});
|
|
|
|
test('detect_node shows troubleshooting after timeout', () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 4,
|
|
nodeMAC: null,
|
|
knownMACs: [],
|
|
wifiSSID: 'TestWiFi',
|
|
wifiPass: 'pass',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
// Simulate no new nodes appearing — fetch returns empty
|
|
fetch.mockResolvedValue({
|
|
ok: true,
|
|
json: jest.fn().mockResolvedValue([]),
|
|
});
|
|
|
|
// Advance past timeout
|
|
jest.advanceTimersByTime(121000);
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Troubleshooting');
|
|
expect(content.innerHTML).toContain('2.4 GHz');
|
|
expect(content.innerHTML).toContain('AP isolation');
|
|
|
|
// Retry button should appear
|
|
var retryBtn = document.getElementById('wizard-next');
|
|
expect(retryBtn).not.toBeNull();
|
|
expect(retryBtn.textContent).toBe('Retry Detection');
|
|
});
|
|
});
|
|
|
|
// ============================================
|
|
// Session Storage Restore at Each Step
|
|
// ============================================
|
|
describe('Session storage restore at each step', () => {
|
|
beforeEach(resetWizardState);
|
|
|
|
afterEach(() => {
|
|
SpaxelOnboard.close();
|
|
jest.useRealTimers();
|
|
});
|
|
|
|
function testRestoreAtStep(stepIndex, stepLabel, expectedContent) {
|
|
test('restores state at step ' + stepIndex + ' (' + stepLabel + ')', () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: stepIndex,
|
|
nodeMAC: stepIndex >= 4 ? 'AA:BB:CC:DD:EE:FF' : null,
|
|
knownMACs: stepIndex >= 4 ? ['11:22:33:44:55:66'] : [],
|
|
wifiSSID: 'TestWiFi',
|
|
wifiPass: 'secret',
|
|
mothershipHost: 'custom-host',
|
|
mothershipPort: 9090,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
expect(_state.currentStepIndex).toBe(stepIndex);
|
|
expect(_state.wifiSSID).toBe('TestWiFi');
|
|
expect(_state.mothershipHost).toBe('custom-host');
|
|
expect(_state.mothershipPort).toBe(9090);
|
|
|
|
if (stepIndex >= 4) {
|
|
expect(_state.nodeMAC).toBe('AA:BB:CC:DD:EE:FF');
|
|
}
|
|
});
|
|
}
|
|
|
|
testRestoreAtStep(1, 'connect_device');
|
|
testRestoreAtStep(2, 'flash_firmware');
|
|
testRestoreAtStep(3, 'provision_wifi');
|
|
testRestoreAtStep(4, 'detect_node');
|
|
testRestoreAtStep(6, 'placement');
|
|
testRestoreAtStep(7, 'complete');
|
|
|
|
test('restores state at calibrate step and connects WebSocket', () => {
|
|
jest.useFakeTimers();
|
|
|
|
sessionStorage.setItem(_CONFIG.storageKey, JSON.stringify({
|
|
currentStepIndex: 5,
|
|
nodeMAC: 'AA:BB:CC:DD:EE:FF',
|
|
knownMACs: ['11:22:33:44:55:66'],
|
|
wifiSSID: 'TestWiFi',
|
|
wifiPass: 'secret',
|
|
mothershipHost: '',
|
|
mothershipPort: 8080,
|
|
}));
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
expect(_state.currentStepIndex).toBe(5);
|
|
expect(_state.nodeMAC).toBe('AA:BB:CC:DD:EE:FF');
|
|
expect(_state.calibratePhase).toBe('walk');
|
|
|
|
var content = document.getElementById('wizard-content');
|
|
expect(content.innerHTML).toContain('Guided Calibration');
|
|
expect(content.innerHTML).toContain('Walk Around Your Space');
|
|
});
|
|
|
|
test('fresh start (no saved state) begins at step 0', () => {
|
|
jest.useFakeTimers();
|
|
|
|
SpaxelOnboard.start();
|
|
|
|
// browser_check auto-advances to step 1
|
|
jest.advanceTimersByTime(400);
|
|
expect(_state.currentStepIndex).toBe(1);
|
|
});
|
|
});
|