diff --git a/README.md b/README.md
index 633b2cd..d37f2df 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,40 @@ by design and would manufacture findings out of geography.
## 🛠️ Tools
+### 🚶 Walk & Test — *survey the building, not just the connection*
+
+Ten destinations people actually depend on — Google, YouTube, Netflix, Facebook and Amazon on the
+consumer side; Microsoft 365, Teams, Zoom, Salesforce and Slack on the business side — probed round
+after round while you walk the building. Name the spot you are standing in, wait for a few rounds,
+move, name the next one. The result is a **per-spot comparison**: which corner of the office loses
+Teams, where the round trips double, which dead spot is a dead spot for everything and which is
+only bad for one destination.
+
+The repeated-probe idea is [Richard Astbury's Azure Speed Test](https://richorama.github.io/AzureSpeedTest2/),
+which times a fixed list of regions until the table stops moving. This one expects you to move
+instead.
+
+What the numbers are, stated permanently on screen rather than in a footnote:
+
+- **Each figure is a full HTTPS request round trip, not a ping.** No raw sockets means no ICMP and
+ no lower-level timing. TLS on a new connection and the destination's own front-end are inside
+ every number.
+- **The requests are `no-cors`, so the response is opaque.** A completed probe proves the edge
+ answered and how long it took. A 200, a 403 and a login redirect are indistinguishable from here,
+ and the tool claims nothing about which it got.
+- **These are front doors, not backends.** Netflix playback, Teams call audio and Zoom media run
+ over paths a browser cannot address, so a green row does not promise a smooth call.
+- **Unanswered is not "down", and it is not packet loss.** A timeout, a refused connection, a failed
+ lookup and being out of range look identical to a browser. The column is called *Answered*.
+- **All ten fire at once each round**, which gives them the same instant — and makes them compete
+ on a constrained link. Compare rows and spots to each other, not a single figure to a spec sheet.
+
+Each destination's first probe pays for DNS, TCP and TLS, so it is counted but kept out of the
+timing statistics. Spots are compared by the **median of each destination's own median**, over only
+the destinations that produced a median at *every* spot — pooling raw samples instead would make the
+figure lurch when a destination dropped out, reporting a change in which destinations answered as
+though it were a change in latency.
+
### 1. ⚡ Speed & Bandwidth
Streams real data over three concurrent connections against the Cloudflare edge, measuring
throughput from bytes that actually moved. Reports download, upload, idle latency, jitter, latency
@@ -234,6 +268,7 @@ without touching it, so the following go directly from your browser to third par
| `1.1.1.1`, `one.one.one.one`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets, and as the two halves of the resolver test |
| `ipv4.icanhazip.com`, `ipv6.icanhazip.com`, `api4.ipify.org`, `api6.ipify.org` | Your IP, during the dual-stack check — each answers on one address family only |
| `cp.cloudflare.com` | Your IP, during the captive-portal check, and only when NetReady is opened over plain `http` |
+| `www.google.com`, `www.youtube.com`, `www.netflix.com`, `www.facebook.com`, `www.amazon.com`, `outlook.office365.com`, `teams.microsoft.com`, `zoom.us`, `login.salesforce.com`, `slack.com` | Your IP, once per round for the length of a Walk & Test run — roughly 200 requests each over ten minutes. One HEAD for a small public file, no cookies sent |
| `stun.l.google.com` and other STUN servers | Your public IP, and potentially local addresses |
| `httpbin.org` | Your IP, only when you press "Trigger Network Spike" on the live traffic monitor |
| `basemaps.cartocdn.com`, `openstreetmap.org` | Map areas you view, revealing an approximate target location |
@@ -267,6 +302,10 @@ properly — over UDP, against their actual IP addresses — since 2010. The cac
"dotcom" separation, the NXDOMAIN-redirection check and the plain-English conclusions are all his
design; NetReady reproduces what a browser honestly can and says plainly where it cannot follow.
+Walk & Test borrows its shape from **Richard Astbury's**
+[Azure Speed Test](https://richorama.github.io/AzureSpeedTest2/), which probes a fixed list of
+destinations over and over and lets the table settle rather than reporting one number and stopping.
+
[Lucide](https://lucide.dev/) · [Tailwind CSS](https://tailwindcss.com/) ·
[Vite](https://vitejs.dev/) · [React](https://react.dev/) · [Leaflet](https://leafletjs.com/) ·
[Recharts](https://recharts.org/) · [Cloudflare](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/)
diff --git a/src/App.tsx b/src/App.tsx
index bdb9614..5a46264 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -4,6 +4,7 @@ import { getNetworkConnectionInfo } from './utils/network';
import { getHistory, getLocalStorageSizeBytes } from './utils/storage';
import { Navbar } from './components/Navbar';
import { Dashboard } from './components/Dashboard';
+import { WalkTest } from './components/WalkTest';
import { TriagePanel } from './components/TriagePanel';
import { DualStackCheck } from './components/DualStackCheck';
import { DnsBenchmark } from './components/DnsBenchmark';
@@ -84,6 +85,8 @@ export default function App() {
/>
)}
+ {activeTab === 'walktest' && }
+
{activeTab === 'triage' && }
{activeTab === 'dualstack' && }
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
index e9bb0f2..6c0fd30 100644
--- a/src/components/Dashboard.tsx
+++ b/src/components/Dashboard.tsx
@@ -22,6 +22,8 @@ import {
Network,
ShieldQuestion,
Timer,
+ Footprints,
+ MapPin,
} from 'lucide-react';
import { ToolTab, NetworkConnectionInfo, SpeedTestResult, PingResult, HistoryItem } from '../types';
import {
@@ -377,6 +379,33 @@ export const Dashboard: React.FC = ({
+ {/* Walk & Test — the only tool here that expects you to move */}
+
+ Ten consumer and business destinations probed round after round while you walk the
+ building. Name each spot as you reach it and compare them side by side.
+
+
+
+ Survey the building
+
+
+
+
{/* Triage — the answer layer */}
setActiveTab('triage')}
diff --git a/src/components/ExportPage.tsx b/src/components/ExportPage.tsx
index 8c59325..0d9101f 100644
--- a/src/components/ExportPage.tsx
+++ b/src/components/ExportPage.tsx
@@ -27,6 +27,7 @@ import {
Network,
ShieldQuestion,
Timer,
+ Footprints,
} from 'lucide-react';
import { HistoryItem } from '../types';
import {
@@ -95,6 +96,8 @@ export const ExportPage: React.FC = ({ onHistoryUpdate }) => {
return ;
case 'captive':
return ;
+ case 'walktest':
+ return ;
default:
return ;
}
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index e99743f..ade60b0 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -26,6 +26,7 @@ import {
Network,
ShieldQuestion,
Timer,
+ Footprints,
} from 'lucide-react';
import { ToolTab, NetworkConnectionInfo } from '../types';
import { PrivacySafetyModal } from './PrivacySafetyModal';
@@ -55,6 +56,7 @@ export const Navbar: React.FC = ({
const tabs: { id: ToolTab; label: string; icon: React.FC<{ className?: string }>; badge?: string }[] = [
{ id: 'dashboard', label: 'Dashboard', icon: Activity },
+ { id: 'walktest', label: 'Walk & Test', icon: Footprints, badge: 'NEW' },
{ id: 'triage', label: 'Me or the Internet?', icon: Stethoscope, badge: 'NEW' },
{ id: 'dualstack', label: 'IPv4 / IPv6', icon: Network, badge: 'NEW' },
{ id: 'captive', label: 'Portal & DNS Hijack', icon: ShieldQuestion, badge: 'NEW' },
diff --git a/src/components/PrivacySafetyModal.tsx b/src/components/PrivacySafetyModal.tsx
index adec73b..d599a37 100644
--- a/src/components/PrivacySafetyModal.tsx
+++ b/src/components/PrivacySafetyModal.tsx
@@ -62,6 +62,18 @@ export const THIRD_PARTY_DISCLOSURES: { host: string; receives: string }[] = [
'Your IP, during the captive-portal check — but only when NetReady is opened over plain ' +
'http. A page served over https cannot make this request at all.',
},
+ {
+ host:
+ 'www.google.com, www.youtube.com, www.netflix.com, www.facebook.com, www.amazon.com, ' +
+ 'outlook.office365.com, teams.microsoft.com, zoom.us, login.salesforce.com, slack.com',
+ receives:
+ 'Your IP, repeatedly, for as long as a Walk & Test run lasts — every one of them is probed ' +
+ 'once per round, so a ten-minute walk at the default interval is roughly two hundred ' +
+ 'requests to each. Each request is a HEAD for one small public file and carries no cookies ' +
+ '(`credentials: \'omit\'`), so these hosts see an address and a TLS handshake rather than a ' +
+ 'logged-in user. Several of them are advertising businesses; the IP and the timing pattern ' +
+ 'are still theirs to log.',
+ },
{
host: 'stun.l.google.com (and other STUN servers)',
receives: 'Your public IP, and potentially local network addresses, during WebRTC analysis.',
diff --git a/src/components/WalkTest.tsx b/src/components/WalkTest.tsx
new file mode 100644
index 0000000..87537a9
--- /dev/null
+++ b/src/components/WalkTest.tsx
@@ -0,0 +1,868 @@
+import React, { useMemo, useRef, useState } from 'react';
+import {
+ ArrowUpDown,
+ Footprints,
+ Info,
+ Loader2,
+ MapPin,
+ Play,
+ Square,
+ WifiOff,
+} from 'lucide-react';
+import {
+ Bar,
+ CartesianGrid,
+ ComposedChart,
+ Legend,
+ Line,
+ ReferenceLine,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import type {
+ HistoryItem,
+ WalkSample,
+ WalkTargetStats,
+ WalkTestResult,
+ WalkWaypoint,
+} from '../types';
+import {
+ DEFAULT_INTERVAL_MS,
+ INTERVAL_CHOICES,
+ MAX_STORED_SAMPLES,
+ PROBE_TIMEOUT_MS,
+ WALK_TARGETS,
+ buildWalkConclusions,
+ buildWalkResult,
+ createWaypoint,
+ runWalkLoop,
+ summariseTarget,
+ summariseWaypoint,
+} from '../utils/walkTest';
+import { MIN_SAMPLES_FOR_SUMMARY, median } from '../utils/network';
+import { FailureNotice, MetricValue, displayMetric } from './MetricValue';
+import { StorageFullError, saveHistoryItem } from '../utils/storage';
+
+/**
+ * Walk & Test.
+ *
+ * The screen has one job the rest of the suite does not: it has to stay
+ * readable while the person holding the phone is looking at a wall socket
+ * rather than at it. So the live table is the product, the chart is secondary,
+ * and the single most important control — "I have moved, start a new spot" — is
+ * a large button that never scrolls out of the way while a walk is running.
+ *
+ * The honesty rules cost more here than usual and are worth stating. A
+ * destination that has not answered yet shows an em-dash and no bar: a
+ * zero-length bar in a latency table reads as "instant", which is the exact
+ * opposite of what a dead spot means. And a median only appears once there are
+ * enough samples behind it, which on a three-second interval is about ten
+ * seconds of standing still — the panel says so, because otherwise the empty
+ * cells look like a broken tool rather than an honest one.
+ */
+
+interface WalkTestProps {
+ onHistoryUpdate: () => void;
+}
+
+type SortKey = 'median' | 'last' | 'answered' | 'label';
+
+const CATEGORY_BADGE: Record<'consumer' | 'business', string> = {
+ consumer: 'bg-fuchsia-500/15 text-fuchsia-300',
+ business: 'bg-sky-500/15 text-sky-300',
+};
+
+/** Latency bands, for the row tint only. Deliberately coarse and never shown as
+ * a grade: these are HTTPS round trips to third-party edges, and turning them
+ * into a letter would imply a precision the measurement does not have. */
+const tone = (ms: number | null): string => {
+ if (ms === null) return 'text-slate-600';
+ if (ms < 100) return 'text-emerald-400';
+ if (ms < 250) return 'text-cyan-300';
+ if (ms < 600) return 'text-amber-300';
+ return 'text-rose-300';
+};
+
+const formatDuration = (ms: number): string => {
+ const total = Math.round(ms / 1000);
+ const mins = Math.floor(total / 60);
+ const secs = total % 60;
+ return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
+};
+
+export const WalkTest: React.FC = ({ onHistoryUpdate }) => {
+ const [isWalking, setIsWalking] = useState(false);
+ const [samples, setSamples] = useState([]);
+ const [waypoints, setWaypoints] = useState([]);
+ const [spotName, setSpotName] = useState('');
+ const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS);
+ const [sortKey, setSortKey] = useState('median');
+ const [result, setResult] = useState(null);
+ const [storageWarning, setStorageWarning] = useState(null);
+ const [round, setRound] = useState(0);
+
+ // Mirrors of the state the loop and the stop handler read. React state is not
+ // visible to a closure created before the render that set it, and the finished
+ // record must be built from every sample, not from the ones that existed when
+ // the walk started.
+ const samplesRef = useRef([]);
+ const waypointsRef = useRef([]);
+ const currentWaypointIdRef = useRef('');
+ const abortRef = useRef(null);
+ const startedAtRef = useRef(0);
+
+ const targets = WALK_TARGETS;
+
+ const openSpot = (label: string) => {
+ const next = createWaypoint(label, waypointsRef.current.length + 1);
+ const closed = waypointsRef.current.map((w, i) =>
+ i === waypointsRef.current.length - 1 && w.endedAt === null
+ ? { ...w, endedAt: next.startedAt }
+ : w,
+ );
+ waypointsRef.current = [...closed, next];
+ currentWaypointIdRef.current = next.id;
+ setWaypoints(waypointsRef.current);
+ setSpotName('');
+ };
+
+ const start = async () => {
+ const controller = new AbortController();
+ abortRef.current = controller;
+ samplesRef.current = [];
+ waypointsRef.current = [];
+ startedAtRef.current = performance.now();
+
+ setSamples([]);
+ setResult(null);
+ setStorageWarning(null);
+ setRound(0);
+ setIsWalking(true);
+ openSpot(spotName);
+
+ try {
+ await runWalkLoop({
+ targets,
+ intervalMs,
+ signal: controller.signal,
+ currentWaypointId: () => currentWaypointIdRef.current,
+ onRound: (roundSamples, roundNumber) => {
+ samplesRef.current = [...samplesRef.current, ...roundSamples];
+ setSamples(samplesRef.current);
+ setRound(roundNumber);
+ },
+ });
+ } finally {
+ setIsWalking(false);
+ abortRef.current = null;
+ }
+ };
+
+ const stop = () => {
+ abortRef.current?.abort();
+
+ const finishedAt = Date.now();
+ const closedWaypoints = waypointsRef.current.map((w) =>
+ w.endedAt === null ? { ...w, endedAt: finishedAt } : w,
+ );
+ waypointsRef.current = closedWaypoints;
+ setWaypoints(closedWaypoints);
+
+ const finished = buildWalkResult({
+ targets,
+ waypoints: closedWaypoints,
+ samples: samplesRef.current,
+ intervalMs,
+ durationMs: Math.round(performance.now() - startedAtRef.current),
+ });
+ setResult(finished);
+
+ if (finished.rounds === 0) return;
+
+ const answered = finished.perTarget.reduce((sum, t) => sum + t.summary.answered, 0);
+ const attempted = finished.perTarget.reduce((sum, t) => sum + t.summary.attempted, 0);
+ const item: HistoryItem = {
+ id: finished.id,
+ type: 'walktest',
+ timestamp: finished.timestamp,
+ title:
+ `Walk & test: ${closedWaypoints.length} spot${closedWaypoints.length === 1 ? '' : 's'}, ` +
+ `${finished.rounds} round${finished.rounds === 1 ? '' : 's'} across ${targets.length} destinations`,
+ summary:
+ `${answered} of ${attempted} probes answered over ${formatDuration(finished.durationMs)}. ` +
+ (finished.conclusions.length > 0
+ ? finished.conclusions[0]
+ : 'Not enough rounds ran to conclude anything.'),
+ data: finished,
+ };
+
+ try {
+ saveHistoryItem(item);
+ onHistoryUpdate();
+ } catch (error) {
+ setStorageWarning(
+ error instanceof StorageFullError
+ ? 'The walk is shown below but could not be saved to history — browser storage is full.'
+ : 'The walk is shown below but could not be saved to history.',
+ );
+ }
+ };
+
+ const perTarget = useMemo(
+ () => targets.map((t) => summariseTarget(t, samples)),
+ [targets, samples],
+ );
+
+ const perWaypoint = useMemo(
+ () => waypoints.map((w) => summariseWaypoint(w, targets, samples)),
+ [waypoints, targets, samples],
+ );
+
+ const conclusions = useMemo(
+ () => buildWalkConclusions(perTarget, perWaypoint),
+ [perTarget, perWaypoint],
+ );
+
+ const sorted = useMemo(() => {
+ const rows = [...perTarget];
+ if (sortKey === 'label') return rows.sort((a, b) => a.label.localeCompare(b.label));
+ if (sortKey === 'answered') {
+ return rows.sort(
+ (a, b) =>
+ b.summary.answered - b.summary.attempted - (a.summary.answered - a.summary.attempted) ||
+ a.label.localeCompare(b.label),
+ );
+ }
+
+ // Absent values sort last in every direction. A destination that produced
+ // nothing must never surface at the top of a column headed "fastest".
+ const value = (row: WalkTargetStats): number | null =>
+ sortKey === 'median' ? row.summary.medianMs : row.lastRoundTripMs;
+ return rows.sort((a, b) => {
+ const av = value(a);
+ const bv = value(b);
+ if (av === null && bv === null) return a.label.localeCompare(b.label);
+ if (av === null) return 1;
+ if (bv === null) return -1;
+ return av - bv || a.label.localeCompare(b.label);
+ });
+ }, [perTarget, sortKey]);
+
+ /**
+ * One point per round.
+ *
+ * `answering` is a straight count and means exactly what it says. `medianMs`
+ * is the median across whichever destinations answered *that* round, so when
+ * destinations drop out the line moves partly because the set changed and not
+ * only because the network did. That is a real trap, so the count is plotted
+ * beside it and the caption says so — the per-spot table below is the figure
+ * that is safe to compare, because it is paired.
+ */
+ const timeline = useMemo(() => {
+ const byRound = new Map();
+ for (const s of samples) {
+ const list = byRound.get(s.round);
+ if (list === undefined) byRound.set(s.round, [s]);
+ else list.push(s);
+ }
+
+ return [...byRound.entries()]
+ .sort((a, b) => a[0] - b[0])
+ .map(([roundNumber, roundSamples]) => {
+ const times = roundSamples
+ .filter((s) => s.outcome === 'answered' && !s.connectionSetup && s.roundTripMs !== null)
+ .map((s) => s.roundTripMs as number);
+ const mid = median(times);
+ return {
+ round: roundNumber,
+ answering: roundSamples.filter((s) => s.outcome === 'answered').length,
+ medianMs: mid === null ? null : Math.round(mid),
+ };
+ });
+ }, [samples]);
+
+ /** The round each spot began at, for the chart's dividers. */
+ const spotBoundaries = useMemo(
+ () =>
+ waypoints
+ .map((w) => {
+ const first = samples.find((s) => s.waypointId === w.id);
+ return first === undefined ? null : { round: first.round, label: w.label };
+ })
+ .filter((b): b is { round: number; label: string } => b !== null),
+ [waypoints, samples],
+ );
+
+ const currentSpot = waypoints.length > 0 ? waypoints[waypoints.length - 1] : null;
+ const totalAttempted = perTarget.reduce((sum, t) => sum + t.summary.attempted, 0);
+ const totalAnswered = perTarget.reduce((sum, t) => sum + t.summary.answered, 0);
+ const hasData = totalAttempted > 0;
+
+ const SortHeader: React.FC<{ id: SortKey; children: React.ReactNode; align?: string }> = ({
+ id,
+ children,
+ align = 'text-right',
+ }) => (
+
+
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
Walk & Test
+
+ Ten destinations people actually depend on — five consumer, five business — probed
+ over and over while you walk the building. Name the spot you are standing in, wait a
+ few rounds, move, name the next one. The table below settles as samples arrive; the
+ per-spot comparison at the bottom is what you came for.
+
+
+ The repeated-probe idea is{' '}
+
+ Richard Astbury’s Azure Speed Test
+
+ , which times a fixed list of regions until the numbers stop moving. This one expects
+ you to move instead.
+
+
+
+ The browser reports no network connection. A walk started now will record every
+ destination as unanswered, which is a real result — but nothing here will have a
+ round-trip time.
+
+
+ )}
+
+
+ {storageWarning !== null && (
+
+ {storageWarning}
+
+ )}
+
+ {/* Live destination table. Present from the first round, because watching
+ it fill in is how you know the walk is working. */}
+
+
+
+
+
+
+ Destination
+
+ Last
+ Median
+
Min
+
Max
+
Jitter
+ Answered
+
+
+
+ {sorted.map((row) => {
+ const target = targets.find((t) => t.id === row.targetId);
+ const unanswered = row.summary.attempted - row.summary.answered;
+ const failure =
+ row.summary.medianMs === null && row.summary.attempted > 0
+ ? {
+ metric: row.targetId,
+ reason:
+ row.summary.answered === 0
+ ? ('api-unreachable' as const)
+ : ('insufficient-samples' as const),
+ detail:
+ row.summary.answered === 0
+ ? `${row.label} has not answered any probe yet.`
+ : `${row.label} needs ${MIN_SAMPLES_FOR_SUMMARY} answers after its first ` +
+ 'before a median means anything.',
+ }
+ : undefined;
+
+ return (
+
0 ? 'bg-rose-500/[0.04]' : ''}>
+
+
+ {row.label}
+
+ {row.category}
+
+
+
+ {target?.host}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Rendered as a plain string. `{count && …}` puts a bare
+ zero on the page, which has shipped in this app before. */}
+ 0 ? 'text-amber-300' : 'text-slate-400'}>
+ {`${row.summary.answered} / ${row.summary.attempted}`}
+
+
+
+ );
+ })}
+
+
+
+
+ {hasData
+ ? `Median, min, max and jitter ignore each destination's very first probe, which pays for
+ DNS, TCP and TLS on top of the round trip. They stay blank until ${MIN_SAMPLES_FOR_SUMMARY}
+ later answers exist — roughly ${Math.round(
+ ((MIN_SAMPLES_FOR_SUMMARY + 1) * intervalMs) / 1000,
+ )} seconds of standing still at this interval. A probe is given
+ ${PROBE_TIMEOUT_MS / 1000} seconds before it counts as unanswered.`
+ : 'No probes yet. Press “Start walking”, name the spot you are standing in, and let a few rounds run before you move.'}
+
+
+
+ {/* Permanent, not collapsible. Without it the table reads as a ping
+ comparison between ten companies, which it is not.
+
+ It sits below the table rather than above it because on a phone — which
+ is where a walk test is actually run — seven hundred pixels of caveats
+ before the live numbers means scrolling past them at every spot. The
+ table's own footer carries the exclusions that change how a cell is
+ read; this panel carries the ones that change what the whole tool
+ means, and nothing here is behind a disclosure triangle. */}
+
+
+
+ What these numbers are
+
+
+
+
+ Each figure is a full HTTPS request round trip, not a ping.
+ {' '}
+ A web page has no raw sockets, so there is no ICMP and no lower-level timing to read.
+ These numbers include TLS on a new connection and the destination’s own front-end,
+ and they are not comparable to what ping prints.
+
+
+
+ A completed probe proves the edge answered — nothing more.
+ {' '}
+ The requests are no-cors, so the response is opaque
+ by construction and its status code is unreadable. A 200, a 404 and a 401 are
+ indistinguishable from here, which is fine: reachability and timing are the whole claim.
+ Each URL was picked because it answers outright rather than redirecting — a
+ no-cors request must follow redirects, and a bounce
+ would fold two round trips into one number.
+
+
+
+ These are front doors, not the backends.
+ {' '}
+ You are measuring the CDN edge that terminates TLS near you. Netflix playback, Teams
+ call audio and Zoom media all run over paths a browser cannot address at all, so a green
+ row here does not promise a smooth call.
+
+
+
+ Unanswered is not the same as down, and it is not packet loss.
+ {' '}
+ A timeout, a refused connection, a failed name lookup and being out of range all look
+ identical to a browser. The column counts answers and is called exactly that.
+
+
+
+ All ten fire at once, every round.
+ {' '}
+ That gives every destination the same instant, which is the point when the phone is
+ moving — but on a constrained link they also compete with each other, which lifts all
+ ten together. Compare rows to each other and spots to each other; do not read a single
+ figure as this connection’s latency.
+
+
+
+
+ {timeline.length > 1 && (
+
+
+ Round by round
+
+
+
+
+
+ {/* Both axes start at zero: a truncated axis turns a 10 ms
+ difference into a cliff. */}
+
+
+
+ typeof value === 'number' ? [`${value}`, name] : ['—', name]
+ }
+ />
+
+
+ {/* connectNulls stays off: a round where nothing answered leaves a
+ gap, and drawing through it would invent a measurement. */}
+
+ {spotBoundaries.map((b) => (
+
+ ))}
+
+
+
+ The bars are a straight count and mean what they say. The line starts at round two,
+ because round one is every destination’s connection-setup probe and carries DNS,
+ TCP and TLS inside it. The line is the median across whichever destinations answered{' '}
+ that round, so when destinations drop out it
+ moves partly because the set changed and not only because the network did — watch the
+ bars alongside it. The per-spot table below does not have that problem: it compares only
+ the destinations that produced a median at every spot.
+
+ Each cell is that destination’s median at that spot. The overall column is the
+ median of those medians, so one chatty destination cannot dominate it and it does not
+ lurch when a destination stops answering.
+
+ {result.samplesDropped} individual probe record
+ {result.samplesDropped === 1 ? '' : 's'} were dropped from the saved copy — only the
+ most recent {MAX_STORED_SAMPLES} are kept, to stay inside the browser’s
+ storage budget. Every statistic above was calculated before that truncation.
+
+ )}
+
+ 0 ? result.failures : undefined} />
+ >
+ )}
+
+ );
+};
diff --git a/src/types.ts b/src/types.ts
index e983677..3524955 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,5 +1,6 @@
export type ToolTab =
| 'dashboard'
+ | 'walktest'
| 'triage'
| 'dualstack'
| 'captive'
@@ -682,6 +683,144 @@ export interface DnsBenchmarkResult {
failures: MeasurementFailure[];
}
+// ---------------------------------------------------------------------------
+// Walk & Test
+//
+// Repeated reachability probes to a fixed set of well-known destinations, tagged
+// with the spot the phone was standing in when they were taken. After Richard
+// Astbury's Azure Speed Test, which times a fixed list of regions over and over
+// and lets the table settle — except that here you are expected to move between
+// readings, so the deliverable is a comparison between spots rather than one
+// figure for "the network".
+//
+// Every millisecond below is a full HTTPS request round trip to the
+// destination's nearest edge, measured with `no-cors`. That means the response
+// is opaque by construction: a completed request proves the edge answered and
+// how long it took, and says nothing whatsoever about what it answered. There is
+// no ICMP ping available to a web page and nothing here pretends otherwise.
+// ---------------------------------------------------------------------------
+
+export type WalkCategory = 'consumer' | 'business';
+
+export interface WalkTarget {
+ id: string;
+ label: string;
+ category: WalkCategory;
+ host: string;
+ /** Small, unauthenticated resource fetched to elicit the round trip. */
+ url: string;
+ /** What this endpoint actually is, shown on screen so a reader can judge the
+ * number rather than take the brand name for the whole service. */
+ note: string;
+}
+
+/** `no-response` covers a timeout, a refused connection, a failed name lookup
+ * and being out of range. A browser cannot separate them, so neither does this
+ * — and it is deliberately not called packet loss, which would imply an ICMP
+ * measurement that never happened. */
+export type WalkProbeOutcome = 'answered' | 'no-response';
+
+export interface WalkSample {
+ targetId: string;
+ waypointId: string;
+ /** 1-based round number. Every destination is probed once per round. */
+ round: number;
+ timestamp: number;
+ /** null when nothing came back — never 0, which would read as instant. */
+ roundTripMs: number | null;
+ outcome: WalkProbeOutcome;
+ /**
+ * True for the first probe against this destination in this run, which pays
+ * for DNS, TCP and TLS on top of the round trip. It is kept and counted, and
+ * excluded from the timing statistics, the same way the DNS benchmark
+ * discards its warm-up query.
+ */
+ connectionSetup: boolean;
+}
+
+/** A place you stood still for a while. */
+export interface WalkWaypoint {
+ id: string;
+ label: string;
+ startedAt: number;
+ endedAt: number | null;
+ /**
+ * What `navigator.connection` claimed at the moment the spot was created.
+ * Reported by the browser, not measured by NetReady — it is coarse, it is
+ * optional, and Safari and Firefox do not implement it at all. Recorded as
+ * context for a reader, never used to fill in a missing measurement.
+ */
+ reportedConnectionType: string | null;
+ reportedEffectiveType: string | null;
+}
+
+/** Timing statistics over a set of samples. Same shape as the DNS benchmark's
+ * summary, and for the same reason: the counts have to travel with the
+ * statistics or a median from two answers looks like a median from two
+ * hundred. */
+export interface WalkTimingSummary {
+ answered: number;
+ attempted: number;
+ /** Null below the minimum sample count — see `summariseSamples`. */
+ medianMs: number | null;
+ p95Ms: number | null;
+ minMs: number | null;
+ maxMs: number | null;
+ stdDevMs: number | null;
+}
+
+export interface WalkTargetStats {
+ targetId: string;
+ label: string;
+ category: WalkCategory;
+ summary: WalkTimingSummary;
+ /** Mean absolute change between consecutive answered probes. Null below two. */
+ jitterMs: number | null;
+ lastRoundTripMs: number | null;
+ lastOutcome: WalkProbeOutcome | null;
+}
+
+export interface WalkWaypointStats {
+ waypointId: string;
+ label: string;
+ attempted: number;
+ answered: number;
+ rounds: number;
+ perTarget: WalkTargetStats[];
+ /**
+ * Median of the per-destination medians, rather than a median over every
+ * sample pooled together. Pooling would make this figure move whenever a
+ * destination stopped answering, because the mix of destinations changed and
+ * not the network.
+ */
+ medianOfTargetMediansMs: number | null;
+ /** The destinations that contributed a median. Two spots are only comparable
+ * over the destinations that produced one at both, so the pool travels with
+ * the number. */
+ pooledTargetIds: string[];
+}
+
+export interface WalkTestResult {
+ id: string;
+ timestamp: number;
+ targets: WalkTarget[];
+ waypoints: WalkWaypoint[];
+ /** Most recent raw samples, capped for storage. Statistics are computed over
+ * every sample before truncation; `samplesDropped` says how many are absent
+ * from this record. */
+ samples: WalkSample[];
+ samplesDropped: number;
+ rounds: number;
+ perTarget: WalkTargetStats[];
+ perWaypoint: WalkWaypointStats[];
+ /** Plain-English findings. Empty when nothing was measured — silence is not a
+ * clean bill of health. */
+ conclusions: string[];
+ intervalMs: number;
+ durationMs: number;
+ failures: MeasurementFailure[];
+}
+
export interface HistoryItem {
id: string;
type:
@@ -700,7 +839,8 @@ export interface HistoryItem {
| 'triage'
| 'dualstack'
| 'captive'
- | 'dnsbench';
+ | 'dnsbench'
+ | 'walktest';
timestamp: number;
title: string;
summary: string;
diff --git a/src/utils/export.test.ts b/src/utils/export.test.ts
index 8ac3710..829397f 100644
--- a/src/utils/export.test.ts
+++ b/src/utils/export.test.ts
@@ -9,6 +9,7 @@ import {
generateDualStackCsv,
generateCaptivePortalCsv,
generateDnsBenchmarkCsv,
+ generateWalkTestCsv,
TEST_TYPES,
} from './export';
import type { HistoryItem } from '../types';
@@ -498,6 +499,165 @@ describe('generateDnsBenchmarkCsv', () => {
});
});
+describe('generateWalkTestCsv', () => {
+ const summary = (over: Record = {}) => ({
+ answered: 6,
+ attempted: 6,
+ medianMs: 42,
+ p95Ms: null,
+ minMs: 38,
+ maxMs: 51,
+ stdDevMs: 4,
+ ...over,
+ });
+
+ const cell = (targetId: string, over: Record = {}) => ({
+ targetId,
+ label: targetId === 'google' ? 'Google' : 'Microsoft 365',
+ category: targetId === 'google' ? 'consumer' : 'business',
+ summary: summary(),
+ jitterMs: 5,
+ lastRoundTripMs: 44,
+ lastOutcome: 'answered',
+ ...over,
+ });
+
+ const item = (over: Record = {}): HistoryItem => ({
+ id: 'walk_1',
+ type: 'walktest',
+ timestamp: 1,
+ title: 't',
+ summary: 's',
+ data: {
+ targets: [
+ {
+ id: 'google',
+ label: 'Google',
+ category: 'consumer',
+ host: 'www.google.com',
+ url: 'https://www.google.com/generate_204',
+ note: '',
+ },
+ {
+ id: 'm365',
+ label: 'Microsoft 365',
+ category: 'business',
+ host: 'outlook.office365.com',
+ url: 'https://outlook.office365.com/robots.txt',
+ note: '',
+ },
+ ],
+ waypoints: [
+ {
+ id: 'w1',
+ label: 'Desk',
+ startedAt: 1,
+ endedAt: 2,
+ reportedConnectionType: 'wifi',
+ reportedEffectiveType: '4g',
+ },
+ ],
+ samples: [],
+ samplesDropped: 0,
+ rounds: 7,
+ intervalMs: 3000,
+ durationMs: 21_000,
+ perTarget: [cell('google'), cell('m365')],
+ perWaypoint: [
+ {
+ waypointId: 'w1',
+ label: 'Desk',
+ attempted: 14,
+ answered: 13,
+ rounds: 7,
+ perTarget: [cell('google'), cell('m365')],
+ medianOfTargetMediansMs: 42,
+ pooledTargetIds: ['google', 'm365'],
+ },
+ ],
+ conclusions: ['Everything answered at the desk.'],
+ failures: [],
+ ...over,
+ },
+ });
+
+ it('writes every field under the name the interface actually uses', () => {
+ // The guard against the six-columns-silently-blank class of bug: rename a
+ // field in types.ts and either the cast stops compiling or this fails.
+ const rows = generateWalkTestCsv([item()]).split('\n');
+ expect(rows).toHaveLength(3); // header + one row per destination per spot
+ expect(rows[1]).toContain('"Desk"');
+ expect(rows[1]).toContain('"Google"');
+ expect(rows[1]).toContain('"www.google.com"');
+ expect(rows[1]).toContain('"42"');
+ expect(rows[2]).toContain('"Microsoft 365"');
+ expect(rows[2]).toContain('"outlook.office365.com"');
+ });
+
+ it('leaves an unmeasured destination blank rather than exporting it as zero', () => {
+ // A dead spot would otherwise become the fastest cell in the spreadsheet.
+ const dead = {
+ ...cell('m365'),
+ summary: summary({
+ answered: 0,
+ attempted: 6,
+ medianMs: null,
+ minMs: null,
+ maxMs: null,
+ stdDevMs: null,
+ }),
+ jitterMs: null,
+ lastRoundTripMs: null,
+ lastOutcome: 'no-response',
+ };
+ const csv = generateWalkTestCsv([
+ item({
+ perTarget: [cell('google'), dead],
+ perWaypoint: [
+ {
+ waypointId: 'w1',
+ label: 'Desk',
+ attempted: 12,
+ answered: 6,
+ rounds: 6,
+ perTarget: [cell('google'), dead],
+ medianOfTargetMediansMs: 42,
+ pooledTargetIds: ['google'],
+ },
+ ],
+ }),
+ ]);
+ const row = csv.split('\n')[2];
+
+ expect(row).toContain('"Microsoft 365"');
+ expect(row).toContain('"0","6"'); // answered / attempted survive
+ expect(row).not.toContain('"0","0"'); // but no timing became a zero
+ // Median, min, max, p95, std dev and jitter are all empty cells.
+ expect(row.split(',').filter((c) => c === '""').length).toBeGreaterThanOrEqual(6);
+ });
+
+ it('still exports a row for a walk that measured nothing', () => {
+ const csv = generateWalkTestCsv([
+ item({
+ rounds: 0,
+ perTarget: [],
+ perWaypoint: [],
+ conclusions: [],
+ failures: [
+ { metric: 'google', reason: 'not-attempted', detail: 'Google was not probed.' },
+ ],
+ }),
+ ]);
+ const row = csv.split('\n')[1];
+ expect(row).toContain('google: Google was not probed.');
+ });
+
+ it('routes through the dispatcher and is a declared export type', () => {
+ expect(getCsvForType([item()], 'walktest')).toContain('Desk');
+ expect(TEST_TYPES.map((t) => t.id)).toContain('walktest');
+ });
+});
+
describe('every declared export type produces a CSV with a header row', () => {
it.each(TEST_TYPES.map((t) => t.id))('%s', (type) => {
const csv = getCsvForType([], type);
diff --git a/src/utils/export.ts b/src/utils/export.ts
index 4c44a5e..49801bb 100644
--- a/src/utils/export.ts
+++ b/src/utils/export.ts
@@ -11,10 +11,12 @@ import type {
DnsBenchmarkResult,
CaptivePortalResult,
DnsIntegrityResult,
+ WalkTestResult,
} from '../types';
import type { TriageVerdict } from '../analysis/types';
export const TEST_TYPES = [
+ { id: 'walktest', label: 'Walk & Test Surveys', filename: 'walktest_results.csv', icon: 'Footprints' },
{ id: 'triage', label: 'Network Triage Verdicts', filename: 'triage_results.csv', icon: 'Stethoscope' },
{ id: 'dualstack', label: 'IPv4 / IPv6 Reachability', filename: 'dualstack_results.csv', icon: 'Network' },
{ id: 'dnsbench', label: 'DNS Resolver Benchmark', filename: 'dnsbench_results.csv', icon: 'Timer' },
@@ -768,6 +770,126 @@ export function generateDnsBenchmarkCsv(items: HistoryItem[]): string {
return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
}
+/**
+ * Walk & Test CSV — one row per spot per destination.
+ *
+ * That shape is deliberate: the point of a walk survey is comparing the same
+ * destination between two places, and a spreadsheet pivot over these rows gives
+ * exactly that. The whole-walk figures repeat on every row so a filtered view
+ * still carries its context.
+ *
+ * Every millisecond column is an HTTPS request round trip, and the headers say
+ * so. Absent figures are blank, never zero — a destination that never answered
+ * at a dead spot would otherwise export as the fastest cell in the file.
+ */
+export function generateWalkTestCsv(items: HistoryItem[]): string {
+ const headers = [
+ 'Test ID',
+ 'Timestamp',
+ 'Date',
+ 'Rounds',
+ 'Interval (s)',
+ 'Duration (s)',
+ 'Spot',
+ 'Spot Rounds',
+ 'Spot Answered',
+ 'Spot Attempted',
+ 'Spot Median of Destination Medians (ms)',
+ 'Browser-Reported Connection Type',
+ 'Browser-Reported Effective Type',
+ 'Destination',
+ 'Kind',
+ 'Host',
+ 'Median HTTPS Round Trip (ms)',
+ 'Min (ms)',
+ 'Max (ms)',
+ 'p95 (ms)',
+ 'Std Dev (ms)',
+ 'Jitter (ms)',
+ 'Answered',
+ 'Attempted',
+ 'Whole-Walk Median (ms)',
+ 'Whole-Walk Answered',
+ 'Whole-Walk Attempted',
+ 'Conclusions',
+ 'Not Measured',
+ ];
+
+ const rows: string[][] = [];
+
+ items
+ .filter((i) => i.type === 'walktest')
+ .forEach((item) => {
+ // Cast at the boundary so tsc checks these field names against the real
+ // interface, rather than letting a renamed field export as an empty column.
+ const d = (item.data ?? {}) as Partial;
+ const waypoints = d.waypoints ?? [];
+ const perWaypoint = d.perWaypoint ?? [];
+ const perTarget = d.perTarget ?? [];
+ const targets = d.targets ?? [];
+
+ const shared = [
+ escapeCsv(item.id),
+ escapeCsv(item.timestamp),
+ escapeCsv(new Date(item.timestamp).toLocaleString()),
+ escapeCsv(d.rounds ?? ''),
+ escapeCsv(d.intervalMs === undefined ? '' : d.intervalMs / 1000),
+ escapeCsv(d.durationMs === undefined ? '' : Math.round(d.durationMs / 1000)),
+ ];
+ const conclusions = escapeCsv((d.conclusions ?? []).join(' | '));
+ const notMeasured = escapeCsv(
+ (d.failures ?? []).map((f) => `${f.metric}: ${f.detail}`).join(' | '),
+ );
+
+ if (perWaypoint.length === 0 || perTarget.length === 0) {
+ // A walk that produced nothing still exports a row, so it stays
+ // distinguishable from a walk that never happened.
+ rows.push([...shared, ...Array(21).fill(escapeCsv('')), conclusions, notMeasured]);
+ return;
+ }
+
+ perWaypoint.forEach((spot) => {
+ const waypoint = waypoints.find((w) => w.id === spot.waypointId);
+ const spotCells = [
+ escapeCsv(spot.label),
+ escapeCsv(spot.rounds),
+ escapeCsv(spot.answered),
+ escapeCsv(spot.attempted),
+ escapeCsv(spot.medianOfTargetMediansMs ?? ''),
+ escapeCsv(waypoint?.reportedConnectionType ?? ''),
+ escapeCsv(waypoint?.reportedEffectiveType ?? ''),
+ ];
+
+ spot.perTarget.forEach((cell) => {
+ const overall = perTarget.find((t) => t.targetId === cell.targetId);
+ const target = targets.find((t) => t.id === cell.targetId);
+ rows.push([
+ ...shared,
+ ...spotCells,
+ escapeCsv(cell.label),
+ escapeCsv(cell.category),
+ escapeCsv(target?.host ?? ''),
+ escapeCsv(cell.summary.medianMs ?? ''),
+ escapeCsv(cell.summary.minMs ?? ''),
+ escapeCsv(cell.summary.maxMs ?? ''),
+ escapeCsv(cell.summary.p95Ms ?? ''),
+ escapeCsv(cell.summary.stdDevMs ?? ''),
+ escapeCsv(cell.jitterMs ?? ''),
+ escapeCsv(cell.summary.answered),
+ escapeCsv(cell.summary.attempted),
+ escapeCsv(overall?.summary.medianMs ?? ''),
+ escapeCsv(overall?.summary.answered ?? ''),
+ escapeCsv(overall?.summary.attempted ?? ''),
+ conclusions,
+ notMeasured,
+ ]);
+ });
+ });
+ });
+
+ return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
+}
+
/**
* Captive-portal / DNS-hijack CSV — one row per integrity probe.
*
@@ -891,6 +1013,8 @@ export function getCsvForType(items: HistoryItem[], type: string): string {
return generateDnsBenchmarkCsv(items);
case 'captive':
return generateCaptivePortalCsv(items);
+ case 'walktest':
+ return generateWalkTestCsv(items);
default:
return generateGenericCsv(items, type);
}
diff --git a/src/utils/walkTest.test.ts b/src/utils/walkTest.test.ts
new file mode 100644
index 0000000..cb975d6
--- /dev/null
+++ b/src/utils/walkTest.test.ts
@@ -0,0 +1,395 @@
+import { describe, expect, it } from 'vitest';
+import type { WalkSample, WalkTarget, WalkWaypoint } from '../types';
+import {
+ MAX_STORED_SAMPLES,
+ WALK_TARGETS,
+ buildWalkConclusions,
+ buildWalkResult,
+ createWaypoint,
+ medianOfTargetMedians,
+ summariseTarget,
+ summariseWaypoint,
+} from './walkTest';
+
+const target = (id: string, category: WalkTarget['category'] = 'consumer'): WalkTarget => ({
+ id,
+ label: id.toUpperCase(),
+ category,
+ host: `${id}.example`,
+ url: `https://${id}.example/robots.txt`,
+ note: 'test target',
+});
+
+const waypoint = (id: string, label = id): WalkWaypoint => ({
+ id,
+ label,
+ startedAt: 1_700_000_000_000,
+ endedAt: null,
+ reportedConnectionType: null,
+ reportedEffectiveType: null,
+});
+
+/** Builds samples for one target: `times` of null means no response. */
+const samplesFor = (
+ targetId: string,
+ waypointId: string,
+ times: (number | null)[],
+ startRound = 1,
+): WalkSample[] =>
+ times.map((ms, i) => ({
+ targetId,
+ waypointId,
+ round: startRound + i,
+ timestamp: 1_700_000_000_000 + i * 1000,
+ roundTripMs: ms,
+ outcome: ms === null ? ('no-response' as const) : ('answered' as const),
+ connectionSetup: i === 0 && startRound === 1,
+ }));
+
+describe('summariseTarget', () => {
+ it('reports no statistics at all below the minimum sample count', () => {
+ const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [50, 60]));
+
+ expect(stats.summary.attempted).toBe(2);
+ expect(stats.summary.answered).toBe(2);
+ // Two samples, one of which is the connection-setup probe, leaves one
+ // usable timing. Everything derived is absent, not small.
+ expect(stats.summary.medianMs).toBeNull();
+ expect(stats.summary.minMs).toBeNull();
+ expect(stats.summary.maxMs).toBeNull();
+ expect(stats.summary.stdDevMs).toBeNull();
+ });
+
+ it('excludes the connection-setup probe from the timings but not the counts', () => {
+ // The first sample carries DNS, TCP and TLS. If it leaked into the median
+ // the answer would be 60, not 40.
+ const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [900, 30, 40, 50]));
+
+ expect(stats.summary.attempted).toBe(4);
+ expect(stats.summary.answered).toBe(4);
+ expect(stats.summary.medianMs).toBe(40);
+ expect(stats.summary.maxMs).toBe(50);
+ });
+
+ it('counts unanswered probes without inventing a time for them', () => {
+ const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [20, 30, null, 40, 50]));
+
+ expect(stats.summary.attempted).toBe(5);
+ expect(stats.summary.answered).toBe(4);
+ expect(stats.summary.medianMs).toBe(40);
+ expect(stats.lastRoundTripMs).toBe(50);
+ });
+
+ it('keeps the last outcome even when the last probe failed', () => {
+ const stats = summariseTarget(target('a'), samplesFor('a', 'w1', [20, 30, 40, null]));
+
+ expect(stats.lastOutcome).toBe('no-response');
+ expect(stats.lastRoundTripMs).toBeNull();
+ });
+
+ it('returns an empty summary rather than zeros for a target with no samples', () => {
+ const stats = summariseTarget(target('a'), []);
+
+ expect(stats.summary).toEqual({
+ answered: 0,
+ attempted: 0,
+ medianMs: null,
+ p95Ms: null,
+ minMs: null,
+ maxMs: null,
+ stdDevMs: null,
+ });
+ expect(stats.jitterMs).toBeNull();
+ expect(stats.lastRoundTripMs).toBeNull();
+ expect(stats.lastOutcome).toBeNull();
+ });
+
+ it('gives no jitter from a single usable timing', () => {
+ expect(summariseTarget(target('a'), samplesFor('a', 'w1', [100, 50])).jitterMs).toBeNull();
+ });
+});
+
+describe('medianOfTargetMedians', () => {
+ it('is null when no destination produced a median', () => {
+ const stats = [
+ summariseTarget(target('a'), samplesFor('a', 'w1', [10])),
+ summariseTarget(target('b'), samplesFor('b', 'w1', [10])),
+ ];
+ expect(medianOfTargetMedians(stats)).toBeNull();
+ });
+
+ it('weights each destination equally rather than pooling raw samples', () => {
+ // 'a' answered far more often than 'b'. Pooling every sample would let 'a'
+ // dominate; the median of the two medians must sit between them.
+ const stats = [
+ summariseTarget(target('a'), samplesFor('a', 'w1', [0, 10, 10, 10, 10, 10, 10, 10, 10])),
+ summariseTarget(target('b'), samplesFor('b', 'w1', [0, 100, 100, 100])),
+ ];
+ expect(medianOfTargetMedians(stats)).toBe(55);
+ });
+
+ it('restricts the calculation to the requested pool', () => {
+ const stats = [
+ summariseTarget(target('a'), samplesFor('a', 'w1', [0, 10, 10, 10])),
+ summariseTarget(target('b'), samplesFor('b', 'w1', [0, 100, 100, 100])),
+ summariseTarget(target('c'), samplesFor('c', 'w1', [0, 900, 900, 900])),
+ ];
+ expect(medianOfTargetMedians(stats, ['a', 'b'])).toBe(55);
+ });
+});
+
+describe('summariseWaypoint', () => {
+ it('counts only the samples taken at that spot', () => {
+ const targets = [target('a'), target('b')];
+ const samples = [
+ ...samplesFor('a', 'w1', [0, 10, 10, 10]),
+ ...samplesFor('b', 'w1', [0, 20, 20, 20]),
+ ...samplesFor('a', 'w2', [500, 500, 500], 5),
+ ];
+
+ const w1 = summariseWaypoint(waypoint('w1'), targets, samples);
+ expect(w1.attempted).toBe(8);
+ expect(w1.answered).toBe(8);
+ expect(w1.rounds).toBe(4);
+ expect(w1.medianOfTargetMediansMs).toBe(15);
+ expect(w1.pooledTargetIds).toEqual(['a', 'b']);
+ });
+
+ it('reports a spot where a destination stopped answering', () => {
+ const targets = [target('a'), target('b')];
+ const samples = [
+ ...samplesFor('a', 'w2', [40, 40, 40], 5),
+ ...samplesFor('b', 'w2', [null, null, null], 5),
+ ];
+
+ const w2 = summariseWaypoint(waypoint('w2'), targets, samples);
+ expect(w2.attempted).toBe(6);
+ expect(w2.answered).toBe(3);
+ // Only 'a' has a median, so only 'a' is in the pool — the figure must not
+ // silently become "the median of everything that happened to answer".
+ expect(w2.pooledTargetIds).toEqual(['a']);
+ expect(w2.medianOfTargetMediansMs).toBe(40);
+ });
+});
+
+describe('buildWalkConclusions', () => {
+ it('says nothing at all when nothing was measured', () => {
+ const targets = [target('a')];
+ const perTarget = targets.map((t) => summariseTarget(t, []));
+ const perWaypoint = [summariseWaypoint(waypoint('w1'), targets, [])];
+
+ // The important half of this assertion is that it is not "all clear".
+ expect(buildWalkConclusions(perTarget, perWaypoint)).toEqual([]);
+ });
+
+ it('names destinations that never answered', () => {
+ const targets = [target('a'), target('b')];
+ const samples = [
+ ...samplesFor('a', 'w1', [10, 10, 10, 10]),
+ ...samplesFor('b', 'w1', [null, null, null, null]),
+ ];
+ const lines = buildWalkConclusions(
+ targets.map((t) => summariseTarget(t, samples)),
+ [summariseWaypoint(waypoint('w1'), targets, samples)],
+ );
+
+ expect(lines.join(' ')).toContain('B never answered');
+ expect(lines.join(' ')).not.toContain('A never answered');
+ });
+
+ it('compares spots only over destinations that produced a median at both', () => {
+ const targets = [target('a'), target('b'), target('c')];
+ const samples = [
+ // Two destinations answer everywhere and get slower at the far spot.
+ ...samplesFor('a', 'near', [0, 10, 10, 10]),
+ ...samplesFor('b', 'near', [0, 20, 20, 20]),
+ ...samplesFor('a', 'far', [200, 200, 200], 5),
+ ...samplesFor('b', 'far', [300, 300, 300], 5),
+ // 'c' only answers at the near spot, so it must not enter the comparison.
+ ...samplesFor('c', 'near', [0, 5, 5, 5]),
+ ...samplesFor('c', 'far', [null, null, null], 5),
+ ];
+ const perTarget = targets.map((t) => summariseTarget(t, samples));
+ const perWaypoint = [
+ summariseWaypoint(waypoint('near'), targets, samples),
+ summariseWaypoint(waypoint('far'), targets, samples),
+ ];
+
+ const text = buildWalkConclusions(perTarget, perWaypoint).join(' ');
+ // Paired over {a, b}: near = median(10, 20) = 15, far = median(200, 300) = 250.
+ // Including 'c' would drag the near figure to 10 and misstate the delta.
+ expect(text).toContain('lowest at “near” (15 ms)');
+ expect(text).toContain('highest at “far” (250 ms)');
+ expect(text).toContain('2 destinations');
+ });
+
+ it('declines to compare spots when too few destinations answered at all of them', () => {
+ const targets = [target('a'), target('b')];
+ const samples = [
+ ...samplesFor('a', 'near', [0, 10, 10, 10]),
+ ...samplesFor('b', 'near', [0, 20, 20, 20]),
+ ...samplesFor('a', 'far', [null, null, null], 5),
+ ...samplesFor('b', 'far', [200, 200, 200], 5),
+ ];
+ const perTarget = targets.map((t) => summariseTarget(t, samples));
+ const perWaypoint = [
+ summariseWaypoint(waypoint('near'), targets, samples),
+ summariseWaypoint(waypoint('far'), targets, samples),
+ ];
+
+ expect(buildWalkConclusions(perTarget, perWaypoint).join(' ')).toContain(
+ 'cannot be compared to each other',
+ );
+ });
+
+ it('blames the destination, not a spot, when the loss rate is the same everywhere', () => {
+ // A destination that is unreachable from anywhere loses probes at an
+ // identical rate at every spot. Naming whichever spot sorted first would
+ // send someone to re-survey a corridor over a problem that is not there.
+ const targets = [target('a'), target('b')];
+ const samples = [
+ ...samplesFor('a', 'near', [0, 10, 10, 10]),
+ ...samplesFor('b', 'near', [null, null, null, null]),
+ ...samplesFor('a', 'far', [12, 12, 12, 12], 5),
+ ...samplesFor('b', 'far', [null, null, null, null], 5),
+ ];
+ const perTarget = targets.map((t) => summariseTarget(t, samples));
+ const perWaypoint = [
+ summariseWaypoint(waypoint('near'), targets, samples),
+ summariseWaypoint(waypoint('far'), targets, samples),
+ ];
+
+ const text = buildWalkConclusions(perTarget, perWaypoint).join(' ');
+ expect(text).toContain('at much the same rate at every spot — B');
+ expect(text).not.toContain('had the most unanswered probes');
+ });
+
+ it('points at the spot with the most unanswered probes', () => {
+ const targets = [target('a')];
+ const samples = [
+ ...samplesFor('a', 'good', [0, 10, 10, 10]),
+ ...samplesFor('a', 'deadspot', [null, null, 30, null], 5),
+ ];
+ const perTarget = targets.map((t) => summariseTarget(t, samples));
+ const perWaypoint = [
+ summariseWaypoint(waypoint('good'), targets, samples),
+ summariseWaypoint(waypoint('deadspot'), targets, samples),
+ ];
+
+ expect(buildWalkConclusions(perTarget, perWaypoint).join(' ')).toContain(
+ '“deadspot” had the most unanswered probes: 3 of 4',
+ );
+ });
+});
+
+describe('buildWalkResult', () => {
+ const targets = [target('a'), target('b', 'business')];
+
+ it('produces an empty, honest record when no round ran', () => {
+ const result = buildWalkResult({
+ targets,
+ waypoints: [waypoint('w1')],
+ samples: [],
+ intervalMs: 3000,
+ durationMs: 0,
+ });
+
+ expect(result.rounds).toBe(0);
+ expect(result.conclusions).toEqual([]);
+ expect(result.perTarget.every((t) => t.summary.medianMs === null)).toBe(true);
+ expect(result.failures.map((f) => f.reason)).toEqual(['not-attempted', 'not-attempted']);
+ });
+
+ it('records a destination that answered nothing as unreachable, not as zero', () => {
+ const samples = [
+ ...samplesFor('a', 'w1', [0, 10, 10, 10]),
+ ...samplesFor('b', 'w1', [null, null, null, null]),
+ ];
+ const result = buildWalkResult({
+ targets,
+ waypoints: [waypoint('w1')],
+ samples,
+ intervalMs: 3000,
+ durationMs: 12_000,
+ });
+
+ const b = result.perTarget.find((t) => t.targetId === 'b');
+ expect(b?.summary.medianMs).toBeNull();
+ expect(b?.summary.answered).toBe(0);
+ expect(result.failures.find((f) => f.metric === 'b')?.reason).toBe('api-unreachable');
+ });
+
+ it('explains an absent median that came from too few answers', () => {
+ const samples = [...samplesFor('a', 'w1', [0, 10]), ...samplesFor('b', 'w1', [0, 10, 10, 10])];
+ const result = buildWalkResult({
+ targets,
+ waypoints: [waypoint('w1')],
+ samples,
+ intervalMs: 3000,
+ durationMs: 6000,
+ });
+
+ expect(result.failures.find((f) => f.metric === 'a')?.reason).toBe('insufficient-samples');
+ expect(result.failures.find((f) => f.metric === 'b')).toBeUndefined();
+ });
+
+ it('truncates stored samples loudly, and after the statistics are computed', () => {
+ const times = Array.from({ length: MAX_STORED_SAMPLES + 50 }, () => 40);
+ const samples = samplesFor('a', 'w1', times);
+ const result = buildWalkResult({
+ targets: [target('a')],
+ waypoints: [waypoint('w1')],
+ samples,
+ intervalMs: 2000,
+ durationMs: 1000,
+ });
+
+ expect(result.samples).toHaveLength(MAX_STORED_SAMPLES);
+ expect(result.samplesDropped).toBe(50);
+ // The count in the summary is the real one, not the truncated one.
+ expect(result.perTarget[0].summary.attempted).toBe(MAX_STORED_SAMPLES + 50);
+ });
+});
+
+describe('WALK_TARGETS', () => {
+ it('has unique ids and https endpoints only', () => {
+ const ids = WALK_TARGETS.map((t) => t.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ // A page served over https cannot open a plaintext connection at all, so a
+ // http: target here would be a permanently dead row.
+ expect(WALK_TARGETS.every((t) => t.url.startsWith('https://'))).toBe(true);
+ });
+
+ it('covers both consumer and business destinations', () => {
+ expect(WALK_TARGETS.some((t) => t.category === 'consumer')).toBe(true);
+ expect(WALK_TARGETS.some((t) => t.category === 'business')).toBe(true);
+ });
+
+ it('probes a terminal URL, not one that redirects', () => {
+ // A `no-cors` request must follow redirects — the browser rejects any other
+ // redirect mode outright — so a bouncing URL would fold two round trips into
+ // one number. This is a reminder to re-check by hand, not a network test.
+ const bouncers = ['https://outlook.office365.com/robots.txt', 'https://www.office.com/robots.txt'];
+ expect(WALK_TARGETS.every((t) => !bouncers.includes(t.url))).toBe(true);
+ });
+
+ it('probes each target on its declared host', () => {
+ for (const t of WALK_TARGETS) {
+ expect(new URL(t.url).hostname).toBe(t.host);
+ }
+ });
+
+ it('describes every target, so the disclosure list stays complete', () => {
+ expect(WALK_TARGETS.every((t) => t.note.trim().length > 0)).toBe(true);
+ });
+});
+
+describe('createWaypoint', () => {
+ it('numbers an unnamed spot rather than leaving it blank', () => {
+ expect(createWaypoint(' ', 3).label).toBe('Spot 3');
+ });
+
+ it('keeps a name the user typed', () => {
+ expect(createWaypoint(' Back bedroom ', 2).label).toBe('Back bedroom');
+ });
+});
diff --git a/src/utils/walkTest.ts b/src/utils/walkTest.ts
new file mode 100644
index 0000000..9051eff
--- /dev/null
+++ b/src/utils/walkTest.ts
@@ -0,0 +1,632 @@
+import type {
+ MeasurementFailure,
+ WalkSample,
+ WalkTarget,
+ WalkTargetStats,
+ WalkTestResult,
+ WalkTimingSummary,
+ WalkWaypoint,
+ WalkWaypointStats,
+} from '../types';
+import {
+ MIN_SAMPLES_FOR_SUMMARY,
+ createId,
+ meanConsecutiveDelta,
+ median,
+ summariseSamples,
+ timeoutSignal,
+} from './network';
+
+/**
+ * Walk & Test — repeated reachability probes to a fixed set of destinations,
+ * tagged with the spot you were standing in when they were taken.
+ *
+ * The idea is borrowed from Richard Astbury's Azure Speed Test, which times a
+ * fixed list of Azure regions over and over and lets the table settle. The
+ * difference here is that you are expected to move: you name a spot, let a few
+ * rounds run, walk somewhere else, name that spot, and at the end you have a
+ * per-spot comparison rather than one number for "the network".
+ *
+ * What each number actually is, stated once here and again on screen:
+ *
+ * - A **full HTTPS request round trip** from this browser to the destination's
+ * nearest edge. It is not an ICMP ping, and it is not comparable to one. A
+ * browser has no raw sockets, so there is no lower-level timing available.
+ * - Measured with `no-cors`, so the response is opaque by construction. A
+ * completed request proves the edge answered; it says nothing about *what*
+ * it answered. Reachability and timing only — never an HTTP status.
+ * - Redirects are followed, because a `no-cors` request is not permitted to do
+ * anything else: the Fetch spec pins redirect mode to `follow` for that mode
+ * and the browser rejects the call outright otherwise. That would let a
+ * redirect chain fold two round trips into one number, so every URL below
+ * was checked to return a terminal response — a 204, a 200, a 405 or a 401
+ * — rather than a bounce. (`outlook.office365.com/robots.txt` redirects to
+ * `/owa/robots.txt`; the probe therefore asks for the destination directly.)
+ * - The first sample against each destination pays for DNS, TCP and TLS. It is
+ * kept, flagged, and excluded from the summary statistics, in the same way
+ * the DNS benchmark discards its warm-up query.
+ *
+ * A round fires all destinations concurrently so every one of them describes the
+ * same instant — which matters when the measuring device is moving. The cost is
+ * that on a constrained link they compete with each other, which inflates all of
+ * them together. That trade is stated in the UI rather than hidden.
+ */
+
+/** Per-probe timeout. A destination that has not answered in this long is
+ * recorded as no-response; the round must not stall behind it. */
+export const PROBE_TIMEOUT_MS = 5000;
+
+/** Gap between rounds. Short enough to follow you across a building, long
+ * enough that the tool is not itself the load. */
+export const DEFAULT_INTERVAL_MS = 3000;
+
+export const INTERVAL_CHOICES = [2000, 3000, 5000, 10000] as const;
+
+/**
+ * Raw samples kept in the saved record.
+ *
+ * A long walk produces thousands, and `localStorage` is a few MB for the whole
+ * origin. Aggregates are computed over every sample before truncation and are
+ * never affected by it; the count that was dropped is stored and shown, because
+ * silent truncation is a lie by omission.
+ */
+export const MAX_STORED_SAMPLES = 500;
+
+/**
+ * The destinations.
+ *
+ * A combined consumer and business list, chosen so that a failure means
+ * something to whoever is holding the phone: five places people go at home and
+ * five that a workday stops without. Where a site publishes a purpose-built
+ * connectivity endpoint (Google's and YouTube's `generate_204`) that is used,
+ * because it is the cheapest thing those hosts serve. Everywhere else the probe
+ * is `/robots.txt`, which every one of these publishes, is a few hundred bytes,
+ * and is served from the same edge as the rest of the site.
+ *
+ * These are front doors — the CDN or front-end edge that terminates TLS near
+ * you. They are not the streaming, media or API backends those services use once
+ * you are logged in (Netflix Open Connect, the Teams media relays), and a
+ * browser cannot reach those at all. The UI says so.
+ *
+ * Requests carry no cookies: `credentials: 'omit'` is set on every probe, so
+ * these hosts see an IP and a TLS handshake, not a logged-in user.
+ */
+export const WALK_TARGETS: readonly WalkTarget[] = [
+ {
+ id: 'google',
+ label: 'Google',
+ category: 'consumer',
+ host: 'www.google.com',
+ url: 'https://www.google.com/generate_204',
+ note: 'Google’s own connectivity endpoint — an empty 204, the cheapest response it serves.',
+ },
+ {
+ id: 'youtube',
+ label: 'YouTube',
+ category: 'consumer',
+ host: 'www.youtube.com',
+ url: 'https://www.youtube.com/generate_204',
+ note: 'YouTube’s connectivity endpoint. The page edge, not the video CDN that serves streams.',
+ },
+ {
+ id: 'netflix',
+ label: 'Netflix',
+ category: 'consumer',
+ host: 'www.netflix.com',
+ url: 'https://www.netflix.com/robots.txt',
+ note: 'Netflix’s web front door. Playback runs on Open Connect appliances a browser cannot address.',
+ },
+ {
+ id: 'meta',
+ label: 'Facebook',
+ category: 'consumer',
+ host: 'www.facebook.com',
+ url: 'https://www.facebook.com/robots.txt',
+ note: 'Meta’s edge, which also fronts Instagram and WhatsApp Web.',
+ },
+ {
+ id: 'amazon',
+ label: 'Amazon',
+ category: 'consumer',
+ host: 'www.amazon.com',
+ url: 'https://www.amazon.com/robots.txt',
+ note: 'Amazon’s retail edge.',
+ },
+ {
+ id: 'm365',
+ label: 'Microsoft 365',
+ category: 'business',
+ host: 'outlook.office365.com',
+ url: 'https://outlook.office365.com/owa/robots.txt',
+ note:
+ 'The Exchange Online front door Outlook itself talks to, rather than the office.com portal. ' +
+ 'It answers an unauthenticated probe with a 401 from the edge, which is a round trip like ' +
+ 'any other.',
+ },
+ {
+ id: 'teams',
+ label: 'Microsoft Teams',
+ category: 'business',
+ host: 'teams.microsoft.com',
+ url: 'https://teams.microsoft.com/robots.txt',
+ note: 'The Teams signalling edge. Call audio and video use separate relays over UDP, out of reach here.',
+ },
+ {
+ id: 'zoom',
+ label: 'Zoom',
+ category: 'business',
+ host: 'zoom.us',
+ url: 'https://zoom.us/robots.txt',
+ note: 'Zoom’s web edge. Meeting media, again, goes somewhere a page cannot follow.',
+ },
+ {
+ id: 'salesforce',
+ label: 'Salesforce',
+ category: 'business',
+ host: 'login.salesforce.com',
+ url: 'https://login.salesforce.com/robots.txt',
+ note: 'The Salesforce login edge — the first thing an org hits every morning.',
+ },
+ {
+ id: 'slack',
+ label: 'Slack',
+ category: 'business',
+ host: 'slack.com',
+ url: 'https://slack.com/robots.txt',
+ note: 'Slack’s web edge. The message socket is a separate WebSocket host.',
+ },
+];
+
+/** Blank summary, used when a destination has produced nothing yet. Every
+ * statistic is null rather than zero: no samples is not "zero milliseconds". */
+export const EMPTY_SUMMARY: WalkTimingSummary = {
+ answered: 0,
+ attempted: 0,
+ medianMs: null,
+ p95Ms: null,
+ minMs: null,
+ maxMs: null,
+ stdDevMs: null,
+};
+
+/**
+ * One probe.
+ *
+ * Never rejects: a failure is a result. `no-response` covers a timeout, a DNS
+ * failure, a TLS failure, a blocked destination and being out of range, and a
+ * browser cannot tell those apart — so the field is called `outcome`, not
+ * `packetLoss`, and the UI does not claim to know which one happened.
+ */
+export async function probeWalkTarget(
+ target: WalkTarget,
+ options: {
+ round: number;
+ waypointId: string;
+ connectionSetup: boolean;
+ signal?: AbortSignal | undefined;
+ },
+): Promise {
+ const separator = target.url.includes('?') ? '&' : '?';
+ const url = `${target.url}${separator}_nr=${Date.now()}_${options.round}`;
+ const started = performance.now();
+ const gate = timeoutSignal(PROBE_TIMEOUT_MS, options.signal);
+
+ const base: Omit = {
+ targetId: target.id,
+ waypointId: options.waypointId,
+ round: options.round,
+ timestamp: Date.now(),
+ connectionSetup: options.connectionSetup,
+ };
+
+ try {
+ await fetch(url, {
+ method: 'HEAD',
+ mode: 'no-cors',
+ cache: 'no-store',
+ credentials: 'omit',
+ // No `redirect` option: `no-cors` requires the default `follow`, and
+ // Chrome rejects the call before it reaches the network otherwise. The
+ // targets are chosen to answer terminally — see the module comment.
+ signal: gate.signal,
+ });
+ return { ...base, roundTripMs: Math.round(performance.now() - started), outcome: 'answered' };
+ } catch {
+ return { ...base, roundTripMs: null, outcome: 'no-response' };
+ } finally {
+ gate.done();
+ }
+}
+
+export interface WalkLoopOptions {
+ targets?: readonly WalkTarget[];
+ intervalMs?: number;
+ /** Read at the start of every round, so moving to a new spot mid-walk tags
+ * subsequent rounds without restarting anything. */
+ currentWaypointId: () => string;
+ onRound: (samples: WalkSample[], round: number) => void;
+ signal: AbortSignal;
+}
+
+/**
+ * Runs rounds until the caller aborts.
+ *
+ * Deliberately not a React hook and not a class: the loop is the measurement,
+ * and keeping it here means the component cannot accidentally change what is
+ * measured by re-rendering.
+ */
+export async function runWalkLoop(options: WalkLoopOptions): Promise {
+ const targets = options.targets ?? WALK_TARGETS;
+ const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
+ const contacted = new Set();
+ let round = 0;
+
+ while (!options.signal.aborted) {
+ round += 1;
+ const waypointId = options.currentWaypointId();
+
+ const samples = await Promise.all(
+ targets.map((target) => {
+ const connectionSetup = !contacted.has(target.id);
+ contacted.add(target.id);
+ return probeWalkTarget(target, {
+ round,
+ waypointId,
+ connectionSetup,
+ signal: options.signal,
+ });
+ }),
+ );
+
+ if (options.signal.aborted) break;
+ options.onRound(samples, round);
+
+ // Interval, interruptible. A plain sleep would leave the stop button feeling
+ // broken for up to ten seconds on the slowest setting.
+ await new Promise((resolve) => {
+ const timer = setTimeout(finish, intervalMs);
+ function finish() {
+ clearTimeout(timer);
+ options.signal.removeEventListener('abort', finish);
+ resolve();
+ }
+ options.signal.addEventListener('abort', finish);
+ });
+ }
+}
+
+/** Samples that count towards timing statistics: answered, and not the
+ * connection-setup sample that carries DNS, TCP and TLS inside it. */
+const steadyStateTimings = (samples: readonly WalkSample[]): number[] =>
+ samples
+ .filter((s) => s.outcome === 'answered' && !s.connectionSetup && s.roundTripMs !== null)
+ .map((s) => s.roundTripMs as number);
+
+/**
+ * Per-destination statistics over a set of samples.
+ *
+ * `attempted` and `answered` count every sample including the setup one — a
+ * destination that answered only its first probe and nothing since should not
+ * read as though it was never tried. The timing columns exclude it.
+ */
+export function summariseTarget(
+ target: WalkTarget,
+ samples: readonly WalkSample[],
+): WalkTargetStats {
+ const mine = samples.filter((s) => s.targetId === target.id);
+ const timings = steadyStateTimings(mine);
+ const summary = summariseSamples(timings);
+ const last = mine.length > 0 ? mine[mine.length - 1] : null;
+
+ return {
+ targetId: target.id,
+ label: target.label,
+ category: target.category,
+ summary: {
+ answered: mine.filter((s) => s.outcome === 'answered').length,
+ attempted: mine.length,
+ medianMs: summary.medianMs,
+ p95Ms: summary.p95Ms,
+ minMs: summary.minMs,
+ maxMs: summary.maxMs,
+ stdDevMs: summary.stdDevMs,
+ },
+ // Jitter over consecutive answered probes. Null below two samples, because
+ // the variation across a single measurement is not small — it is absent.
+ jitterMs: meanConsecutiveDelta(timings),
+ lastRoundTripMs: last === null ? null : last.roundTripMs,
+ lastOutcome: last === null ? null : last.outcome,
+ };
+}
+
+/**
+ * Median of the per-destination medians, over an explicit pool.
+ *
+ * Pooling every raw sample instead would make this figure move when a
+ * destination drops out, because the mix of destinations changed rather than
+ * the network. Taking one median per destination and then a median of those
+ * keeps every destination weighted equally, and the pool it was computed over
+ * travels with the number so two spots are only ever compared over the
+ * destinations that produced a median at both.
+ */
+export function medianOfTargetMedians(
+ stats: readonly WalkTargetStats[],
+ pool?: readonly string[],
+): number | null {
+ const eligible = stats.filter(
+ (s) => s.summary.medianMs !== null && (pool === undefined || pool.includes(s.targetId)),
+ );
+ if (eligible.length === 0) return null;
+ const value = median(eligible.map((s) => s.summary.medianMs as number));
+ return value === null ? null : Math.round(value);
+}
+
+/** Per-spot statistics. */
+export function summariseWaypoint(
+ waypoint: WalkWaypoint,
+ targets: readonly WalkTarget[],
+ samples: readonly WalkSample[],
+): WalkWaypointStats {
+ const mine = samples.filter((s) => s.waypointId === waypoint.id);
+ const perTarget = targets.map((t) => summariseTarget(t, mine));
+
+ return {
+ waypointId: waypoint.id,
+ label: waypoint.label,
+ attempted: mine.length,
+ answered: mine.filter((s) => s.outcome === 'answered').length,
+ rounds: new Set(mine.map((s) => s.round)).size,
+ perTarget,
+ medianOfTargetMediansMs: medianOfTargetMedians(perTarget),
+ pooledTargetIds: perTarget
+ .filter((s) => s.summary.medianMs !== null)
+ .map((s) => s.targetId),
+ };
+}
+
+/**
+ * How much worse one spot's loss rate has to be than the best spot's before the
+ * conclusions single it out: five percentage points.
+ *
+ * A judgement, not a measurement. Below it the spots are treated as losing
+ * probes at the same rate, which is the honest reading of a destination that is
+ * unreachable from everywhere rather than of a bad corner.
+ */
+export const LOSS_RATE_DIFFERENCE = 0.05;
+
+/** Ordinal for "N of M", written out so a reader is not left to divide. */
+const missed = (stats: { answered: number; attempted: number }): number =>
+ stats.attempted - stats.answered;
+
+/**
+ * Plain-English findings, in the spirit of GRC's "Conclusions" tab.
+ *
+ * Pure and total: no samples means an empty list, never "everything looks
+ * fine". Silence here is silence, not a clean bill of health — the same rule the
+ * rules engine applies to `no-fault-found`.
+ */
+export function buildWalkConclusions(
+ perTarget: readonly WalkTargetStats[],
+ perWaypoint: readonly WalkWaypointStats[],
+): string[] {
+ const lines: string[] = [];
+ const totalAttempted = perTarget.reduce((sum, t) => sum + t.summary.attempted, 0);
+ if (totalAttempted === 0) return lines;
+
+ // 1. Destinations that never answered at all.
+ const silent = perTarget.filter((t) => t.summary.attempted > 0 && t.summary.answered === 0);
+ if (silent.length > 0) {
+ lines.push(
+ `${silent.map((t) => t.label).join(', ')} never answered during this walk. That is not ` +
+ 'proof the destination is down — a browser cannot separate a blocked host, a failed ' +
+ 'name lookup and a dropped connection — but every other destination was tried the same ' +
+ 'way at the same moments.',
+ );
+ }
+
+ // 2. Destinations that answered sometimes.
+ const intermittent = perTarget
+ .filter((t) => t.summary.answered > 0 && missed(t.summary) > 0)
+ .sort((a, b) => missed(b.summary) - missed(a.summary));
+ if (intermittent.length > 0) {
+ const worst = intermittent[0];
+ lines.push(
+ `${worst.label} missed ${missed(worst.summary)} of ${worst.summary.attempted} probes` +
+ (intermittent.length > 1
+ ? `, and ${intermittent.length - 1} other destination${
+ intermittent.length === 2 ? '' : 's'
+ } dropped probes too.`
+ : '.'),
+ );
+ }
+
+ // 3. Spot-to-spot comparison, paired over the destinations that produced a
+ // median at both spots. Comparing unpaired figures would report a change in
+ // which destinations answered as though it were a change in latency.
+ const comparable = perWaypoint.filter((w) => w.pooledTargetIds.length > 0);
+ if (comparable.length >= 2) {
+ let best: { label: string; value: number } | null = null;
+ let worst: { label: string; value: number } | null = null;
+ let poolSize = 0;
+
+ const shared = comparable.reduce(
+ (pool, w) => pool.filter((id) => w.pooledTargetIds.includes(id)),
+ [...comparable[0].pooledTargetIds],
+ );
+
+ if (shared.length >= 2) {
+ poolSize = shared.length;
+ for (const w of comparable) {
+ const value = medianOfTargetMedians(w.perTarget, shared);
+ if (value === null) continue;
+ if (best === null || value < best.value) best = { label: w.label, value };
+ if (worst === null || value > worst.value) worst = { label: w.label, value };
+ }
+ }
+
+ if (best !== null && worst !== null && best.label !== worst.label) {
+ lines.push(
+ `Round trips were lowest at “${best.label}” (${best.value} ms) and highest at ` +
+ `“${worst.label}” (${worst.value} ms), comparing the ${poolSize} destinations that ` +
+ 'produced a median at every spot. Each figure is the median of those destinations’ ' +
+ 'own medians, so it does not move when a destination drops out.',
+ );
+ } else if (shared.length >= 2) {
+ lines.push(
+ `Every spot produced the same median round trip across the ${poolSize} destinations ` +
+ 'measured at all of them, so nothing here separates them.',
+ );
+ } else {
+ lines.push(
+ 'The spots cannot be compared to each other: fewer than two destinations produced a ' +
+ 'median at every one of them. Spend longer at each spot, or drop the ones that are ' +
+ 'not answering.',
+ );
+ }
+ }
+
+ // 4. Where the unanswered probes were.
+ //
+ // Naming the worst spot is only meaningful when the spots actually differ. A
+ // destination that is unreachable everywhere produces an identical loss rate
+ // at every spot, and pointing at whichever one happened to sort first would
+ // send someone to re-survey a corridor over a problem that has nothing to do
+ // with where they were standing.
+ const probed = perWaypoint.filter((w) => w.attempted > 0);
+ const totalMissed = probed.reduce((sum, w) => sum + missed(w), 0);
+ if (totalMissed > 0) {
+ const rate = (w: WalkWaypointStats): number => missed(w) / w.attempted;
+ const ranked = [...probed].sort((a, b) => rate(b) - rate(a));
+ const worst = ranked[0];
+ const spread = rate(worst) - rate(ranked[ranked.length - 1]);
+
+ // Which destinations account for the misses, so the reader chases the right
+ // thing — a bad corner and a dead destination are different problems.
+ const blameFor = (stats: readonly WalkTargetStats[]): string => {
+ const names = stats
+ .filter((t) => missed(t.summary) > 0)
+ .sort((a, b) => missed(b.summary) - missed(a.summary))
+ .map((t) => t.label);
+ return names.length === 0 ? '' : ` — ${names.join(', ')}`;
+ };
+
+ if (probed.length >= 2 && spread >= LOSS_RATE_DIFFERENCE) {
+ lines.push(
+ `“${worst.label}” had the most unanswered probes: ${missed(worst)} of ` +
+ `${worst.attempted}${blameFor(worst.perTarget)}. On a walk test that is usually the ` +
+ 'signal worth chasing — a spot where connections stop completing, rather than one ' +
+ 'where they are merely slower.',
+ );
+ } else {
+ const totalAttempted = probed.reduce((sum, w) => sum + w.attempted, 0);
+ lines.push(
+ `${totalMissed} of ${totalAttempted} probes went unanswered, at much the same rate at ` +
+ `every spot${blameFor(perTarget)}. That points at the destination rather than at where ` +
+ 'you were standing.',
+ );
+ }
+ }
+
+ return lines;
+}
+
+/**
+ * Assembles the finished record from everything collected during the walk.
+ *
+ * Pure: it takes the samples and returns the result, so the whole aggregation
+ * path is testable without a network or a clock. The caller supplies the elapsed
+ * time it measured.
+ */
+export function buildWalkResult(input: {
+ targets: readonly WalkTarget[];
+ waypoints: readonly WalkWaypoint[];
+ samples: readonly WalkSample[];
+ intervalMs: number;
+ durationMs: number;
+}): WalkTestResult {
+ const { targets, waypoints, samples, intervalMs, durationMs } = input;
+ const perTarget = targets.map((t) => summariseTarget(t, samples));
+ const perWaypoint = waypoints.map((w) => summariseWaypoint(w, targets, samples));
+ const rounds = new Set(samples.map((s) => s.round)).size;
+
+ const failures: MeasurementFailure[] = [];
+ for (const stats of perTarget) {
+ if (stats.summary.attempted === 0) {
+ failures.push({
+ metric: stats.targetId,
+ reason: 'not-attempted',
+ detail: `${stats.label} was not probed during this walk.`,
+ });
+ continue;
+ }
+ if (stats.summary.answered === 0) {
+ failures.push({
+ metric: stats.targetId,
+ reason: 'api-unreachable',
+ detail:
+ `${stats.label} did not answer any of its ${stats.summary.attempted} probes, so it has ` +
+ 'no round-trip time. A browser cannot tell a blocked destination from an unreachable ' +
+ 'one.',
+ });
+ continue;
+ }
+ if (stats.summary.medianMs === null) {
+ failures.push({
+ metric: stats.targetId,
+ reason: 'insufficient-samples',
+ detail:
+ `${stats.label} produced ${stats.summary.answered} answer` +
+ `${stats.summary.answered === 1 ? '' : 's'}, and the first one against each ` +
+ `destination is set aside because it includes DNS and TLS setup. At least ` +
+ `${MIN_SAMPLES_FOR_SUMMARY} later answers are needed before a median is reported.`,
+ });
+ }
+ }
+
+ // Newest samples are the ones worth keeping: a walk ends where the problem is.
+ const kept = samples.slice(Math.max(0, samples.length - MAX_STORED_SAMPLES));
+
+ return {
+ id: createId('walktest'),
+ timestamp: Date.now(),
+ targets: [...targets],
+ waypoints: [...waypoints],
+ samples: kept,
+ samplesDropped: samples.length - kept.length,
+ rounds,
+ perTarget,
+ perWaypoint,
+ conclusions: buildWalkConclusions(perTarget, perWaypoint),
+ intervalMs,
+ durationMs,
+ failures,
+ };
+}
+
+/** What `navigator.connection` reports, if anything. Reported by the browser
+ * rather than measured, labelled as such everywhere it is shown, and never
+ * used to fill in a missing measurement. */
+export function readReportedConnection(): {
+ reportedConnectionType: string | null;
+ reportedEffectiveType: string | null;
+} {
+ const conn = (navigator as unknown as { connection?: { type?: string; effectiveType?: string } })
+ .connection;
+ return {
+ reportedConnectionType: conn?.type ?? null,
+ reportedEffectiveType: conn?.effectiveType ?? null,
+ };
+}
+
+/** A new spot. Blank names get a numbered placeholder rather than an empty
+ * column header. */
+export function createWaypoint(label: string, index: number): WalkWaypoint {
+ const trimmed = label.trim();
+ return {
+ id: createId('waypoint'),
+ label: trimmed.length > 0 ? trimmed : `Spot ${index}`,
+ startedAt: Date.now(),
+ endedAt: null,
+ ...readReportedConnection(),
+ };
+}