Rework more coverage in e2e tests and don't force radio restart + better startup error handling

This commit is contained in:
Jack Kingsman
2026-03-06 12:59:33 -08:00
parent 58daf63d00
commit cba9835568
11 changed files with 335 additions and 35 deletions
+37
View File
@@ -48,6 +48,40 @@ class Settings(BaseSettings):
settings = Settings()
class _RepeatSquelch(logging.Filter):
"""Suppress rapid-fire identical messages and emit a summary instead.
Attached to the ``meshcore`` library logger to catch its repeated
"Serial Connection started" lines that flood the log when another
process holds the serial port.
"""
def __init__(self, threshold: int = 3) -> None:
super().__init__()
self._last_msg: str | None = None
self._repeat_count: int = 0
self._threshold = threshold
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
if msg == self._last_msg:
self._repeat_count += 1
if self._repeat_count == self._threshold:
record.msg = (
"%s (repeated %d times — possible serial port contention from another process)"
)
record.args = (msg, self._repeat_count)
record.levelno = logging.WARNING
record.levelname = "WARNING"
return True
# Suppress further repeats beyond the threshold
return self._repeat_count < self._threshold
else:
self._last_msg = msg
self._repeat_count = 1
return True
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
@@ -55,3 +89,6 @@ def setup_logging() -> None:
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
# "Serial Connection started" when the port is contended).
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
+21 -2
View File
@@ -470,6 +470,8 @@ class RadioManager:
from app.websocket import broadcast_health
CHECK_INTERVAL_SECONDS = 5
UNRESPONSIVE_THRESHOLD = 3
consecutive_setup_failures = 0
while True:
try:
@@ -483,6 +485,7 @@ class RadioManager:
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, self._connection_info)
self._last_connected = False
consecutive_setup_failures = 0
if not current_connected:
# Attempt reconnection on every loop while disconnected
@@ -492,6 +495,7 @@ class RadioManager:
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
consecutive_setup_failures = 0
elif not self._last_connected and current_connected:
# Connection restored (might have reconnected automatically).
@@ -500,19 +504,34 @@ class RadioManager:
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
consecutive_setup_failures = 0
elif current_connected and not self._setup_complete:
# Transport connected but setup incomplete — retry
logger.info("Retrying post-connect setup...")
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
consecutive_setup_failures = 0
except asyncio.CancelledError:
# Task is being cancelled, exit cleanly
break
except Exception as e:
# Log error but continue monitoring - don't let the monitor die
logger.exception("Error in connection monitor, continuing: %s", e)
consecutive_setup_failures += 1
if consecutive_setup_failures == UNRESPONSIVE_THRESHOLD:
logger.error(
"Post-connect setup has failed %d times in a row. "
"The radio port appears open but the radio is not "
"responding to commands. Common causes: another "
"process has the serial port open (check for other "
"RemoteTerm instances, serial monitors, etc.), the "
"firmware is in repeater mode (not client), or the "
"radio needs a power cycle. Will keep retrying.",
consecutive_setup_failures,
)
elif consecutive_setup_failures < UNRESPONSIVE_THRESHOLD:
logger.exception("Error in connection monitor, continuing: %s", e)
# After the threshold, silently retry (avoid log spam)
self._reconnect_task = asyncio.create_task(monitor_loop())
logger.info("Radio connection monitor started")
+22 -2
View File
@@ -117,7 +117,16 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error("Failed to get contacts from radio: %s", result)
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
@@ -662,8 +671,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
else:
failed += 1
reason = result.payload
hint = ""
if reason is None:
hint = (
" (no response from radio — if this repeats, check for "
"serial port contention from another process or try a "
"power cycle)"
)
logger.warning(
"Failed to load contact %s: %s", contact.public_key[:12], result.payload
"Failed to load contact %s: %s%s",
contact.public_key[:12],
reason,
hint,
)
except Exception as e:
failed += 1
@@ -141,9 +141,7 @@ export function SettingsRadioSection({
);
};
const handleSave = async () => {
setError(null);
const buildUpdate = (): RadioConfigUpdate | null => {
const parsedLat = parseFloat(lat);
const parsedLon = parseFloat(lon);
const parsedTxPower = parseInt(txPower, 10);
@@ -158,24 +156,46 @@ export function SettingsRadioSection({
)
) {
setError('All numeric fields must have valid values');
return;
return null;
}
setBusy(true);
return {
name,
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
radio: {
freq: parsedFreq,
bw: parsedBw,
sf: parsedSf,
cr: parsedCr,
},
};
};
const handleSave = async () => {
setError(null);
const update = buildUpdate();
if (!update) return;
setBusy(true);
try {
await onSave(update);
toast.success('Radio config saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
const handleSaveAndReboot = async () => {
setError(null);
const update = buildUpdate();
if (!update) return;
setBusy(true);
try {
const update: RadioConfigUpdate = {
name,
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
radio: {
freq: parsedFreq,
bw: parsedBw,
sf: parsedSf,
cr: parsedCr,
},
};
await onSave(update);
toast.success('Radio config saved, rebooting...');
setRebooting(true);
@@ -413,9 +433,22 @@ export function SettingsRadioSection({
</div>
)}
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
</Button>
<div className="flex gap-2">
<Button
onClick={handleSave}
disabled={busy || rebooting}
variant="outline"
className="flex-1"
>
{busy && !rebooting ? 'Saving...' : 'Save'}
</Button>
<Button onClick={handleSaveAndReboot} disabled={busy || rebooting} className="flex-1">
{rebooting ? 'Rebooting...' : 'Save & Reboot'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Some settings may require a reboot to take effect on some radios.
</p>
<Separator />
+1 -1
View File
@@ -295,7 +295,7 @@ describe('SettingsModal', () => {
});
openRadioSection();
fireEvent.click(screen.getByRole('button', { name: 'Save Radio Config & Reboot' }));
fireEvent.click(screen.getByRole('button', { name: 'Save & Reboot' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledTimes(1);
expect(onReboot).toHaveBeenCalledTimes(1);
+22
View File
@@ -9,8 +9,15 @@
* When a @mesh-traffic-tagged test fails, an advisory annotation is added
* to the HTML report and a console message is printed, letting the user
* know the failure may be due to low mesh traffic rather than a real bug.
*
* Call `await nudgeEchoBot()` at the start of any @mesh-traffic test to
* send a trigger message to an echo bot on #flightless. If the bot is in
* radio range it will generate an incoming packet, potentially saving the
* full 3-minute wait. The nudge is best-effort tests still rely on the
* long polling timeout for environments without the bot.
*/
import { test as base, expect } from '@playwright/test';
import { ensureFlightlessChannel, sendChannelMessage } from './api';
export { expect };
@@ -18,6 +25,21 @@ const TRAFFIC_ADVISORY =
'This test depends on receiving messages from other nodes on the mesh ' +
'network. Failure may indicate insufficient mesh traffic rather than a bug.';
/**
* Best-effort: send a message to #flightless that triggers a remote echo
* bot. If the bot is within radio range it will reply, generating the
* incoming traffic the test needs. Failures are silently ignored the
* test will fall back to waiting for organic mesh traffic.
*/
export async function nudgeEchoBot(): Promise<void> {
try {
const channel = await ensureFlightlessChannel();
await sendChannelMessage(channel.key, '!echo please give incoming message');
} catch {
// Best-effort — bot may not be reachable
}
}
export const test = base.extend<{ _meshTrafficAdvisory: void }>({
_meshTrafficAdvisory: [
async ({}, use, testInfo) => {
+7 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../helpers/meshTrafficTest';
import { test, expect, nudgeEchoBot } from '../helpers/meshTrafficTest';
import { createChannel, getChannels, getMessages } from '../helpers/api';
/**
@@ -55,6 +55,9 @@ test.describe('Incoming mesh messages', () => {
});
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
// Nudge echo bot on #flightless — may generate an incoming packet quickly
await nudgeEchoBot();
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
@@ -103,6 +106,9 @@ test.describe('Incoming mesh messages', () => {
});
test('incoming message with path shows hop badge and path modal', { tag: '@mesh-traffic' }, async ({ page }) => {
// Nudge echo bot on #flightless — may generate an incoming packet quickly
await nudgeEchoBot();
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
+4 -1
View File
@@ -1,4 +1,4 @@
import { test, expect } from '../helpers/meshTrafficTest';
import { test, expect, nudgeEchoBot } from '../helpers/meshTrafficTest';
test.describe('Packet Feed page', () => {
test('packet feed page loads and shows header', async ({ page }) => {
@@ -11,6 +11,9 @@ test.describe('Packet Feed page', () => {
// This test waits for real RF traffic — needs 180s timeout
test.setTimeout(180_000);
// Nudge echo bot on #flightless — may generate a packet quickly
await nudgeEchoBot();
await page.goto('/#raw');
await expect(page.getByText('Raw Packet Feed')).toBeVisible({ timeout: 10_000 });
+160
View File
@@ -0,0 +1,160 @@
import { test, expect } from '@playwright/test';
import http from 'http';
import {
createFanoutConfig,
deleteFanoutConfig,
ensureFlightlessChannel,
sendChannelMessage,
} from '../helpers/api';
/**
* Spin up a local HTTP server that captures incoming webhook requests.
* Returns the server, its URL, and a promise-based helper to wait for
* the next request body.
*/
function createWebhookReceiver() {
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
let resolve: (() => void) | null = null;
const server = http.createServer((req, res) => {
let body = '';
req.on('data', (chunk) => (body += chunk));
req.on('end', () => {
requests.push({ body, headers: req.headers });
resolve?.();
resolve = null;
res.writeHead(200);
res.end('ok');
});
});
return {
server,
requests,
/** Wait until at least `count` requests have been received. */
waitForRequests(count: number, timeoutMs = 30_000): Promise<void> {
if (requests.length >= count) return Promise.resolve();
return new Promise<void>((res, rej) => {
const timer = setTimeout(
() => rej(new Error(`Timed out waiting for ${count} webhook request(s), got ${requests.length}`)),
timeoutMs
);
const check = () => {
if (requests.length >= count) {
clearTimeout(timer);
res();
} else {
resolve = check;
}
};
resolve = check;
});
},
/** Start listening on a random port and return the URL. */
async listen(): Promise<string> {
return new Promise((res) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (typeof addr === 'object' && addr) {
res(`http://127.0.0.1:${addr.port}`);
}
});
});
},
};
}
test.describe('Webhook delivery', () => {
let webhookId: string | null = null;
let receiver: ReturnType<typeof createWebhookReceiver>;
let webhookUrl: string;
test.beforeAll(async () => {
await ensureFlightlessChannel();
receiver = createWebhookReceiver();
webhookUrl = await receiver.listen();
});
test.afterAll(async () => {
receiver.server.close();
if (webhookId) {
try {
await deleteFanoutConfig(webhookId);
} catch {
// Best-effort cleanup
}
}
});
test('webhook receives message payload when a channel message is sent', async () => {
// Create an enabled webhook pointing at our local receiver
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'E2E Delivery Test',
config: { url: webhookUrl, method: 'POST', headers: {} },
enabled: true,
});
webhookId = webhook.id;
// Send a message via API — this triggers broadcast_event → fanout → webhook
const channel = await ensureFlightlessChannel();
const testText = `webhook-delivery-${Date.now()}`;
await sendChannelMessage(channel.key, testText);
// Wait for the webhook to receive the request
await receiver.waitForRequests(1);
const req = receiver.requests[0];
expect(req.headers['content-type']).toBe('application/json');
expect(req.headers['x-webhook-event']).toBe('message');
const payload = JSON.parse(req.body);
expect(payload.text).toContain(testText);
expect(payload.type).toBe('CHAN');
expect(payload.conversation_key).toBe(channel.key);
});
test('webhook respects HMAC signing when configured', async () => {
// Clean up previous webhook
if (webhookId) {
await deleteFanoutConfig(webhookId);
}
const hmacSecret = 'e2e-test-secret';
const webhook = await createFanoutConfig({
type: 'webhook',
name: 'E2E HMAC Test',
config: {
url: webhookUrl,
method: 'POST',
headers: {},
hmac_secret: hmacSecret,
},
enabled: true,
});
webhookId = webhook.id;
// Clear previous requests
const baselineCount = receiver.requests.length;
const channel = await ensureFlightlessChannel();
const testText = `hmac-test-${Date.now()}`;
await sendChannelMessage(channel.key, testText);
await receiver.waitForRequests(baselineCount + 1);
const req = receiver.requests[baselineCount];
const signature = req.headers['x-webhook-signature'];
expect(signature).toBeDefined();
expect(typeof signature).toBe('string');
expect((signature as string).startsWith('sha256=')).toBe(true);
// Verify the HMAC is valid
const crypto = await import('crypto');
const expectedSig = crypto
.createHmac('sha256', hmacSecret)
.update(req.body)
.digest('hex');
expect(signature).toBe(`sha256=${expectedSig}`);
});
});
+1 -2
View File
@@ -115,10 +115,9 @@ test.describe('Webhook integration settings', () => {
const row = page.getByText('Scope Webhook').locator('..');
await row.getByRole('button', { name: 'Edit' }).click();
// Verify scope selector is visible with all four modes
// Verify scope selector is visible with the three webhook-applicable modes
await expect(page.getByText('Message Scope')).toBeVisible();
await expect(page.getByText('All messages')).toBeVisible();
await expect(page.getByText('No messages')).toBeVisible();
await expect(page.getByText('Only listed channels/contacts')).toBeVisible();
await expect(page.getByText('All except listed channels/contacts')).toBeVisible();
+7 -6
View File
@@ -23,16 +23,17 @@ test.describe('Radio settings', () => {
await nameInput.clear();
await nameInput.fill(testName);
await page.getByRole('button', { name: 'Save Radio Config & Reboot' }).click();
await expect(page.getByText('Radio config saved, rebooting...')).toBeVisible({ timeout: 10_000 });
// Use "Save" (no reboot) — name changes apply immediately
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Radio config saved')).toBeVisible({ timeout: 10_000 });
// --- Step 2: Verify via API (send_appstart refreshes cached info) ---
const config = await getRadioConfig();
expect(config.name).toBe(testName);
// Exit settings page mode
await page.getByRole('button', { name: /Back to Chat/i }).click();
// --- Step 2: Verify via API (now returns fresh data after send_appstart fix) ---
const config = await getRadioConfig();
expect(config.name).toBe(testName);
// --- Step 3: Verify persistence across page reload ---
await page.reload();
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });