test(desktop): vitest + OTAManager unit tests

Desktop had zero tests before. This commit adds vitest at
the desktop package level and 6 unit tests for the OTA
manager logic. The tests run with mocked electron / fs /
http so they need neither a real ESP32 nor the native
serialport / zacus-native rebuild.

Setup:
- vitest.config.ts: node env, scans src/**/__tests__/**/*.test.ts
- desktop/package.json: vitest@^2.1.8 devDep, "test" script
- npm install --ignore-scripts to avoid the
  electron-builder install-app-deps postinstall + native
  ABI rebuild (these matter for app launch, not unit tests)

Coverage (6 tests, all green in 251 ms):
- construction triggers ensureCacheDir() (mkdir -p
  ~/.zacus-studio/firmwares stubbed via os homedir mock)
- checkUpdate returns 'unknown' when device + manifest
  unreachable (catch swallows the error)
- startUpdate rejects unknown OTA method
- startUpdate rate-limits on same device within 60 s
- importFirmware rejects a binary whose first byte is not
  0xE9 (ESP32 image magic)
- importFirmware accepts 0xE9 and copies to cache dir

Documented gap: the activeOTAs concurrent-call guard is
unreachable from external callers because rate-limit
triggers first; left in source as belt-and-braces, not
covered by a test (would need bypass via reflection).

Out of scope (deferred):
- Playwright Electron mode (would require building the
  desktop app + mocking serialport at runtime)
- HTTP-flow tests (otaViaWiFi, pollOTAStatus,
  waitForReboot) — these need an HTTP server fixture
- BLE / USB delegation paths (executeJavaScript -> renderer)

Acceptance: pnpm test (or npm test) inside desktop/
prints "6 passed (6)" in <1 s.
This commit is contained in:
L'électron rare
2026-05-03 14:14:59 +02:00
parent 07433dfdc5
commit 010981846a
4 changed files with 9285 additions and 0 deletions
+9109
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -14,6 +14,7 @@
"build:mac-arm64": "npm run build && electron-builder --mac --arm64",
"notarize": "node scripts/notarize.js",
"rebuild-native": "electron-rebuild -f -w zacus-native",
"test": "vitest run",
"postinstall": "electron-builder install-app-deps"
},
"dependencies": {
@@ -33,6 +34,7 @@
"typescript": "^5.6.0",
"vite": "^6.0.0",
"vite-plugin-electron": "^0.28.0",
"vitest": "^2.1.8",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
// Mock electron BEFORE importing the module under test. OTAManager only
// touches BrowserWindow.webContents.send / executeJavaScript, so a tiny
// stub is enough.
vi.mock('electron', () => ({
BrowserWindow: vi.fn(),
IpcMain: vi.fn(),
}));
// Stub homedir so we never write to the real ~/.zacus-studio cache.
vi.mock('os', async () => {
const actual = await vi.importActual<typeof import('os')>('os');
return { ...actual, homedir: () => '/tmp/zacus-studio-test-home' };
});
// fs mocks for the file-import test path.
vi.mock('fs/promises', async () => {
const actual = await vi.importActual<typeof import('fs/promises')>('fs/promises');
return {
...actual,
mkdir: vi.fn().mockResolvedValue(undefined),
readFile: vi.fn(),
writeFile: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock('fs', async () => {
const actual = await vi.importActual<typeof import('fs')>('fs');
return {
...actual,
existsSync: vi.fn().mockReturnValue(false),
statSync: vi.fn().mockReturnValue({ size: 1024 }),
createReadStream: vi.fn(() => {
// Minimal Readable stub: emits a single chunk + end. The OTA WiFi
// path pipes this into a mocked http.request, which itself never
// resolves; the rejection we care about comes from the activeOTAs
// guard, not from upload completion.
const { Readable } = require('node:stream');
return Readable.from([Buffer.from([0xe9, 0x00, 0x00])]);
}),
};
});
// Networking is exercised separately; for these unit tests we stub http
// to never make real requests.
vi.mock('http', () => ({
default: {
get: vi.fn(),
request: vi.fn(),
},
get: vi.fn(),
request: vi.fn(),
}));
import { OTAManager } from '../ota-manager.js';
import type { BrowserWindow } from 'electron';
import { readFile } from 'fs/promises';
function makeFakeWindow(): BrowserWindow {
const send = vi.fn();
const executeJavaScript = vi.fn().mockResolvedValue(undefined);
return {
webContents: { send, executeJavaScript },
// Cast — OTAManager only uses webContents.
} as unknown as BrowserWindow;
}
describe('OTAManager — construction', () => {
it('initialises without throwing and triggers cache dir creation', async () => {
const win = makeFakeWindow();
const mgr = new OTAManager(win);
expect(mgr).toBeInstanceOf(OTAManager);
// Constructor calls ensureCacheDir asynchronously; give it a tick.
await Promise.resolve();
const { mkdir } = await import('fs/promises');
expect(mkdir).toHaveBeenCalled();
});
});
describe('OTAManager — checkUpdate', () => {
let mgr: OTAManager;
beforeEach(() => {
mgr = new OTAManager(makeFakeWindow());
});
afterEach(() => {
vi.clearAllMocks();
});
it('returns "unknown" when both device + manifest are unreachable', async () => {
// No manifest exists (existsSync = false from fs mock), and the HTTP
// request will be a no-op stub that never resolves. Make checkUpdate
// tolerate that path: catch swallows everything.
const result = await mgr.checkUpdate('192.168.0.99');
expect(result.current).toBe('unknown');
expect(result.available).toBe('unknown');
expect(result.needsUpdate).toBe(false);
});
});
describe('OTAManager — startUpdate guards', () => {
let mgr: OTAManager;
beforeEach(() => {
mgr = new OTAManager(makeFakeWindow());
});
afterEach(() => {
vi.clearAllMocks();
});
it('rejects an unknown OTA method', async () => {
await expect(
// @ts-expect-error — exercising the runtime guard
mgr.startUpdate('192.168.0.42', 'mqtt', '/tmp/firmware.bin'),
).rejects.toThrow(/Unknown OTA method|method/i);
});
// Note: the "rejects concurrent OTA on same device" guard exists in
// OTAManager.startUpdate but is unreachable from external callers
// because the 60 s rate limit triggers first when invoked twice in
// quick succession. The activeOTAs check survives as a belt-and-
// braces guard for hypothetical concurrent calls from different IPC
// contexts and is not exercised here.
it('rate-limits two updates on the same device within 60s', async () => {
// First call records lastOTATime even though the WiFi path will fail.
await mgr.startUpdate('192.168.0.42', 'wifi', '/tmp/firmware.bin').catch(() => undefined);
// Immediately retry: rate limit must trigger.
await expect(
mgr.startUpdate('192.168.0.42', 'wifi', '/tmp/firmware.bin'),
).rejects.toThrow(/rate limit/i);
});
});
describe('OTAManager — importFirmware', () => {
let mgr: OTAManager;
beforeEach(() => {
mgr = new OTAManager(makeFakeWindow());
});
afterEach(() => {
vi.clearAllMocks();
});
it('rejects a binary whose first byte is not 0xE9 (ESP32 magic)', async () => {
vi.mocked(readFile).mockResolvedValueOnce(Buffer.from([0x00, 0x00, 0x00]));
await expect(mgr.importFirmware('/tmp/not-firmware.bin')).rejects.toThrow(
/bad magic byte|Invalid ESP32/i,
);
});
it('accepts a binary starting with 0xE9 and copies it to the cache dir', async () => {
const valid = Buffer.from([0xe9, 0x01, 0x02, 0x03]);
vi.mocked(readFile).mockResolvedValue(valid);
const dest = await mgr.importFirmware('/tmp/zacus.bin');
expect(dest).toContain('zacus.bin');
const { writeFile } = await import('fs/promises');
expect(writeFile).toHaveBeenCalledWith(expect.stringContaining('zacus.bin'), valid);
});
});
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
include: ['src/**/__tests__/**/*.test.ts'],
pool: 'forks',
},
});