mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add a smatttering of tests and fix return-to-public after channel deletion
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
55
tests/e2e/specs/conversation-delete.spec.ts
Normal file
55
tests/e2e/specs/conversation-delete.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
75
tests/e2e/specs/favorites.spec.ts
Normal file
75
tests/e2e/specs/favorites.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
95
tests/e2e/specs/hash-routing.spec.ts
Normal file
95
tests/e2e/specs/hash-routing.spec.ts
Normal 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)}/`);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user