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:
119
src/lib/diagnostics-classify.test.ts
Normal file
119
src/lib/diagnostics-classify.test.ts
Normal 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/)
|
||||||
|
})
|
||||||
84
src/lib/diagnostics-classify.ts
Normal file
84
src/lib/diagnostics-classify.ts
Normal 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.",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,31 +6,14 @@ import * as Clipboard from "expo-clipboard"
|
|||||||
import * as Device from "expo-device"
|
import * as Device from "expo-device"
|
||||||
import appJson from "../../app.json"
|
import appJson from "../../app.json"
|
||||||
import { log, formatLogLines } from "./logbuffer"
|
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
|
const PROBE_TIMEOUT_MS = 8_000
|
||||||
// Public 204 endpoints used purely as an "is the internet reachable at all" check.
|
// Public 204 endpoints used purely as an "is the internet reachable at all" check.
|
||||||
const INTERNET_CHECK_URL = "https://www.gstatic.com/generate_204"
|
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 {
|
export interface DiagnosticReport {
|
||||||
classification: Classification
|
classification: Classification
|
||||||
summary: string
|
summary: string
|
||||||
@@ -49,26 +32,6 @@ export interface DiagnosticReport {
|
|||||||
timestamp: string
|
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> {
|
async function timedFetch(name: string, target: string, init?: RequestInit): Promise<ProbeAttempt> {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
const controller = new AbortController()
|
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> {
|
export async function probeConnection(url: string, auth?: { username: string; password: string }): Promise<DiagnosticReport> {
|
||||||
const parsed = parseUrl(url)
|
const parsed = parseUrl(url)
|
||||||
log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed))
|
log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed))
|
||||||
|
|||||||
Reference in New Issue
Block a user