From 619f26c31476082f3f8776c308b6e347e8b079ba Mon Sep 17 00:00:00 2001 From: Perry Dale Date: Sun, 23 Aug 2026 00:37:43 +0000 Subject: [PATCH] Add Walk & Test: repeated probes to ten destinations, tagged by spot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A survey tool rather than a snapshot one. Ten destinations people actually depend on — Google, YouTube, Netflix, Facebook, Amazon, Microsoft 365, Teams, Zoom, Salesforce, Slack — are probed once per round while you walk the building, and each round is tagged with the spot you named when you got there. The deliverable is the per-spot comparison, not one number for "the network". Shape borrowed from Richard Astbury's Azure Speed Test, which probes a fixed list until the table settles; this one expects you to move. What the numbers are, and the traps avoided: - Every figure is a full HTTPS request round trip to the destination's edge, measured no-cors. Not a ping, and the response is opaque by construction, so reachability and timing are the whole claim. - `redirect: 'manual'` is invalid in no-cors mode — Chrome rejects the call before it reaches the network. A browser run caught it failing every probe; targets are now URLs verified to answer terminally, and outlook.office365.com/robots.txt was swapped for its /owa/ target, which 401s directly instead of bouncing. - Each destination's first probe carries DNS, TCP and TLS. It is counted but kept out of the timing statistics, as the DNS benchmark does with its warm-up query. - Spots are compared by the median of each destination's own median, paired over only the destinations that produced a median at every spot. Pooling raw samples would report a change in which destinations answered as though it were a change in latency. - A destination unreachable everywhere loses probes at the same rate at every spot, so the conclusions blame the destination instead of singling out whichever spot sorted first. - Nothing is reported that was not measured: unanswered is null, medians need enough samples, an empty walk produces no conclusions at all, and storage truncation is stated rather than silent. Offline in DevTools every cell reads "—" with a reason, verified in Chromium along with a simulated two-spot walk and the mobile layout. Third parties disclosed in PrivacySafetyModal and the README table in the same commit; CSV export, history and the export bundle all wired up. --- README.md | 39 ++ src/App.tsx | 3 + src/components/Dashboard.tsx | 29 + src/components/ExportPage.tsx | 3 + src/components/Navbar.tsx | 2 + src/components/PrivacySafetyModal.tsx | 12 + src/components/WalkTest.tsx | 868 ++++++++++++++++++++++++++ src/types.ts | 142 ++++- src/utils/export.test.ts | 160 +++++ src/utils/export.ts | 124 ++++ src/utils/walkTest.test.ts | 395 ++++++++++++ src/utils/walkTest.ts | 632 +++++++++++++++++++ 12 files changed, 2408 insertions(+), 1 deletion(-) create mode 100644 src/components/WalkTest.tsx create mode 100644 src/utils/walkTest.test.ts create mode 100644 src/utils/walkTest.ts 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(), + }; +}