mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-11 19:23:03 +02:00
Add a smatttering of tests and fix return-to-public after channel deletion
This commit is contained in:
+49
-3
@@ -59,11 +59,13 @@ import type {
|
|||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const MAX_RAW_PACKETS = 500;
|
const MAX_RAW_PACKETS = 500;
|
||||||
|
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||||
const activeConversationRef = useRef<Conversation | null>(null);
|
const activeConversationRef = useRef<Conversation | null>(null);
|
||||||
const rebootPollTokenRef = useRef(0);
|
const rebootPollTokenRef = useRef(0);
|
||||||
|
const pendingDeleteFallbackRef = useRef(false);
|
||||||
// Track seen message content to prevent duplicate unread increments
|
// Track seen message content to prevent duplicate unread increments
|
||||||
// Uses content-based key (type-conversation_key-text-sender_timestamp) for deduplication
|
// Uses content-based key (type-conversation_key-text-sender_timestamp) for deduplication
|
||||||
const seenMessageContentRef = useRef<Set<string>>(new Set());
|
const seenMessageContentRef = useRef<Set<string>>(new Set());
|
||||||
@@ -497,6 +499,29 @@ export function App() {
|
|||||||
}
|
}
|
||||||
}, [activeConversation]);
|
}, [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
|
// Send message handler
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (text: string) => {
|
async (text: string) => {
|
||||||
@@ -638,10 +663,20 @@ export function App() {
|
|||||||
const handleDeleteChannel = useCallback(async (key: string) => {
|
const handleDeleteChannel = useCallback(async (key: string) => {
|
||||||
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
if (!confirm('Delete this channel? Message history will be preserved.')) return;
|
||||||
try {
|
try {
|
||||||
|
pendingDeleteFallbackRef.current = true;
|
||||||
await api.deleteChannel(key);
|
await api.deleteChannel(key);
|
||||||
messageCache.remove(key);
|
messageCache.remove(key);
|
||||||
setChannels((prev) => prev.filter((c) => c.key !== key));
|
const refreshedChannels = await api.getChannels();
|
||||||
setActiveConversation(null);
|
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');
|
toast.success('Channel deleted');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete channel:', err);
|
console.error('Failed to delete channel:', err);
|
||||||
@@ -655,10 +690,21 @@ export function App() {
|
|||||||
const handleDeleteContact = useCallback(async (publicKey: string) => {
|
const handleDeleteContact = useCallback(async (publicKey: string) => {
|
||||||
if (!confirm('Delete this contact? Message history will be preserved.')) return;
|
if (!confirm('Delete this contact? Message history will be preserved.')) return;
|
||||||
try {
|
try {
|
||||||
|
pendingDeleteFallbackRef.current = true;
|
||||||
await api.deleteContact(publicKey);
|
await api.deleteContact(publicKey);
|
||||||
messageCache.remove(publicKey);
|
messageCache.remove(publicKey);
|
||||||
setContacts((prev) => prev.filter((c) => c.public_key !== 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');
|
toast.success('Contact deleted');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete contact:', 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' });
|
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 ---
|
// --- Messages ---
|
||||||
|
|
||||||
export interface MessagePath {
|
export interface MessagePath {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
await expect(modal).toBeVisible();
|
||||||
|
|
||||||
// Verify the modal has the basic structural elements every path modal should have
|
// Verify the modal has the basic structural elements every path modal should have
|
||||||
await expect(modal.getByText('Sender')).toBeVisible();
|
await expect(modal.getByText('Sender:').first()).toBeVisible();
|
||||||
await expect(modal.getByText('Receiver (me)')).toBeVisible();
|
await expect(modal.getByText('Receiver (me):').first()).toBeVisible();
|
||||||
|
|
||||||
// Title should be either "Message Path" (single) or "Message Paths (N)" (multiple)
|
// Title should be either "Message Path" (single) or "Message Paths (N)" (multiple)
|
||||||
const titleEl = modal.locator('h2, [class*="DialogTitle"]').first();
|
const titleEl = modal.locator('h2, [class*="DialogTitle"]').first();
|
||||||
|
|||||||
Reference in New Issue
Block a user