Add a smatttering of tests and fix return-to-public after channel deletion

This commit is contained in:
Jack Kingsman
2026-02-10 23:02:39 -08:00
parent 6696681f63
commit f52feb2d5c
6 changed files with 317 additions and 5 deletions

View File

@@ -59,11 +59,13 @@ import type {
} from './types';
const MAX_RAW_PACKETS = 500;
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const activeConversationRef = useRef<Conversation | null>(null);
const rebootPollTokenRef = useRef(0);
const pendingDeleteFallbackRef = useRef(false);
// Track seen message content to prevent duplicate unread increments
// Uses content-based key (type-conversation_key-text-sender_timestamp) for deduplication
const seenMessageContentRef = useRef<Set<string>>(new Set());
@@ -497,6 +499,29 @@ export function App() {
}
}, [activeConversation]);
// If a delete action left us without an active conversation, recover to Public
// once channels are available. This is scoped to delete flows only so it doesn't
// interfere with hash-based startup resolution.
useEffect(() => {
if (!pendingDeleteFallbackRef.current) return;
if (activeConversation) {
pendingDeleteFallbackRef.current = false;
return;
}
const publicChannel =
channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) || channels.find((c) => c.name === 'Public');
if (!publicChannel) return;
hasSetDefaultConversation.current = true;
pendingDeleteFallbackRef.current = false;
setActiveConversation({
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
});
}, [activeConversation, channels]);
// Send message handler
const handleSendMessage = useCallback(
async (text: string) => {
@@ -638,10 +663,20 @@ export function App() {
const handleDeleteChannel = useCallback(async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
try {
pendingDeleteFallbackRef.current = true;
await api.deleteChannel(key);
messageCache.remove(key);
setChannels((prev) => prev.filter((c) => c.key !== key));
setActiveConversation(null);
const refreshedChannels = await api.getChannels();
setChannels(refreshedChannels);
const publicChannel =
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
refreshedChannels.find((c) => c.name === 'Public');
hasSetDefaultConversation.current = true;
setActiveConversation({
type: 'channel',
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
name: publicChannel?.name || 'Public',
});
toast.success('Channel deleted');
} catch (err) {
console.error('Failed to delete channel:', err);
@@ -655,10 +690,21 @@ export function App() {
const handleDeleteContact = useCallback(async (publicKey: string) => {
if (!confirm('Delete this contact? Message history will be preserved.')) return;
try {
pendingDeleteFallbackRef.current = true;
await api.deleteContact(publicKey);
messageCache.remove(publicKey);
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
setActiveConversation(null);
const refreshedChannels = await api.getChannels();
setChannels(refreshedChannels);
const publicChannel =
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
refreshedChannels.find((c) => c.name === 'Public');
hasSetDefaultConversation.current = true;
setActiveConversation({
type: 'channel',
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
name: publicChannel?.name || 'Public',
});
toast.success('Contact deleted');
} catch (err) {
console.error('Failed to delete contact:', err);

View File

@@ -81,6 +81,47 @@ export function deleteChannel(key: string): Promise<void> {
return fetchJson(`/channels/${key}`, { method: 'DELETE' });
}
// --- Contacts ---
export interface Contact {
public_key: string;
name: string | null;
type: number;
flags: number;
last_path: string | null;
last_path_len: number;
last_advert: number | null;
lat: number | null;
lon: number | null;
last_seen: number | null;
on_radio: boolean;
last_contacted: number | null;
last_read_at: number | null;
}
export function getContacts(limit: number = 100, offset: number = 0): Promise<Contact[]> {
return fetchJson(`/contacts?limit=${limit}&offset=${offset}`);
}
export function createContact(
publicKey: string,
name?: string,
tryHistorical: boolean = false
): Promise<Contact> {
return fetchJson('/contacts', {
method: 'POST',
body: JSON.stringify({
public_key: publicKey,
...(name ? { name } : {}),
try_historical: tryHistorical,
}),
});
}
export function deleteContact(publicKey: string): Promise<{ status: string }> {
return fetchJson(`/contacts/${publicKey}`, { method: 'DELETE' });
}
// --- Messages ---
export interface MessagePath {

View File

@@ -0,0 +1,55 @@
import { test, expect } from '@playwright/test';
import { createChannel, deleteChannel, getChannels } from '../helpers/api';
test.describe('Conversation deletion flow', () => {
test.beforeAll(async () => {
const channels = await getChannels();
if (!channels.some((c) => c.name === 'Public')) {
await createChannel('Public');
}
});
test('deleting active channel removes it from sidebar and clears composer', async ({ page }) => {
const channelName = `#e2edel${Date.now().toString().slice(-6)}`;
const channel = await createChannel(channelName);
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText(channelName, { exact: true }).first().click();
await expect(page.getByPlaceholder(new RegExp(`message\\s+${channelName}`, 'i'))).toBeVisible();
page.once('dialog', async (dialog) => {
await dialog.accept();
});
await page.getByTitle('Delete').click();
await expect(page.getByText('Channel deleted')).toBeVisible();
await expect(page.getByText(channelName, { exact: true })).not.toBeVisible();
await expect(page.getByPlaceholder(new RegExp(`message\\s+${channelName}`, 'i'))).not.toBeVisible();
try {
await deleteChannel(channel.key);
} catch {
// Best-effort cleanup
}
});
test('deleting active channel falls back to Public conversation', async ({ page }) => {
const channelName = `#e2edel${Date.now().toString().slice(-6)}`;
const channel = await createChannel(channelName);
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText(channelName, { exact: true }).first().click();
await expect(page.getByPlaceholder(new RegExp(`message\\s+${channelName}`, 'i'))).toBeVisible();
page.once('dialog', async (dialog) => {
await dialog.accept();
});
await page.getByTitle('Delete').click();
await expect(page.getByPlaceholder(/message\s+public/i)).toBeVisible({ timeout: 15_000 });
});
});

View File

@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
import {
createChannel,
deleteChannel,
getSettings,
updateSettings,
type Favorite,
} from '../helpers/api';
test.describe('Favorites persistence', () => {
let originalFavorites: Favorite[] = [];
let channelName = '';
let channelKey = '';
test.beforeAll(async () => {
const settings = await getSettings();
originalFavorites = settings.favorites ?? [];
// Start deterministic: no favorites
await updateSettings({ favorites: [] });
channelName = `#e2efav${Date.now().toString().slice(-6)}`;
const channel = await createChannel(channelName);
channelKey = channel.key;
});
test.afterAll(async () => {
try {
await deleteChannel(channelKey);
} catch {
// Best-effort cleanup
}
try {
await updateSettings({ favorites: originalFavorites });
} catch {
// Best-effort cleanup
}
});
test('add and remove favorite channel with persistence across reload', async ({ page }) => {
await page.goto('/');
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText(channelName, { exact: true }).first().click();
const addFavoriteButton = page.getByTitle('Add to favorites');
await expect(addFavoriteButton).toBeVisible();
await addFavoriteButton.click();
await expect(page.getByTitle('Remove from favorites')).toBeVisible();
await expect(page.getByText('Favorites')).toBeVisible();
await expect
.poll(async () => {
const settings = await getSettings();
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
})
.toBe(true);
await page.reload();
await expect(page.getByText('Connected')).toBeVisible();
await page.getByText(channelName, { exact: true }).first().click();
await expect(page.getByTitle('Remove from favorites')).toBeVisible();
await expect(page.getByText('Favorites')).toBeVisible();
await page.getByTitle('Remove from favorites').click();
await expect(page.getByTitle('Add to favorites')).toBeVisible();
await expect
.poll(async () => {
const settings = await getSettings();
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
})
.toBe(false);
await expect(page.getByText('Favorites')).not.toBeVisible();
});
});

View File

@@ -0,0 +1,95 @@
import { test, expect } from '@playwright/test';
import { randomBytes } from 'crypto';
import { createChannel, createContact, deleteChannel, deleteContact } from '../helpers/api';
function randomHex(bytes: number): string {
return randomBytes(bytes).toString('hex');
}
function makeKeyWithPrefix(prefix: string): string {
return `${prefix}${randomHex(26)}`;
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
test.describe('Hash routing and conversation identity', () => {
let channelName = '';
let channelKey = '';
let contactAKey = '';
let contactAName = '';
let contactBKey = '';
let contactBName = '';
test.beforeAll(async () => {
channelName = `#e2ehash${Date.now().toString().slice(-6)}`;
const createdChannel = await createChannel(channelName);
channelKey = createdChannel.key;
const sharedPrefix = randomHex(6);
contactAKey = makeKeyWithPrefix(sharedPrefix);
contactBKey = makeKeyWithPrefix(sharedPrefix);
contactAName = `E2E Hash A ${Date.now().toString().slice(-5)}`;
contactBName = `E2E Hash B ${Date.now().toString().slice(-5)}`;
await createContact(contactAKey, contactAName);
await createContact(contactBKey, contactBName);
});
test.afterAll(async () => {
try {
await deleteChannel(channelKey);
} catch {
// Best-effort cleanup
}
try {
await deleteContact(contactAKey);
} catch {
// Best-effort cleanup
}
try {
await deleteContact(contactBKey);
} catch {
// Best-effort cleanup
}
});
test('legacy channel-name hash resolves and rewrites to stable channel-key hash', async ({
page,
}) => {
const legacyToken = channelName.slice(1); // no leading '#'
await page.goto(`/#channel/${encodeURIComponent(legacyToken)}`);
await expect(page.getByText('Connected')).toBeVisible();
await expect(
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(channelName)}`, 'i'))
).toBeVisible();
await expect.poll(() => page.url()).toContain(`#channel/${encodeURIComponent(channelKey)}/`);
});
test('full-key contact hash selects the exact contact even with shared prefixes', async ({ page }) => {
await page.goto(`/#contact/${contactBKey}`);
await expect(page.getByText('Connected')).toBeVisible();
await expect(
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(contactBName)}`, 'i'))
).toBeVisible();
await expect(page.getByText(contactBKey, { exact: true })).toBeVisible();
await expect.poll(() => page.url()).toContain(`#contact/${encodeURIComponent(contactBKey)}/`);
});
test('legacy contact-name hash resolves and rewrites to stable full-key hash', async ({ page }) => {
await page.goto(`/#contact/${encodeURIComponent(contactAName)}`);
await expect(page.getByText('Connected')).toBeVisible();
await expect(
page.getByPlaceholder(new RegExp(`message\\s+${escapeRegex(contactAName)}`, 'i'))
).toBeVisible();
await expect(page.getByText(contactAKey, { exact: true })).toBeVisible();
await expect.poll(() => page.url()).toContain(`#contact/${encodeURIComponent(contactAKey)}/`);
});
});

View File

@@ -158,8 +158,8 @@ test.describe('Incoming mesh messages', () => {
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();
await expect(modal.getByText('Sender:').first()).toBeVisible();
await expect(modal.getByText('Receiver (me):').first()).toBeVisible();
// Title should be either "Message Path" (single) or "Message Paths (N)" (multiple)
const titleEl = modal.locator('h2, [class*="DialogTitle"]').first();