Add e2e tests

This commit is contained in:
Jack Kingsman
2026-01-30 20:33:55 -08:00
parent 7ec4151d6c
commit db550faab0
12 changed files with 745 additions and 0 deletions

4
tests/e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
.tmp/
test-results/
playwright-report/

38
tests/e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { FullConfig } from '@playwright/test';
const BASE_URL = 'http://localhost:8000';
const MAX_RETRIES = 10;
const RETRY_DELAY_MS = 2000;
export default async function globalSetup(_config: FullConfig) {
// Wait for the backend to be fully ready and radio connected
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
const res = await fetch(`${BASE_URL}/api/health`);
if (!res.ok) {
throw new Error(`Health check returned ${res.status}`);
}
const health = (await res.json()) as { radio_connected: boolean; serial_port: string | null };
if (!health.radio_connected) {
throw new Error(
'Radio not connected — E2E tests require hardware. ' +
'Set MESHCORE_SERIAL_PORT if auto-detection fails.'
);
}
console.log(`Radio connected on ${health.serial_port}`);
return;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_RETRIES) {
console.log(`Waiting for backend (attempt ${attempt}/${MAX_RETRIES})...`);
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
}
}
}
throw new Error(`Backend not ready after ${MAX_RETRIES} attempts: ${lastError?.message}`);
}

187
tests/e2e/helpers/api.ts Normal file
View File

@@ -0,0 +1,187 @@
/**
* Direct REST API helpers for E2E test setup and teardown.
* These bypass the UI to set up preconditions and verify backend state.
*/
const BASE_URL = 'http://localhost:8000/api';
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${path}`, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
});
if (!res.ok) {
const body = await res.text();
throw new Error(`API ${init?.method || 'GET'} ${path} returned ${res.status}: ${body}`);
}
return res.json() as Promise<T>;
}
// --- Health ---
export interface HealthStatus {
radio_connected: boolean;
serial_port: string | null;
}
export function getHealth(): Promise<HealthStatus> {
return fetchJson('/health');
}
// --- Radio Config ---
export interface RadioConfig {
name: string;
public_key: string;
lat: number;
lon: number;
tx_power: number;
freq: number;
bw: number;
sf: number;
cr: number;
}
export function getRadioConfig(): Promise<RadioConfig> {
return fetchJson('/radio/config');
}
export function updateRadioConfig(patch: Partial<RadioConfig>): Promise<RadioConfig> {
return fetchJson('/radio/config', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
export function rebootRadio(): Promise<{ status: string; message: string }> {
return fetchJson('/radio/reboot', { method: 'POST' });
}
// --- Channels ---
export interface Channel {
key: string;
name: string;
is_hashtag: boolean;
on_radio: boolean;
}
export function getChannels(): Promise<Channel[]> {
return fetchJson('/channels');
}
export function createChannel(name: string): Promise<Channel> {
return fetchJson('/channels', {
method: 'POST',
body: JSON.stringify({ name }),
});
}
export function deleteChannel(key: string): Promise<void> {
return fetchJson(`/channels/${key}`, { method: 'DELETE' });
}
// --- Messages ---
export interface MessagePath {
path: string;
received_at: number;
}
export interface Message {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string;
text: string;
outgoing: boolean;
acked: number;
received_at: number;
sender_timestamp: number | null;
paths: MessagePath[] | null;
}
export function getMessages(params: {
type?: string;
conversation_key?: string;
limit?: number;
}): Promise<Message[]> {
const qs = new URLSearchParams();
if (params.type) qs.set('type', params.type);
if (params.conversation_key) qs.set('conversation_key', params.conversation_key);
if (params.limit) qs.set('limit', String(params.limit));
return fetchJson(`/messages?${qs}`);
}
export function sendChannelMessage(
channelKey: string,
text: string
): Promise<{ status: string; message_id: number }> {
return fetchJson('/messages/channel', {
method: 'POST',
body: JSON.stringify({ channel_key: channelKey, text }),
});
}
// --- Settings ---
export interface BotConfig {
id: string;
name: string;
enabled: boolean;
code: string;
}
export interface AppSettings {
max_radio_contacts: number;
favorites: { type: string; id: string }[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: string;
last_message_times: Record<string, number>;
preferences_migrated: boolean;
bots: BotConfig[];
advert_interval: number;
}
export function getSettings(): Promise<AppSettings> {
return fetchJson('/settings');
}
export function updateSettings(patch: Partial<AppSettings>): Promise<AppSettings> {
return fetchJson('/settings', {
method: 'PATCH',
body: JSON.stringify(patch),
});
}
// --- Helpers ---
/**
* Ensure #flightless channel exists, creating it if needed.
* Returns the channel object.
*/
export async function ensureFlightlessChannel(): Promise<Channel> {
const channels = await getChannels();
const existing = channels.find((c) => c.name === '#flightless');
if (existing) return existing;
return createChannel('#flightless');
}
/**
* Wait for health to show radio_connected, polling with retries.
*/
export async function waitForRadioConnected(
timeoutMs: number = 30_000,
intervalMs: number = 2000
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const health = await getHealth();
if (health.radio_connected) return;
} catch {
// Backend might be restarting
}
await new Promise((r) => setTimeout(r, intervalMs));
}
throw new Error(`Radio did not reconnect within ${timeoutMs}ms`);
}

76
tests/e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,76 @@
{
"name": "remoteterm-e2e",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "remoteterm-e2e",
"devDependencies": {
"@playwright/test": "^1.52.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}

11
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,11 @@
{
"name": "remoteterm-e2e",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed"
},
"devDependencies": {
"@playwright/test": "^1.52.0"
}
}

View File

@@ -0,0 +1,51 @@
import { defineConfig } from '@playwright/test';
import path from 'path';
const projectRoot = path.resolve(__dirname, '..', '..');
const tmpDir = path.resolve(__dirname, '.tmp');
export default defineConfig({
testDir: './specs',
globalSetup: './global-setup.ts',
// Radio operations are slow — generous timeouts
timeout: 60_000,
expect: { timeout: 15_000 },
// Don't retry — failures likely indicate real hardware/app issues
retries: 0,
// Run tests serially — single radio means no parallelism
fullyParallel: false,
workers: 1,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'http://localhost:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
webServer: {
command: 'uv run uvicorn app.main:app --host 127.0.0.1 --port 8000',
cwd: projectRoot,
port: 8000,
reuseExistingServer: false,
timeout: 30_000,
env: {
MESHCORE_DATABASE_PATH: path.join(tmpDir, 'e2e-test.db'),
// Pass through the serial port from the environment
...(process.env.MESHCORE_SERIAL_PORT
? { MESHCORE_SERIAL_PORT: process.env.MESHCORE_SERIAL_PORT }
: {}),
},
},
});

View File

@@ -0,0 +1,67 @@
import { test, expect } from '@playwright/test';
import { ensureFlightlessChannel, getSettings, updateSettings } from '../helpers/api';
import type { BotConfig } from '../helpers/api';
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
return "[BOT] e2e-ok"
return None`;
test.describe('Bot functionality', () => {
let originalBots: BotConfig[];
test.beforeAll(async () => {
await ensureFlightlessChannel();
const settings = await getSettings();
originalBots = settings.bots ?? [];
});
test.afterAll(async () => {
// Restore original bot config
try {
await updateSettings({ bots: originalBots });
} catch {
console.warn('Failed to restore bot config');
}
});
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
page,
}) => {
// --- Step 1: Create and enable bot via API ---
// CodeMirror is difficult to drive via Playwright (contenteditable, lazy-loaded),
// so we set the bot code via the REST API and verify it through the UI.
const testBot: BotConfig = {
id: crypto.randomUUID(),
name: 'E2E Test Bot',
enabled: true,
code: BOT_CODE,
};
await updateSettings({ bots: [...originalBots, testBot] });
// --- Step 2: Verify bot appears in settings UI ---
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText('Radio & Config').click();
await page.getByRole('tab', { name: 'Bot' }).click();
// The bot name should be visible in the bot list
await expect(page.getByText('E2E Test Bot')).toBeVisible();
// Close settings
await page.keyboard.press('Escape');
// --- Step 3: Trigger the bot ---
await page.getByText('#flightless', { exact: true }).first().click();
const triggerMessage = `!e2etest ${Date.now()}`;
const input = page.getByPlaceholder(/type a message|message #flightless/i);
await input.fill(triggerMessage);
await page.getByRole('button', { name: 'Send' }).click();
// --- Step 4: Verify bot response appears ---
// Bot has ~2s delay before responding, plus radio send time
await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 });
});
});

View File

@@ -0,0 +1,22 @@
import { test, expect } from '@playwright/test';
test.describe('Health & UI basics', () => {
test('page loads and shows connected status', async ({ page }) => {
await page.goto('/');
// Status bar shows "Connected"
await expect(page.getByText('Connected')).toBeVisible();
// Sidebar is visible with key sections
await expect(page.getByRole('heading', { name: 'Conversations' })).toBeVisible();
await expect(page.getByText('Packet Feed')).toBeVisible();
await expect(page.getByText('Node Map')).toBeVisible();
});
test('sidebar shows Channels and Contacts sections', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Channels')).toBeVisible();
await expect(page.getByText('Contacts')).toBeVisible();
});
});

View File

@@ -0,0 +1,174 @@
import { test, expect } from '@playwright/test';
import { createChannel, getChannels, getMessages } from '../helpers/api';
/**
* These tests wait for real incoming messages from the mesh network.
* They require a radio attached and other nodes actively transmitting.
* Timeout is 10 minutes to allow for intermittent traffic.
*/
const ROOMS = [
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
'#seattle', '#vanbot', '#bot-van', '#lynden', '#bham', '#sipesbot', '#psrg',
'#testing', '#olybot', '#test', '#ve7rva', '#wardrive', '#kitsap', '#tacoma',
'#rats', '#pdx', '#olympia', '#bot2', '#transit', '#salishmesh', '#meshwar',
'#cats', '#jokes', '#decode', '#whatcom', '#bot-oly', '#sports', '#weather',
'#wasma', '#ravenna', '#northbend', '#dsa', '#oly-bot', '#grove', '#cars',
'#bellingham', '#baseball', '#mariners', '#eugene', '#victoria', '#vimesh',
'#bot-pdx', '#chinese', '#miro', '#poop', '#papa', '#uw', '#renton',
'#general', '#bellevue', '#eastside', '#bit', '#dev', '#farts', '#protest',
'#gmrs', '#pri', '#boob', '#baga', '#fun', '#w7dk', '#wedgwood', '#bots',
'#sounders', '#steelhead', '#uetfwf', '#ballard', '#at', '#1234567', '#funny',
'#abbytest', '#abird', '#afterparty', '#arborheights', '#atheist', '#auburn',
'#bbs', '#blog', '#bottest', '#cascadiamesh', '#chat', '#checkcheck',
'#civicmesh', '#columbiacity', '#dad', '#dmaspace', '#droptable', '#duvall',
'#dx', '#emcomm', '#finnhill', '#foxden', '#freebsd', '#greenwood', '#howlbot',
'#idahomesh', '#junk', '#kraken', '#kremwerk', '#maplemesh', '#meshcore',
'#meshmonday', '#methow', '#minecraft', '#newwestminster', '#northvan',
'#ominous', '#pagan', '#party', '#place', '#pokemon', '#portland', '#rave',
'#raving', '#rftest', '#richmond', '#rolston', '#salishtest', '#saved',
'#seahawks', '#sipebot', '#slumbermesh', '#snoqualmie', '#southisland',
'#sydney', '#tacobot', '#tdeck', '#trans', '#ubc', '#underground', '#van-bot',
'#vancouver', '#vashon', '#wardriving', '#wormhole', '#yelling', '#zork',
];
// 10 minute timeout for waiting on mesh traffic
test.describe('Incoming mesh messages', () => {
test.setTimeout(600_000);
test.beforeAll(async () => {
// Ensure all rooms exist — create any that are missing
const existing = await getChannels();
const existingNames = new Set(existing.map((c) => c.name));
for (const room of ROOMS) {
if (!existingNames.has(room)) {
try {
await createChannel(room);
} catch {
// May already exist from a concurrent creation, ignore
}
}
}
});
test('receive an incoming message in any room', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Record existing message counts per channel so we can detect new ones
const channels = await getChannels();
const baselineCounts = new Map<string, number>();
for (const ch of channels) {
const msgs = await getMessages({ type: 'CHAN', conversation_key: ch.key, limit: 1 });
baselineCounts.set(ch.key, msgs.length > 0 ? msgs[0].id : 0);
}
// Poll for a new incoming message across all channels
let foundChannel: string | null = null;
let foundMessageText: string | null = null;
await expect(async () => {
for (const ch of channels) {
const msgs = await getMessages({
type: 'CHAN',
conversation_key: ch.key,
limit: 5,
});
const baseline = baselineCounts.get(ch.key) ?? 0;
const newIncoming = msgs.find((m) => m.id > baseline && !m.outgoing);
if (newIncoming) {
foundChannel = ch.name;
foundMessageText = newIncoming.text;
return;
}
}
throw new Error('No new incoming messages yet');
}).toPass({ intervals: [5_000], timeout: 570_000 });
// Navigate to the channel that received a message
console.log(`Received message in ${foundChannel}: "${foundMessageText}"`);
await page.getByText(foundChannel!, { exact: true }).first().click();
// Verify the message text is visible in the message list area (not sidebar)
const messageArea = page.locator('.break-words');
const messageContent = foundMessageText!.includes(': ')
? foundMessageText!.split(': ').slice(1).join(': ')
: foundMessageText!;
await expect(messageArea.getByText(messageContent, { exact: false }).first()).toBeVisible({
timeout: 15_000,
});
});
test('incoming message with path shows hop badge and path modal', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// Record baselines
const channels = await getChannels();
const baselineCounts = new Map<string, number>();
for (const ch of channels) {
const msgs = await getMessages({ type: 'CHAN', conversation_key: ch.key, limit: 1 });
baselineCounts.set(ch.key, msgs.length > 0 ? msgs[0].id : 0);
}
// Wait for any incoming message that has path data
let foundChannel: string | null = null;
await expect(async () => {
for (const ch of channels) {
const msgs = await getMessages({
type: 'CHAN',
conversation_key: ch.key,
limit: 10,
});
const baseline = baselineCounts.get(ch.key) ?? 0;
const withPath = msgs.find(
(m) => m.id > baseline && !m.outgoing && m.paths && m.paths.length > 0
);
if (withPath) {
foundChannel = ch.name;
return;
}
}
throw new Error('No new incoming messages with path data yet');
}).toPass({ intervals: [5_000], timeout: 570_000 });
console.log(`Found message with path in ${foundChannel}`);
// Navigate to the channel that received a message with path data
await page.getByText(foundChannel!, { exact: true }).first().click();
// Find any hop badge on the page — they all have title="View message path"
// We don't care which specific message; just that a path badge exists and works.
const badge = page.getByTitle('View message path').first();
await expect(badge).toBeVisible({ timeout: 15_000 });
// The badge text should match the pattern: (d), (1), (d/1/3), etc.
const badgeText = await badge.textContent();
console.log(`Badge text: ${badgeText}`);
expect(badgeText).toMatch(/^\([d\d]+(\/[d\d]+)*\)$/);
// Click the badge to open the path modal
await badge.click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible();
// Verify the modal has the basic structural elements every path modal should have
await expect(modal.getByText('Sender')).toBeVisible();
await expect(modal.getByText('Receiver (me)')).toBeVisible();
// Title should be either "Message Path" (single) or "Message Paths (N)" (multiple)
const titleEl = modal.locator('h2, [class*="DialogTitle"]').first();
const titleText = await titleEl.textContent();
console.log(`Modal title: ${titleText}`);
expect(titleText).toMatch(/^Message Paths?(\s+\(\d+\))?$/);
// Close the modal
await modal.getByRole('button', { name: 'Close', exact: true }).first().click();
await expect(modal).not.toBeVisible();
});
});

View File

@@ -0,0 +1,49 @@
import { test, expect } from '@playwright/test';
import { ensureFlightlessChannel } from '../helpers/api';
test.describe('Channel messaging in #flightless', () => {
test.beforeEach(async () => {
await ensureFlightlessChannel();
});
test('send a message and see it appear', async ({ page }) => {
await page.goto('/');
// Click #flightless in the sidebar (use exact match to avoid "Flightless🥝" etc.)
await page.getByText('#flightless', { exact: true }).first().click();
// Verify conversation is open — the input placeholder includes the channel name
await expect(page.getByPlaceholder(/message #flightless/i)).toBeVisible();
// Compose a unique message
const testMessage = `e2e-test-${Date.now()}`;
const input = page.getByPlaceholder(/type a message|message #flightless/i);
await input.fill(testMessage);
// Send it
await page.getByRole('button', { name: 'Send' }).click();
// Verify message appears in the message list
await expect(page.getByText(testMessage)).toBeVisible({ timeout: 15_000 });
});
test('outgoing message shows ack indicator', async ({ page }) => {
await page.goto('/');
await page.getByText('#flightless', { exact: true }).first().click();
const testMessage = `ack-test-${Date.now()}`;
const input = page.getByPlaceholder(/type a message|message #flightless/i);
await input.fill(testMessage);
await page.getByRole('button', { name: 'Send' }).click();
// Wait for the message to appear
const messageEl = page.getByText(testMessage);
await expect(messageEl).toBeVisible({ timeout: 15_000 });
// Outgoing messages show either "?" (pending) or "✓" (acked)
// The ack indicator is in the same container as the message text
const messageContainer = messageEl.locator('..');
await expect(messageContainer.getByText(/[?✓]/)).toBeVisible();
});
});

View File

@@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { getRadioConfig, updateRadioConfig } from '../helpers/api';
test.describe('Radio settings', () => {
let originalName: string;
test.beforeAll(async () => {
const config = await getRadioConfig();
originalName = config.name;
});
test.afterAll(async () => {
// Restore original name via API
try {
await updateRadioConfig({ name: originalName });
} catch {
console.warn('Failed to restore radio name — manual intervention may be needed');
}
});
test('change radio name via settings UI and verify persistence', async ({ page }) => {
// Radio names are limited to 8 characters
const testName = 'E2Etest1';
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
// --- Step 1: Change the name via settings UI ---
await page.getByText('Radio & Config').click();
await page.getByRole('tab', { name: 'Identity' }).click();
const nameInput = page.locator('#name');
await nameInput.clear();
await nameInput.fill(testName);
await page.getByRole('button', { name: 'Save Identity Settings' }).click();
await expect(page.getByText('Identity settings saved')).toBeVisible({ timeout: 10_000 });
// Close settings
await page.keyboard.press('Escape');
// --- 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 });
await page.getByText('Radio & Config').click();
await page.getByRole('tab', { name: 'Identity' }).click();
await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 });
});
});

12
tests/e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist"
},
"include": ["**/*.ts"]
}