4 Commits

Author SHA1 Message Date
Jack Kingsman
ec1c24ce18 Add e2e tests 2026-01-30 20:45:39 -08:00
Jack Kingsman
ef3439b07f Fix up some warnings 2026-01-30 20:45:38 -08:00
Jack Kingsman
e4caca7c8a Make links clickable 2026-01-30 20:45:37 -08:00
Jack Kingsman
9a69a9b343 Fix verbiage around bot response to self 2026-01-30 20:45:32 -08:00
24 changed files with 867 additions and 73 deletions

View File

@@ -104,6 +104,11 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
# Sync time with system clock
await sync_radio_time()
# Re-fetch self_info so the response reflects the changes we just made.
# Commands like set_name() write to flash but don't update the cached
# self_info — send_appstart() triggers a fresh SELF_INFO from the radio.
await mc.commands.send_appstart()
return await get_radio_config()

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,8 +13,8 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" crossorigin src="/assets/index-DIU0_HcJ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DiCOP9Mw.css">
<script type="module" crossorigin src="/assets/index-DafoZZfC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DJA5wYVF.css">
</head>
<body>
<div id="root"></div>

View File

@@ -518,29 +518,35 @@ export function App() {
}, []);
// Toggle favorite status for a conversation (via API) with optimistic update
const handleToggleFavorite = useCallback(
async (type: 'channel' | 'contact', id: string) => {
// Compute optimistic new state
const wasFavorited = isFavorite(favorites, type, id);
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
// Read current favorites inside the callback to avoid a dependency on the
// derived `favorites` array (which creates a new reference every render).
setAppSettings((prev) => {
if (!prev) return prev;
const currentFavorites = prev.favorites ?? [];
const wasFavorited = isFavorite(currentFavorites, type, id);
const optimisticFavorites = wasFavorited
? favorites.filter((f) => !(f.type === type && f.id === id))
: [...favorites, { type, id }];
// Optimistic update
setAppSettings((prev) => (prev ? { ...prev, favorites: optimisticFavorites } : prev));
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
: [...currentFavorites, { type, id }];
return { ...prev, favorites: optimisticFavorites };
});
try {
const updatedSettings = await api.toggleFavorite(type, id);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle favorite:', err);
// Revert: re-fetch would be safest, but restoring from server state on next sync
// is acceptable. For now, just refetch settings.
try {
const updatedSettings = await api.toggleFavorite(type, id);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle favorite:', err);
// Revert on error
setAppSettings((prev) => (prev ? { ...prev, favorites } : prev));
toast.error('Failed to update favorite');
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
},
[favorites]
);
toast.error('Failed to update favorite');
}
}, []);
// Delete channel handler
const handleDeleteChannel = useCallback(async (key: string) => {

View File

@@ -19,10 +19,45 @@ interface MessageListProps {
config?: RadioConfig | null;
}
// Helper to render text with highlighted @[Name] mentions
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
if (!radioName) return text;
// URL regex for linkifying plain text
const URL_PATTERN =
/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g;
// Helper to convert URLs in a plain text string into clickable links
function linkifyText(text: string, keyPrefix: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let keyIndex = 0;
URL_PATTERN.lastIndex = 0;
while ((match = URL_PATTERN.exec(text)) !== null) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
}
parts.push(
<a
key={`${keyPrefix}-link-${keyIndex++}`}
href={match[0]}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline hover:text-primary/80"
>
{match[0]}
</a>
);
lastIndex = match.index + match[0].length;
}
if (lastIndex === 0) return [text];
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}
return parts;
}
// Helper to render text with highlighted @[Name] mentions and clickable URLs
function renderTextWithMentions(text: string, radioName?: string): ReactNode {
const mentionPattern = /@\[([^\]]+)\]/g;
const parts: ReactNode[] = [];
let lastIndex = 0;
@@ -30,17 +65,17 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
let keyIndex = 0;
while ((match = mentionPattern.exec(text)) !== null) {
// Add text before the match
// Add text before the match (with linkification)
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index));
parts.push(...linkifyText(text.slice(lastIndex, match.index), `pre-${keyIndex}`));
}
const mentionedName = match[1];
const isOwnMention = mentionedName === radioName;
const isOwnMention = radioName ? mentionedName === radioName : false;
parts.push(
<span
key={keyIndex++}
key={`mention-${keyIndex++}`}
className={cn(
'rounded px-0.5',
isOwnMention ? 'bg-primary/30 text-primary font-medium' : 'bg-muted-foreground/20'
@@ -53,9 +88,9 @@ function renderTextWithMentions(text: string, radioName?: string): ReactNode {
lastIndex = match.index + match[0].length;
}
// Add remaining text after last match
// Add remaining text after last match (with linkification)
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
parts.push(...linkifyText(text.slice(lastIndex), `post-${keyIndex}`));
}
return parts.length > 0 ? parts : text;

View File

@@ -470,6 +470,7 @@ function useVisualizerData({
return () => {
sim.stop();
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- one-time init; dimensions/charge handled by the effect below
}, []);
// Update simulation forces when dimensions/charge change
@@ -516,6 +517,7 @@ function useVisualizerData({
});
syncSimulation();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- syncSimulation is stable (no deps), defined below
}, [config, dimensions]);
// Reset on option changes

View File

@@ -1023,9 +1023,10 @@ export function SettingsModal({
<strong>Limits:</strong> 10 second timeout per bot.
</p>
<p>
<strong>Note:</strong> Bots only respond to incoming messages, not your own. For
<strong>Note:</strong> Bots respond to all messages, including your own. For
channel messages, <code>sender_key</code> is <code>None</code>. Multiple enabled
bots run serially.
bots run serially, with a two-second delay between messages to prevent repeater
collision.
</p>
</div>

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"]
}