forked from iarv/Remote-Terminal-for-MeshCore
Compare commits
4 Commits
main
...
e2e-automa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec1c24ce18 | ||
|
|
ef3439b07f | ||
|
|
e4caca7c8a | ||
|
|
9a69a9b343 |
@@ -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
1
frontend/dist/assets/index-DIU0_HcJ.js.map
vendored
1
frontend/dist/assets/index-DIU0_HcJ.js.map
vendored
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
1
frontend/dist/assets/index-DafoZZfC.js.map
vendored
Normal file
1
frontend/dist/assets/index-DafoZZfC.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
4
frontend/dist/index.html
vendored
4
frontend/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
tests/e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.tmp/
|
||||
test-results/
|
||||
playwright-report/
|
||||
38
tests/e2e/global-setup.ts
Normal file
38
tests/e2e/global-setup.ts
Normal 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
187
tests/e2e/helpers/api.ts
Normal 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
76
tests/e2e/package-lock.json
generated
Normal 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
11
tests/e2e/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
51
tests/e2e/playwright.config.ts
Normal file
51
tests/e2e/playwright.config.ts
Normal 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 }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
67
tests/e2e/specs/bot.spec.ts
Normal file
67
tests/e2e/specs/bot.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
22
tests/e2e/specs/health.spec.ts
Normal file
22
tests/e2e/specs/health.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
174
tests/e2e/specs/incoming-message.spec.ts
Normal file
174
tests/e2e/specs/incoming-message.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
49
tests/e2e/specs/messaging.spec.ts
Normal file
49
tests/e2e/specs/messaging.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
54
tests/e2e/specs/radio-settings.spec.ts
Normal file
54
tests/e2e/specs/radio-settings.spec.ts
Normal 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
12
tests/e2e/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user