diff --git a/README.md b/README.md index 6cd1129..db7b70a 100644 --- a/README.md +++ b/README.md @@ -291,7 +291,7 @@ npm run test:run **E2E:** -Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly. E2E tests are generally not necessary to run for normal development work. +Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly and an available autodetect-able radio, as well as a contact in the test database (which you can provide in `tests/e2e/.tmp/e2e-test.db` after an initial run). E2E tests are generally not necessary to run for normal development work. ```bash cd tests/e2e diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index 1ce2899..93d7ef1 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -1,11 +1,7 @@ -import type React from 'react'; import { toast } from './ui/sonner'; -import { api } from '../api'; -import { formatTime } from '../utils/messageParser'; -import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; -import { getMapFocusHash } from '../utils/urlHash'; import { isFavorite } from '../utils/favorites'; import { ContactAvatar } from './ContactAvatar'; +import { ContactStatusInfo } from './ContactStatusInfo'; import type { Contact, Conversation, Favorite, RadioConfig } from '../types'; interface ChatHeaderProps { @@ -81,88 +77,13 @@ export function ChatHeader({ (() => { const contact = contacts.find((c) => c.public_key === conversation.id); if (!contact) return null; - const parts: React.ReactNode[] = []; - if (contact.last_seen) { - parts.push(`Last heard: ${formatTime(contact.last_seen)}`); - } - if (contact.last_path_len === -1) { - parts.push('flood'); - } else if (contact.last_path_len === 0) { - parts.push( - { - e.stopPropagation(); - if (window.confirm('Reset path to flood?')) { - api.resetContactPath(contact.public_key).then( - () => toast.success('Path reset to flood'), - () => toast.error('Failed to reset path') - ); - } - }} - title="Click to reset path to flood" - > - direct - - ); - } else if (contact.last_path_len > 0) { - parts.push( - { - e.stopPropagation(); - if (window.confirm('Reset path to flood?')) { - api.resetContactPath(contact.public_key).then( - () => toast.success('Path reset to flood'), - () => toast.error('Failed to reset path') - ); - } - }} - title="Click to reset path to flood" - > - {contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''} - - ); - } - if (isValidLocation(contact.lat, contact.lon)) { - const distFromUs = - config && isValidLocation(config.lat, config.lon) - ? calculateDistance(config.lat, config.lon, contact.lat, contact.lon) - : null; - parts.push( - - { - e.stopPropagation(); - const url = - window.location.origin + - window.location.pathname + - getMapFocusHash(contact.public_key); - window.open(url, '_blank'); - }} - title="View on map" - > - {contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)} - - {distFromUs !== null && ` (${formatDistance(distFromUs)})`} - - ); - } - return parts.length > 0 ? ( - - ( - {parts.map((part, i) => ( - - {i > 0 && ', '} - {part} - - ))} - ) - - ) : null; + return ( + + ); })()}
diff --git a/frontend/src/components/ContactStatusInfo.tsx b/frontend/src/components/ContactStatusInfo.tsx new file mode 100644 index 0000000..5da6fce --- /dev/null +++ b/frontend/src/components/ContactStatusInfo.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from 'react'; +import { toast } from './ui/sonner'; +import { api } from '../api'; +import { formatTime } from '../utils/messageParser'; +import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; +import { getMapFocusHash } from '../utils/urlHash'; +import type { Contact } from '../types'; + +interface ContactStatusInfoProps { + contact: Contact; + ourLat: number | null; + ourLon: number | null; +} + +/** + * Renders the "(Last heard: ..., N hops, lat, lon (dist))" status line + * shared between ChatHeader and RepeaterDashboard. + */ +export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) { + const parts: ReactNode[] = []; + + if (contact.last_seen) { + parts.push(`Last heard: ${formatTime(contact.last_seen)}`); + } + + if (contact.last_path_len === -1) { + parts.push('flood'); + } else if (contact.last_path_len === 0) { + parts.push( + { + e.stopPropagation(); + if (window.confirm('Reset path to flood?')) { + api.resetContactPath(contact.public_key).then( + () => toast.success('Path reset to flood'), + () => toast.error('Failed to reset path') + ); + } + }} + title="Click to reset path to flood" + > + direct + + ); + } else if (contact.last_path_len > 0) { + parts.push( + { + e.stopPropagation(); + if (window.confirm('Reset path to flood?')) { + api.resetContactPath(contact.public_key).then( + () => toast.success('Path reset to flood'), + () => toast.error('Failed to reset path') + ); + } + }} + title="Click to reset path to flood" + > + {contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''} + + ); + } + + if (isValidLocation(contact.lat, contact.lon)) { + const distFromUs = + ourLat != null && ourLon != null && isValidLocation(ourLat, ourLon) + ? calculateDistance(ourLat, ourLon, contact.lat, contact.lon) + : null; + parts.push( + + { + e.stopPropagation(); + const url = + window.location.origin + + window.location.pathname + + getMapFocusHash(contact.public_key); + window.open(url, '_blank'); + }} + title="View on map" + > + {contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)} + + {distFromUs !== null && ` (${formatDistance(distFromUs)})`} + + ); + } + + if (parts.length === 0) return null; + + return ( + + ( + {parts.map((part, i) => ( + + {i > 0 && ', '} + {part} + + ))} + ) + + ); +} diff --git a/frontend/src/components/RepeaterDashboard.tsx b/frontend/src/components/RepeaterDashboard.tsx index 46b8f77..4ee9065 100644 --- a/frontend/src/components/RepeaterDashboard.tsx +++ b/frontend/src/components/RepeaterDashboard.tsx @@ -1,14 +1,10 @@ -import { type ReactNode } from 'react'; import { toast } from './ui/sonner'; import { Button } from './ui/button'; import { RepeaterLogin } from './RepeaterLogin'; import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; -import { api } from '../api'; -import { formatTime } from '../utils/messageParser'; import { isFavorite } from '../utils/favorites'; +import { ContactStatusInfo } from './ContactStatusInfo'; import type { Contact, Conversation, Favorite } from '../types'; -import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils'; -import { getMapFocusHash } from '../utils/urlHash'; import { TelemetryPane } from './repeater/RepeaterTelemetryPane'; import { NeighborsPane } from './repeater/RepeaterNeighborsPane'; import { AclPane } from './repeater/RepeaterAclPane'; @@ -86,91 +82,7 @@ export function RepeaterDashboard({ > {conversation.id} - {contact && - (() => { - const parts: ReactNode[] = []; - if (contact.last_seen) { - parts.push(`Last heard: ${formatTime(contact.last_seen)}`); - } - if (contact.last_path_len === -1) { - parts.push('flood'); - } else if (contact.last_path_len === 0) { - parts.push( - { - e.stopPropagation(); - if (window.confirm('Reset path to flood?')) { - api.resetContactPath(contact.public_key).then( - () => toast.success('Path reset to flood'), - () => toast.error('Failed to reset path') - ); - } - }} - title="Click to reset path to flood" - > - direct - - ); - } else if (contact.last_path_len > 0) { - parts.push( - { - e.stopPropagation(); - if (window.confirm('Reset path to flood?')) { - api.resetContactPath(contact.public_key).then( - () => toast.success('Path reset to flood'), - () => toast.error('Failed to reset path') - ); - } - }} - title="Click to reset path to flood" - > - {contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''} - - ); - } - if (isValidLocation(contact.lat, contact.lon)) { - const distFromUs = - radioLat != null && radioLon != null && isValidLocation(radioLat, radioLon) - ? calculateDistance(radioLat, radioLon, contact.lat, contact.lon) - : null; - parts.push( - - { - e.stopPropagation(); - const url = - window.location.origin + - window.location.pathname + - getMapFocusHash(contact.public_key); - window.open(url, '_blank'); - }} - title="View on map" - > - {contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)} - - {distFromUs !== null && ` (${formatDistance(distFromUs)})`} - - ); - } - return parts.length > 0 ? ( - - ( - {parts.map((part, i) => ( - - {i > 0 && ', '} - {part} - - ))} - ) - - ) : null; - })()} + {contact && }
{loggedIn && ( diff --git a/frontend/src/components/settings/SettingsDatabaseSection.tsx b/frontend/src/components/settings/SettingsDatabaseSection.tsx index 9bcb8c4..c2d1ac0 100644 --- a/frontend/src/components/settings/SettingsDatabaseSection.tsx +++ b/frontend/src/components/settings/SettingsDatabaseSection.tsx @@ -46,10 +46,6 @@ export function SettingsDatabaseSection({ setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); }, [appSettings]); - useEffect(() => { - setReopenLastConversation(getReopenLastConversationEnabled()); - }, []); - const handleCleanup = async () => { const days = parseInt(retentionDays, 10); if (isNaN(days) || days < 1) { diff --git a/frontend/src/prefetch.ts b/frontend/src/prefetch.ts index f5b7bdf..1625b05 100644 --- a/frontend/src/prefetch.ts +++ b/frontend/src/prefetch.ts @@ -22,7 +22,7 @@ const store: Partial = type PrefetchResolved = Awaited; /** Take a prefetched promise (consumed once, then gone). */ -export function takePrefetch(key: K): PrefetchMap[K] | undefined { +function takePrefetch(key: K): PrefetchMap[K] | undefined { const p = store[key]; delete store[key]; return p; diff --git a/frontend/src/utils/visualizerUtils.ts b/frontend/src/utils/visualizerUtils.ts index 898a704..14e0df0 100644 --- a/frontend/src/utils/visualizerUtils.ts +++ b/frontend/src/utils/visualizerUtils.ts @@ -93,7 +93,6 @@ export const PARTICLE_COLOR_MAP: Record = { }; export const PARTICLE_SPEED = 0.008; -export const DEFAULT_OBSERVATION_WINDOW_SEC = 15; // Traffic pattern analysis thresholds // Be conservative - once split, we can't unsplit, so require strong evidence const MIN_OBSERVATIONS_TO_SPLIT = 20; // Need at least this many unique sources per next-hop group diff --git a/pyproject.toml b/pyproject.toml index c7fc005..10f83d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,11 +60,6 @@ include = ["app"] exclude = ["references", ".venv", "tests"] reportMissingImports = true reportMissingTypeStubs = false -# Temporarily lenient - fix these incrementally -reportOptionalMemberAccess = "warning" -reportArgumentType = "warning" -reportReturnType = "warning" -reportAttributeAccessIssue = "warning" [dependency-groups] dev = [ diff --git a/scripts/e2e.sh b/scripts/e2e.sh index 0c09bcb..20688f1 100644 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -3,6 +3,6 @@ set -e SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -echo "Starting E2E tests (kicks off a build; this may take a few minutes..." +echo "Starting E2E tests..." cd "$SCRIPT_DIR/tests/e2e" npx playwright test "$@" diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index 85df888..2a6e0f6 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -35,8 +35,18 @@ export default defineConfig({ ], webServer: { - command: - 'bash -c "if [ ! -d frontend/dist ]; then cd frontend && npm install && npm run build; fi; uv run uvicorn app.main:app --host 127.0.0.1 --port 8000"', + command: `bash -c ' + echo "[e2e] $(date +%T.%3N) Starting webServer command..." + if [ ! -d frontend/dist ]; then + echo "[e2e] $(date +%T.%3N) frontend/dist missing — running npm install + build" + cd frontend && npm install && npm run build + echo "[e2e] $(date +%T.%3N) Frontend build complete" + else + echo "[e2e] $(date +%T.%3N) frontend/dist exists — skipping build" + fi + echo "[e2e] $(date +%T.%3N) Launching uvicorn..." + uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 + '`, cwd: projectRoot, port: 8000, reuseExistingServer: false, diff --git a/tests/e2e/specs/health.spec.ts b/tests/e2e/specs/health.spec.ts index a88975d..c49d0fd 100644 --- a/tests/e2e/specs/health.spec.ts +++ b/tests/e2e/specs/health.spec.ts @@ -16,7 +16,7 @@ test.describe('Health & UI basics', () => { test('sidebar shows Channels and Contacts sections', async ({ page }) => { await page.goto('/'); - await expect(page.getByText('Channels')).toBeVisible(); - await expect(page.getByText('Contacts')).toBeVisible(); + await expect(page.getByText('Channels', { exact: true }).first()).toBeVisible(); + await expect(page.getByText('Contacts', { exact: true }).first()).toBeVisible(); }); });