mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 01:03:34 +02:00
Rework more coverage in e2e tests and don't force radio restart + better startup error handling
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user