test(lib): cover connection URL parsing + failure classification (42→57 tests)

Extract parseUrl + classify from diagnostics.ts into a pure diagnostics-classify.ts
(diagnostics.ts imports + re-exports the types; behavior unchanged) so the
connection-failure decision tree — the user-facing 'why did connecting fail'
guidance — is unit-testable without react-native/expo. 15 tests: URL parsing
(port defaults, IPv4-vs-hostname, path/query stripping, malformed rejection) and
every classify branch (malformed, ok, tls, no-internet, health-failed, timeout,
server-unreachable incl. the hostname-only MagicDNS hint). typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
engineer
2026-06-03 14:20:27 -07:00
parent 48bc314dfc
commit 39c4347092
3 changed files with 206 additions and 79 deletions

View File

@@ -0,0 +1,119 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { parseUrl, classify, type ProbeAttempt } from "./diagnostics-classify.ts"
const probe = (over: Partial<ProbeAttempt> = {}): ProbeAttempt => ({
name: "p",
target: "t",
ok: false,
durationMs: 1,
...over,
})
// ---------------- parseUrl ----------------
test("parses scheme, host, and explicit port", () => {
assert.deepEqual(parseUrl("http://192.168.1.10:4096"), {
valid: true,
scheme: "http",
host: "192.168.1.10",
port: "4096",
isHostname: false,
})
})
test("defaults port to 443 for https and 80 for http", () => {
assert.equal(parseUrl("https://example.com").port, "443")
assert.equal(parseUrl("http://example.com").port, "80")
})
test("lowercases the scheme and trims surrounding whitespace", () => {
const p = parseUrl(" HTTPS://Host.Example ")
assert.equal(p.scheme, "https")
assert.equal(p.host, "Host.Example") // host case is preserved
})
test("flags an IPv4 host as not-a-hostname, and a DNS name as a hostname", () => {
assert.equal(parseUrl("http://10.0.0.1:8080").isHostname, false)
assert.equal(parseUrl("http://server.tailnet.ts.net:8080").isHostname, true)
})
test("ignores path, query, and fragment when extracting the host", () => {
const p = parseUrl("http://host:9000/global/health?x=1#frag")
assert.equal(p.host, "host")
assert.equal(p.port, "9000")
})
test("rejects malformed or non-http(s) URLs", () => {
for (const bad of ["", "not a url", "ftp://host", "host:1234", "://nope", "https://"]) {
assert.equal(parseUrl(bad).valid, false, `should be invalid: ${JSON.stringify(bad)}`)
}
})
// ---------------- classify ----------------
const okUrl = parseUrl("http://host:8080")
test("malformed URL short-circuits to malformed-url", () => {
const r = classify(parseUrl("garbage"), probe(), probe(), probe())
assert.equal(r.classification, "malformed-url")
})
test("a healthy probe classifies as ok regardless of other probes", () => {
const r = classify(okUrl, probe({ ok: true, status: 200 }), probe(), probe())
assert.equal(r.classification, "ok")
})
test("TLS-flavored error wins even when internet is down", () => {
const r = classify(okUrl, probe({ error: "SSL handshake failed" }), probe({ ok: false }), probe())
assert.equal(r.classification, "tls-error")
})
test("detects TLS keywords from errorCause too", () => {
const r = classify(okUrl, probe({ errorCause: "certificate has expired" }), probe({ ok: true }), probe())
assert.equal(r.classification, "tls-error")
})
test("no internet (and no TLS signal) classifies as no-internet", () => {
const r = classify(okUrl, probe({ error: "Network request failed" }), probe({ ok: false }), probe())
assert.equal(r.classification, "no-internet")
})
test("internet up + server root reachable but health failed -> health-failed (includes status)", () => {
const r = classify(
okUrl,
probe({ error: "HTTP 404", status: 404 }),
probe({ ok: true }), // internet
probe({ ok: true, status: 404 }), // root reachable
)
assert.equal(r.classification, "health-failed")
assert.match(r.summary, /404/)
})
test("internet up, root unreachable, timeout signal -> timeout", () => {
const r = classify(
okUrl,
probe({ error: "timeout after 8000ms" }),
probe({ ok: true }),
probe({ ok: false }),
)
assert.equal(r.classification, "timeout")
})
test("internet up, root unreachable, no timeout -> server-unreachable", () => {
const r = classify(
okUrl,
probe({ error: "connection refused" }),
probe({ ok: true }),
probe({ ok: false }),
)
assert.equal(r.classification, "server-unreachable")
})
test("server-unreachable adds MagicDNS hint only for hostnames, not IPs", () => {
const fail = { internet: probe({ ok: true }), root: probe({ ok: false }) }
const hostR = classify(parseUrl("http://box.ts.net:8080"), probe({ error: "refused" }), fail.internet, fail.root)
const ipR = classify(parseUrl("http://10.0.0.5:8080"), probe({ error: "refused" }), fail.internet, fail.root)
assert.match(hostR.summary, /MagicDNS/)
assert.doesNotMatch(ipR.summary, /MagicDNS/)
})

View File

@@ -0,0 +1,84 @@
// Pure URL parsing + connection-failure classification, extracted from diagnostics.ts
// so this decision logic (which produces the user-facing "why did connecting fail"
// guidance) is unit-testable without react-native / expo-clipboard / expo-device.
export type Classification =
| "ok"
| "malformed-url"
| "no-internet"
| "server-unreachable"
| "health-failed"
| "tls-error"
| "timeout"
| "unknown"
export interface ProbeAttempt {
name: string
target: string
ok: boolean
status?: number
durationMs: number
error?: string
errorCause?: string
}
export interface ParsedUrl {
valid: boolean
scheme?: string
host?: string
port?: string
isHostname: boolean
}
// Hermes' built-in URL is incomplete (hostname/port often unreliable), so parse
// with a regex instead of `new URL`.
export function parseUrl(url: string): ParsedUrl {
const m = url.trim().match(/^(https?):\/\/([^/:?#]+)(?::(\d+))?/i)
if (!m) return { valid: false, isHostname: false }
const scheme = m[1].toLowerCase()
const host = m[2]
const port = m[3] || (scheme === "https" ? "443" : "80")
const isHostname = !/^\d{1,3}(\.\d{1,3}){3}$/.test(host)
return { valid: true, scheme, host, port, isHostname }
}
export function classify(
parsed: ParsedUrl,
health: ProbeAttempt,
internet: ProbeAttempt,
root: ProbeAttempt,
): { classification: Classification; summary: string } {
if (!parsed.valid) {
return { classification: "malformed-url", summary: "The server URL could not be parsed. Check for typos or extra characters." }
}
if (health.ok) {
return { classification: "ok", summary: "Health endpoint responded — connection actually works now." }
}
const txt = `${health.error ?? ""} ${health.errorCause ?? ""}`.toLowerCase()
const isTls = /ssl|tls|certificate|trust|handshake/.test(txt)
const isTimeout = /timeout|timed out/.test(txt)
if (isTls) {
return { classification: "tls-error", summary: "TLS/certificate problem. Try http:// instead of https://, or fix the server certificate." }
}
if (!internet.ok) {
return { classification: "no-internet", summary: "The device has no working internet/network at all (public check also failed). Check Wi-Fi/data and Tailscale (VPN) status." }
}
// Internet works, server does not.
if (root.ok) {
return { classification: "health-failed", summary: `Server is reachable but /global/health failed (HTTP ${health.status ?? "error"}). Likely wrong path, auth, or an old server version.` }
}
if (isTimeout) {
return { classification: "timeout", summary: "Connection to the server timed out (dropped, not refused). Likely a firewall, wrong port, or Tailscale ACL blocking the device." }
}
return {
classification: "server-unreachable",
summary:
`Internet works, but the server at ${parsed.host}:${parsed.port} is unreachable. ` +
(parsed.isHostname
? "Hostname may not resolve from this device (MagicDNS off?). Try the raw Tailscale IP. "
: "") +
"Confirm the opencode server is running, the device is on the same tailnet, and the port is correct.",
}
}

View File

@@ -6,31 +6,14 @@ import * as Clipboard from "expo-clipboard"
import * as Device from "expo-device"
import appJson from "../../app.json"
import { log, formatLogLines } from "./logbuffer"
import { type Classification, type ProbeAttempt, type ParsedUrl, parseUrl, classify } from "./diagnostics-classify"
export type { Classification, ProbeAttempt } from "./diagnostics-classify"
const PROBE_TIMEOUT_MS = 8_000
// Public 204 endpoints used purely as an "is the internet reachable at all" check.
const INTERNET_CHECK_URL = "https://www.gstatic.com/generate_204"
export type Classification =
| "ok"
| "malformed-url"
| "no-internet"
| "server-unreachable"
| "health-failed"
| "tls-error"
| "timeout"
| "unknown"
export interface ProbeAttempt {
name: string
target: string
ok: boolean
status?: number
durationMs: number
error?: string
errorCause?: string
}
export interface DiagnosticReport {
classification: Classification
summary: string
@@ -49,26 +32,6 @@ export interface DiagnosticReport {
timestamp: string
}
interface ParsedUrl {
valid: boolean
scheme?: string
host?: string
port?: string
isHostname: boolean
}
// Hermes' built-in URL is incomplete (hostname/port often unreliable), so parse
// with a regex instead of `new URL`.
function parseUrl(url: string): ParsedUrl {
const m = url.trim().match(/^(https?):\/\/([^/:?#]+)(?::(\d+))?/i)
if (!m) return { valid: false, isHostname: false }
const scheme = m[1].toLowerCase()
const host = m[2]
const port = m[3] || (scheme === "https" ? "443" : "80")
const isHostname = !/^\d{1,3}(\.\d{1,3}){3}$/.test(host)
return { valid: true, scheme, host, port, isHostname }
}
async function timedFetch(name: string, target: string, init?: RequestInit): Promise<ProbeAttempt> {
const start = Date.now()
const controller = new AbortController()
@@ -92,45 +55,6 @@ async function timedFetch(name: string, target: string, init?: RequestInit): Pro
}
}
function classify(parsed: ParsedUrl, health: ProbeAttempt, internet: ProbeAttempt, root: ProbeAttempt): {
classification: Classification
summary: string
} {
if (!parsed.valid) {
return { classification: "malformed-url", summary: "The server URL could not be parsed. Check for typos or extra characters." }
}
if (health.ok) {
return { classification: "ok", summary: "Health endpoint responded — connection actually works now." }
}
const txt = `${health.error ?? ""} ${health.errorCause ?? ""}`.toLowerCase()
const isTls = /ssl|tls|certificate|trust|handshake/.test(txt)
const isTimeout = /timeout|timed out/.test(txt)
if (isTls) {
return { classification: "tls-error", summary: "TLS/certificate problem. Try http:// instead of https://, or fix the server certificate." }
}
if (!internet.ok) {
return { classification: "no-internet", summary: "The device has no working internet/network at all (public check also failed). Check Wi-Fi/data and Tailscale (VPN) status." }
}
// Internet works, server does not.
if (root.ok) {
return { classification: "health-failed", summary: `Server is reachable but /global/health failed (HTTP ${health.status ?? "error"}). Likely wrong path, auth, or an old server version.` }
}
if (isTimeout) {
return { classification: "timeout", summary: "Connection to the server timed out (dropped, not refused). Likely a firewall, wrong port, or Tailscale ACL blocking the device." }
}
return {
classification: "server-unreachable",
summary:
`Internet works, but the server at ${parsed.host}:${parsed.port} is unreachable. ` +
(parsed.isHostname
? "Hostname may not resolve from this device (MagicDNS off?). Try the raw Tailscale IP. "
: "") +
"Confirm the opencode server is running, the device is on the same tailnet, and the port is correct.",
}
}
export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise<DiagnosticReport> {
const parsed = parseUrl(url)
log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed))