diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 0000000..3e3209e --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.tmp/ +test-results/ +playwright-report/ diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts new file mode 100644 index 0000000..12aa4d4 --- /dev/null +++ b/tests/e2e/global-setup.ts @@ -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}`); +} diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts new file mode 100644 index 0000000..aaf80fa --- /dev/null +++ b/tests/e2e/helpers/api.ts @@ -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(path: string, init?: RequestInit): Promise { + 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; +} + +// --- Health --- + +export interface HealthStatus { + radio_connected: boolean; + serial_port: string | null; +} + +export function getHealth(): Promise { + 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 { + return fetchJson('/radio/config'); +} + +export function updateRadioConfig(patch: Partial): Promise { + 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 { + return fetchJson('/channels'); +} + +export function createChannel(name: string): Promise { + return fetchJson('/channels', { + method: 'POST', + body: JSON.stringify({ name }), + }); +} + +export function deleteChannel(key: string): Promise { + 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 { + 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; + preferences_migrated: boolean; + bots: BotConfig[]; + advert_interval: number; +} + +export function getSettings(): Promise { + return fetchJson('/settings'); +} + +export function updateSettings(patch: Partial): Promise { + 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 { + 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 { + 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`); +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 0000000..ebff230 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -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" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 0000000..b831bc7 --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,11 @@ +{ + "name": "remoteterm-e2e", + "private": true, + "scripts": { + "test": "playwright test", + "test:headed": "playwright test --headed" + }, + "devDependencies": { + "@playwright/test": "^1.52.0" + } +} diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts new file mode 100644 index 0000000..292cb7d --- /dev/null +++ b/tests/e2e/playwright.config.ts @@ -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 } + : {}), + }, + }, +}); diff --git a/tests/e2e/specs/bot.spec.ts b/tests/e2e/specs/bot.spec.ts new file mode 100644 index 0000000..40ffc0c --- /dev/null +++ b/tests/e2e/specs/bot.spec.ts @@ -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 }); + }); +}); diff --git a/tests/e2e/specs/health.spec.ts b/tests/e2e/specs/health.spec.ts new file mode 100644 index 0000000..a88975d --- /dev/null +++ b/tests/e2e/specs/health.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/specs/incoming-message.spec.ts b/tests/e2e/specs/incoming-message.spec.ts new file mode 100644 index 0000000..b12e307 --- /dev/null +++ b/tests/e2e/specs/incoming-message.spec.ts @@ -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(); + 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(); + 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(); + }); +}); diff --git a/tests/e2e/specs/messaging.spec.ts b/tests/e2e/specs/messaging.spec.ts new file mode 100644 index 0000000..741d422 --- /dev/null +++ b/tests/e2e/specs/messaging.spec.ts @@ -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(); + }); +}); diff --git a/tests/e2e/specs/radio-settings.spec.ts b/tests/e2e/specs/radio-settings.spec.ts new file mode 100644 index 0000000..07b9c86 --- /dev/null +++ b/tests/e2e/specs/radio-settings.spec.ts @@ -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 }); + }); +}); diff --git a/tests/e2e/tsconfig.json b/tests/e2e/tsconfig.json new file mode 100644 index 0000000..b6a6666 --- /dev/null +++ b/tests/e2e/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["**/*.ts"] +}