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();
});
});