diff --git a/src/lib/diagnostics-classify.test.ts b/src/lib/diagnostics-classify.test.ts new file mode 100644 index 0000000..b47770a --- /dev/null +++ b/src/lib/diagnostics-classify.test.ts @@ -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 => ({ + 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/) +}) diff --git a/src/lib/diagnostics-classify.ts b/src/lib/diagnostics-classify.ts new file mode 100644 index 0000000..d0cd1b4 --- /dev/null +++ b/src/lib/diagnostics-classify.ts @@ -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.", + } +} diff --git a/src/lib/diagnostics.ts b/src/lib/diagnostics.ts index 46fca8f..2efe58a 100644 --- a/src/lib/diagnostics.ts +++ b/src/lib/diagnostics.ts @@ -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 { 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 { const parsed = parseUrl(url) log.info("diag", "probe start", url, "parsed", JSON.stringify(parsed))