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 */} +
setActiveTab('walktest')} + className="group bg-gradient-to-br from-teal-950/50 via-slate-900 to-slate-900 border border-teal-500/40 hover:border-teal-400 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-xl hover:shadow-teal-500/10 hover:-translate-y-0.5" + > +
+ +
+
+

+ Walk & Test +

+ + New + +
+

+ 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. +

+
+
+ +
+ {isWalking ? ( + + ) : ( + + )} + + + + {isWalking && ( + + + round {round} · {totalAnswered} of {totalAttempted} probes answered + + )} +
+ + {/* The one control that has to be reachable one-handed, mid-walk. */} + {isWalking && ( +
+ setSpotName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') openSpot(spotName); + }} + placeholder="Where are you now? e.g. back bedroom" + className="flex-1 min-w-[14rem] bg-slate-800 border border-slate-700 rounded-xl px-3 py-2.5 text-sm text-slate-200 placeholder:text-slate-600 focus:outline-none focus:border-teal-500/60" + /> + + {currentSpot !== null && ( + + measuring “{currentSpot.label}” + {currentSpot.reportedEffectiveType !== null && ( + + {' '} + · browser says {currentSpot.reportedEffectiveType} + + )} + + )} +
+ )} + + {!navigator.onLine && ( +
+ + + 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 + + + + 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]' : ''}> + + + + + + + + + ); + })} + +
MinMaxJitter
+
+ {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. +

+
+ )} + + {perWaypoint.length > 0 && hasData && ( +
+
+

Spot by 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. +

+
+
+ + + + + + + + {targets.map((t) => ( + + ))} + + + + {perWaypoint.map((w) => { + const unanswered = w.attempted - w.answered; + return ( + + + + + + {targets.map((t) => { + const cell = w.perTarget.find((p) => p.targetId === t.id); + return ( + + ); + })} + + ); + })} + +
SpotRoundsAnsweredOverall + {t.label} +
+
{w.label}
+ {waypoints.find((p) => p.id === w.waypointId)?.reportedEffectiveType !== + null && ( +
+ browser reported{' '} + {waypoints.find((p) => p.id === w.waypointId)?.reportedEffectiveType} +
+ )} +
+ {`${w.rounds}`} + + 0 ? 'text-amber-300' : 'text-slate-400'}> + {`${w.answered} / ${w.attempted}`} + + + + + +
+
+
+ )} + + {conclusions.length > 0 && ( +
+
+ {isWalking ? 'Conclusions so far' : 'Conclusions'} +
+
    + {conclusions.map((line) => ( +
  • + › + {line} +
  • + ))} +
+
+ )} + + {result !== null && ( + <> +
+
+ Walk finished: {formatDuration(result.durationMs)} across {result.waypoints.length}{' '} + spot{result.waypoints.length === 1 ? '' : 's'}, {result.rounds} round + {result.rounds === 1 ? '' : 's'}, {displayMetric(result.rounds * targets.length)}{' '} + probes. +
+ {result.samplesDropped > 0 && ( +
+ {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(), + }; +}