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
+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 });