diff --git a/tests/e2e/helpers/api.ts b/tests/e2e/helpers/api.ts index a00cb88..ca0f998 100644 --- a/tests/e2e/helpers/api.ts +++ b/tests/e2e/helpers/api.ts @@ -43,6 +43,8 @@ export interface RadioConfig { cr: number; } +export type RadioAdvertMode = 'flood' | 'zero_hop'; + export function getRadioConfig(): Promise { return fetchJson('/radio/config'); } @@ -58,6 +60,13 @@ export function rebootRadio(): Promise<{ status: string; message: string }> { return fetchJson('/radio/reboot', { method: 'POST' }); } +export function sendAdvertisement(mode: RadioAdvertMode = 'flood'): Promise<{ status: string }> { + return fetchJson('/radio/advertise', { + method: 'POST', + body: JSON.stringify({ mode }), + }); +} + // --- Channels --- export interface Channel { @@ -128,6 +137,22 @@ export function deleteContact(publicKey: string): Promise<{ status: string }> { return fetchJson(`/contacts/${publicKey}`, { method: 'DELETE' }); } +export async function getContactByKey(publicKey: string): Promise { + const normalized = publicKey.toLowerCase(); + const contacts = await getContacts(500, 0); + return contacts.find((contact) => contact.public_key.toLowerCase() === normalized); +} + +export function setContactRoutingOverride( + publicKey: string, + route: string +): Promise<{ status: string; public_key: string }> { + return fetchJson(`/contacts/${publicKey}/routing-override`, { + method: 'POST', + body: JSON.stringify({ route }), + }); +} + // --- Messages --- export interface MessagePath { diff --git a/tests/e2e/specs/dev-flightless-direct-route.spec.ts b/tests/e2e/specs/dev-flightless-direct-route.spec.ts new file mode 100644 index 0000000..fd48b5a --- /dev/null +++ b/tests/e2e/specs/dev-flightless-direct-route.spec.ts @@ -0,0 +1,135 @@ +import { test, expect } from '@playwright/test'; +import { + createContact, + deleteContact, + getContactByKey, + getMessages, + setContactRoutingOverride, +} from '../helpers/api'; + +const DEV_ONLY_ENV = 'MESHCORE_ENABLE_DEV_FLIGHTLESS_ROUTE_E2E'; +const FLIGHTLESS_NAME = 'FlightlessDtšŸ„'; +const FLIGHTLESS_PUBLIC_KEY = + 'ae92577bae6c269a1da3c87b5333e1bdb007e372b66e94204b9f92a6b52a62b1'; +const DEVELOPER_ONLY_NOTICE = + `Developer-only hardware test. This scenario assumes ${FLIGHTLESS_NAME} ` + + `(${FLIGHTLESS_PUBLIC_KEY.slice(0, 12)}...) is a nearby reachable node for the author's test radio. ` + + `Set ${DEV_ONLY_ENV}=1 to run it intentionally.`; + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +test.describe('Developer-only direct-route learning for FlightlessDtšŸ„', () => { + test('zero-hop adverts then DM ACK learns a direct route', { tag: '@developer-only' }, async ({ + page, + }, testInfo) => { + testInfo.annotations.push({ type: 'notice', description: DEVELOPER_ONLY_NOTICE }); + if (process.env[DEV_ONLY_ENV] !== '1') { + test.skip(true, DEVELOPER_ONLY_NOTICE); + } + + test.setTimeout(180_000); + console.warn(`[developer-only e2e] ${DEVELOPER_ONLY_NOTICE}`); + + try { + await deleteContact(FLIGHTLESS_PUBLIC_KEY); + } catch { + // Best-effort reset; the contact may not exist yet in the temp E2E DB. + } + + await createContact(FLIGHTLESS_PUBLIC_KEY, FLIGHTLESS_NAME); + await setContactRoutingOverride(FLIGHTLESS_PUBLIC_KEY, ''); + + await expect + .poll( + async () => { + const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY); + return contact?.direct_path_len ?? null; + }, + { + timeout: 10_000, + message: 'Waiting for recreated FlightlessDt contact to start in flood mode', + } + ) + .toBe(-1); + + await page.goto('/#settings/radio'); + await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible(); + + const zeroHopButton = page.getByRole('button', { name: 'Send Zero-Hop Advertisement' }); + await expect(zeroHopButton).toBeVisible(); + + await zeroHopButton.click(); + await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 }); + + await page.waitForTimeout(5_000); + + await zeroHopButton.click(); + await expect(page.getByText('Zero-hop advertisement sent')).toBeVisible({ timeout: 15_000 }); + + await page.getByRole('button', { name: /Back to Chat/i }).click(); + await expect(page.getByRole('button', { name: /Back to Chat/i })).toBeHidden({ + timeout: 15_000, + }); + + const searchInput = page.getByLabel('Search conversations'); + await searchInput.fill(FLIGHTLESS_PUBLIC_KEY.slice(0, 12)); + await expect(page.getByText(FLIGHTLESS_NAME, { exact: true })).toBeVisible({ + timeout: 15_000, + }); + await page.getByText(FLIGHTLESS_NAME, { exact: true }).click(); + await expect + .poll(() => page.url(), { + timeout: 15_000, + message: 'Waiting for FlightlessDt conversation route to load', + }) + .toContain(`#contact/${encodeURIComponent(FLIGHTLESS_PUBLIC_KEY)}`); + await expect( + page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(FLIGHTLESS_NAME)}`, 'i')) + ).toBeVisible({ timeout: 15_000 }); + + const text = `dev-flightless-direct-${Date.now()}`; + const input = page.getByPlaceholder(/message/i); + await input.fill(text); + await page.getByRole('button', { name: 'Send', exact: true }).click(); + await expect(page.getByText(text)).toBeVisible({ timeout: 15_000 }); + + await expect + .poll( + async () => { + const messages = await getMessages({ + type: 'PRIV', + conversation_key: FLIGHTLESS_PUBLIC_KEY, + limit: 25, + }); + const match = messages.find((message) => message.outgoing && message.text === text); + return match?.acked ?? 0; + }, + { + timeout: 90_000, + message: 'Waiting for FlightlessDt DM ACK', + } + ) + .toBeGreaterThan(0); + + await expect + .poll( + async () => { + const contact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY); + return contact?.direct_path_len ?? null; + }, + { + timeout: 90_000, + message: 'Waiting for FlightlessDt route to update from flood to direct', + } + ) + .toBe(0); + + const learnedContact = await getContactByKey(FLIGHTLESS_PUBLIC_KEY); + expect(learnedContact?.direct_path ?? '').toBe(''); + + await page.locator('[title="View contact info"]').click(); + await expect(page.getByLabel('Contact Info')).toBeVisible({ timeout: 15_000 }); + }); +});