Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ thing this tool has that mainstream speed tests do not.
boundary and let tsc check it. Every CSV generator has a test — keep it that way.
- **Leaflet `divIcon({html})` and `bindPopup(html)` are `innerHTML` sinks.** They were
fed raw user hostnames and third-party GeoIP strings, which was live XSS. Build DOM
nodes and set `textContent`. See `buildHopPopup` / `buildPopup`.
nodes and set `textContent`. See `buildPopup`.
- **CSV needs formula-injection escaping**, not just quoting. `escapeCsv` prefixes
`=`, `+`, `-`, `@`, tab and CR. Always use it; never hand-roll quoting.
- **Third parties must stay disclosed.** `THIRD_PARTY_DISCLOSURES` in
Expand Down Expand Up @@ -130,9 +130,11 @@ conclusions rather than reporting numbers, so it has its own version of the rule

## Verification

Run `npm run check`. For anything touching measurement or rendering, also drive the real
app — `npm run build && npm run preview`, then Playwright against `127.0.0.1:4173`
(Chromium at `/opt/pw-browsers/chromium`).
Run `npm run check`. For anything touching measurement or rendering, also run
`npm run test:browser` (builds the app and drives Chromium through Playwright).
Install Chromium once with `npx playwright install chromium`. The browser checks cover
dashboard launches, adjacent controls, history preservation, GeoIP missing fields and
offline speed measurements; supplement them when changing other measurement paths.

**The regression test that matters most:** go offline in DevTools and run every tool.
Every metric must read `—` with a reason. A number appearing anywhere is a P0.
Expand Down
35 changes: 11 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,18 +42,11 @@ Two rules govern it, and both are tested:
`indeterminate` — explicitly *not* "your network is fine". A run that measured nothing concludes
nothing.

### 🎯 Bottleneck attribution
### Dashboard quick launches

The dashboard headline now names the *binding constraint* rather than showing four flat bars:

> **Grade B — the binding constraint is bufferbloat, not bandwidth.** Latency rose from 18 ms idle
> to 80 ms under load, an increase of 62 ms.

It is computed by sensitivity analysis over the existing scoring function: each measured input is
lifted, one at a time, to a level past which it stops limiting the result, and whichever lift moves
the score furthest is the constraint. Those comparison values live inside the calculation and are
never reported as measurements — a test asserts they cannot leak into the output. Severe bufferbloat
overrides the ranking, because the numeric score does not model it and the user's experience does.
Each tool has a card with its input fields and a run button. Submit a target to open the
corresponding tool and start it with those values; use Open tool to configure it first.
Recent diagnostics stored in this browser appear below the cards.

### 🌍 Dual-stack (IPv4 / IPv6) reachability

Expand Down Expand Up @@ -220,22 +213,15 @@ Everything a browser can genuinely observe about the path to a host:
reported rather than shown as zeros.
- **Which CDN edge answered**, by IATA code, resolved against a bundled airport table to a real
coordinate. An unknown code is flagged, not guessed.
- **HTTP/3 negotiation** — if every h3-capable origin falls back to HTTP/2, that is direct evidence
UDP/443 is blocked upstream by a firewall or middlebox.
- **HTTP/3 negotiation** — report the protocols actually negotiated. Missing HTTP/3 does not
establish a blocked UDP/443 path: browser settings, server support and protocol discovery can
also explain it.
- **Latency horizon** — light travels ~200 km/ms in fibre, so a round trip puts a hard ceiling on
how far away a server can be. Drawn as a constraint circle: the endpoint is somewhere inside it.
This is a proof, not an estimate — queuing delay only loosens the bound.

### 12. 🗺️ Route Model *(simulated — read this)*
Resolves a target, looks up its real location, and draws a plausible great-circle path to it.

**The intermediate hops are generated, not measured.** Browsers cannot send ICMP packets or set an
IP TTL, so no web page can perform a real traceroute. The first and last hops are grounded in a real
DNS resolution and a real geolocation lookup; everything between them is illustrative. Exports mark
these records as simulated. Use the Edge Path Explorer above for measurements you can rely on.

### 13. 💾 History & Export
Results persist in `localStorage`. Search, filter, inspect raw JSON, and export per-tool CSVs, a
### 12. 💾 History & Export
Results persist in `localStorage`. Legacy simulated route records are excluded from the UI and exports; their stored data is preserved. Search, filter, inspect raw JSON, and export per-tool CSVs, a
master summary, or a bundled ZIP with a manifest.

---
Expand All @@ -253,6 +239,7 @@ npm run dev # http://localhost:5173
npm run build # static bundle in dist/
npm run preview
npm run check # typecheck + lint + tests
npm run test:browser # build + browser regression checks (requires Playwright Chromium)
```

There is no server component. `npm run build` emits static files that can be hosted anywhere.
Expand All @@ -275,7 +262,7 @@ without touching it, so the following go directly from your browser to third par
| `cloudflare-dns.com`, `dns.google` | Every domain you resolve, over encrypted DoH |
| `cloudflare-dns.com`, `dns.google`, `dns.quad9.net`, `dns10.quad9.net`, `freedns.controld.com`, `doh.sb`, `public.dns.iij.jp` | Your IP and every name the DNS benchmark queries — ~20 each, most randomly generated, which identifies the run to each provider |
| `dnssec-failed.org`, `internetsociety.org` | Not contacted; their names are the DNSSEC test pair sent to the resolvers above |
| `ipwho.is`, `ipapi.co`, `freeipapi.com` | Your public IP on opening the GeoIP tool, and every IP or domain you look up |
| `ipwho.is`, `ipapi.co` | Your public IP when you run the GeoIP lookup, and every IP or domain you look up |
| `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` |
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"test": "vitest run",
"test:watch": "vitest",
"check": "npm run typecheck && npm run lint && npm run test",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"test:browser": "npm run build && node scripts/browser-check.mjs"
},
"dependencies": {
"jszip": "^3.10.1",
Expand Down
154 changes: 154 additions & 0 deletions scripts/browser-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { chromium } from 'playwright';

// Real UI checks with controlled probe responses; never contact diagnostic targets.
const origin = 'http://127.0.0.1:4175';
const server = spawn(process.execPath, ['node_modules/vite/bin/vite.js', 'preview', '--host', '127.0.0.1', '--port', '4175', '--strictPort'], { stdio: 'pipe' });
let serverOutput = '';
server.stdout.on('data', chunk => { serverOutput += chunk; });
server.stderr.on('data', chunk => { serverOutput += chunk; });
let browser;
const errors = [];
try {
for (let attempt = 0; ; attempt++) {
try { if ((await fetch(origin)).ok) break; } catch { /* server is starting */ }
if (attempt > 100 || server.exitCode !== null) throw new Error(`Preview failed: ${serverOutput}`);
await new Promise(resolve => setTimeout(resolve, 100));
}
browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
await context.addInitScript(() => {
if (window.top !== window) return;
localStorage.setItem('netready_responsible_networking_accepted', 'true');
if (!localStorage.getItem('netready_history_v1')) localStorage.setItem('netready_history_v1', JSON.stringify([
{ id: 'old-route', type: 'tracert', timestamp: 1, title: 'Legacy simulated route', summary: 'Invented hops', data: {} },
{ id: 'saved-dns', type: 'dns', timestamp: 2, title: 'Saved measured DNS', summary: 'Saved diagnostic', data: {} },
]));
});
const page = await context.newPage();
page.on('pageerror', error => errors.push(error.message));
const requests = [];
let geoResponse = { ip: '203.0.113.2', city: 'Example City', success: true };
await context.route('**/*', async route => {
const url = route.request().url();
if (url.startsWith(origin)) return route.continue();
requests.push(url);
if (url.includes('cloudflare-dns.com/dns-query') || url.includes('dns.google/resolve')) return route.fulfill({ json: { Status: 0, Answer: [{ name: 'example.com.', type: 1, TTL: 60, data: '203.0.113.2' }] }, headers: { 'access-control-allow-origin': '*' } });
if (url.startsWith('https://ipwho.is/')) return route.fulfill({ json: geoResponse, headers: { 'access-control-allow-origin': '*' } });
return route.abort();
});
const dashboard = async () => {
await page.getByRole('button', { name: 'Dashboard', exact: true }).click();
await page.getByRole('heading', { name: 'Recent diagnostic history' }).waitFor();
};
const card = title => page.locator('article').filter({ has: page.getByRole('heading', { name: title, exact: true }) });
await page.goto(origin);
assert.equal(await page.locator('article').count(), 18);
assert.equal(await page.getByText('Network Readiness Score', { exact: true }).count(), 0);
assert.equal(await page.getByText('Route Model', { exact: true }).count(), 0);
assert.equal(await page.getByText('Legacy simulated route', { exact: true }).count(), 0);
assert.equal(await page.getByText('Saved measured DNS', { exact: true }).count(), 1);

await card('DNS-over-HTTPS Resolver').getByLabel('Domain', { exact: true }).fill('example.com');
await card('DNS-over-HTTPS Resolver').getByRole('button', { name: 'Resolve', exact: true }).click();
await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.data?.domain === 'example.com'));
assert.equal(requests.filter(url => url.includes('name=example.com')).length, 1, 'launch runs DNS exactly once');
assert.equal(await page.locator('main input[type=text]').inputValue(), 'example.com');
assert.equal(await page.evaluate(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.id === 'old-route')), true, 'old records preserved in storage');
await dashboard();
const beforeOpen = requests.length;
await card('DNS-over-HTTPS Resolver').getByRole('button', { name: 'Open tool', exact: true }).click();
await page.waitForTimeout(150);
assert.equal(requests.length, beforeOpen, 'Open tool does not reuse a launch');
await dashboard();
await card('DNS-over-HTTPS Resolver').getByLabel('Domain', { exact: true }).fill('second.example');
await card('DNS-over-HTTPS Resolver').getByRole('button', { name: 'Resolve', exact: true }).click();
await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.data?.domain === 'second.example'));
assert.equal(requests.filter(url => url.includes('name=second.example')).length, 1);

await dashboard();
await card('Subnet & CIDR Calculator').getByLabel('IPv4 CIDR').fill('10.20.30.0/invalid');
await card('Subnet & CIDR Calculator').getByRole('button', { name: 'Calculate', exact: true }).click();
assert.equal(await page.getByRole('heading', { name: 'Recent diagnostic history' }).count(), 1, 'malformed CIDR cannot launch invalid calculations');
await card('Subnet & CIDR Calculator').getByLabel('IPv4 CIDR').fill('10.20.30.0/28');
await card('Subnet & CIDR Calculator').getByRole('button', { name: 'Calculate', exact: true }).click();
assert.equal(await page.locator('main input[type=text]').inputValue(), '10.20.30.0');
assert.match(await page.locator('main').innerText(), /10\.20\.30\.0\/28/);
await dashboard();
await card('MAC / OUI Lookup').getByLabel('MAC address or prefix').fill('00:1A:2B');
await card('MAC / OUI Lookup').getByRole('button', { name: 'Look up', exact: true }).click();
assert.equal(await page.locator('main input[type=text]').inputValue(), '00:1A:2B');

await dashboard();
await card('GeoIP & ISP Lookup').getByLabel('IP or hostname (optional)').fill('203.0.113.2');
await card('GeoIP & ISP Lookup').getByRole('button', { name: 'Look up', exact: true }).click();
await page.getByText(/Example City/).first().waitFor();
assert.equal(requests.filter(url => url === 'https://ipwho.is/203.0.113.2').length, 1, 'GeoIP launches only requested target');
assert.equal(await page.locator('main iframe').count(), 0, 'unknown coordinates do not show a map');
assert.doesNotMatch(await page.locator('main').innerText(), /0\.0000|\bNO\b/);
const geo = await page.evaluate(() => JSON.parse(localStorage.getItem('netready_history_v1')).find(item => item.type === 'geoip').data);
assert.equal(geo.lat, null); assert.equal(geo.isVpn, null);

geoResponse = { ...geoResponse, latitude: 0, longitude: 0, security: { proxy: false, vpn: false } };
await page.locator('main form button[type=submit]').click();
await page.locator('main iframe').waitFor();
assert.match(await page.locator('main').innerText(), /0\.0000, 0\.0000/);

// Every configurable tool keeps its primary run control in the input/settings panel.
const layoutCases = [
['Ping & Jitter', /Start Ping Sequence/, 'input[type=text]'],
['DNS-over-HTTPS Resolver', /Resolve DNS/, 'input[type=text]'],
['Local Port Scanner', /Launch Scan/, 'input[type=text]'],
['HTTP Probe', /Probe Endpoint/, 'input[type=text]'],
['WebSocket Tester', /Test WebSocket Connection/, 'input[type=text]'],
['Edge Path Explorer', /Explore/, 'input[type=text]'],
['WebRTC ICE Analyzer', /Inspect ICE Candidates/, 'input[type=text], select'],
['Walk & Test', /Start walking/, '#walk-start-spot'],
];
for (const [title, buttonName, selector] of layoutCases) {
await dashboard();
await card(title).getByRole('button', { name: 'Open tool', exact: true }).click();
const input = page.locator(`main ${selector}`).last();
const button = page.locator('main').getByRole('button', { name: buttonName }).first();
const inputBox = await input.boundingBox(); const buttonBox = await button.boundingBox();
assert(inputBox && buttonBox, `${title} controls visible`);
assert(Math.abs(buttonBox.y - inputBox.y) < 320, `${title} run control beside inputs`);
}
await dashboard();
const beforeGeoOpen = requests.length;
await card('GeoIP & ISP Lookup').getByRole('button', { name: 'Open tool', exact: true }).click();
await page.waitForTimeout(150);
assert.equal(requests.length, beforeGeoOpen, 'opening GeoIP does not run a lookup');
await dashboard();
await page.evaluate(() => localStorage.removeItem('netready_responsible_networking_accepted'));
await card('HTTP Probe').getByLabel('HTTP or HTTPS URL').fill('https://example.com/diagnostic');
const beforeConsent = requests.length;
await card('HTTP Probe').getByRole('button', { name: 'Probe', exact: true }).click();
await page.getByText('Responsible Networking Warning', { exact: true }).waitFor();
assert.equal(requests.length, beforeConsent, 'dashboard submission retains authorization gate');
await page.getByText('I confirm that I own or have explicit authorization to scan and test target hosts.', { exact: true }).click();
await page.getByRole('button', { name: 'Confirm & Continue', exact: true }).click();
await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.type === 'httpprobe'));
assert.equal(await page.locator('main input[type=text]').inputValue(), 'https://example.com/diagnostic');
assert(requests.some(url => url.startsWith('https://example.com/diagnostic')));
await dashboard();
await card('Local Port Scanner').getByLabel('Host, subnet or range').fill('127.0.0.1');
await card('Local Port Scanner').getByLabel('Ports', { exact: true }).fill('1-65535');
const beforeInvalidScan = requests.length;
await card('Local Port Scanner').getByRole('button', { name: 'Run test', exact: true }).click();
await page.locator('main').getByText(/Select at most 1000/).first().waitFor();
assert.equal(requests.length, beforeInvalidScan, 'oversized ports do not launch a partial scan');
await dashboard();
await context.setOffline(true);
await card('Speed & Bandwidth').getByRole('button', { name: 'Run test', exact: true }).click();
await page.waitForFunction(() => JSON.parse(localStorage.getItem('netready_history_v1')).some(item => item.type === 'speedtest'), { timeout: 60000 });
const speed = await page.evaluate(() => JSON.parse(localStorage.getItem('netready_history_v1')).find(item => item.type === 'speedtest').data);
for (const key of ['downloadSpeed', 'uploadSpeed', 'ping', 'jitter', 'bufferbloatScore']) assert.equal(speed[key], null, `offline ${key} unavailable`);
assert(speed.failures.length > 0);
assert.equal(errors.length, 0, errors.join('\n'));
console.log('Browser checks passed: quick launches, history preservation, input/button placement, GeoIP unknown/zero values, offline speed measurements.');
} finally {
await browser?.close();
server.kill('SIGTERM');
}
Loading
Loading