diff --git a/src/api.ts b/src/api.ts index 7dc367d..1ec516b 100644 --- a/src/api.ts +++ b/src/api.ts @@ -5,11 +5,30 @@ import { autoResolveProject, promptChoice, type ProjectItem } from './resolve-pr import { die } from './util.js' import { USER_AGENT } from './version.js' import { agentHeaders, agentMode } from './agent.js' +import { describeTarget, failureReason, hostOf, targetLines } from './target.js' + +/** Extra lines an error carries under its first line: which host answered (or did not), and what + * pointed the CLI there. Attached at throw time, where the target is known; index.ts prints them. */ +export type ErrorContext = string[] export class ApiError extends Error { // body carries the parsed error payload for callers that branch on machine-readable errors // (e.g. template deploy's missing_variables); the message stays the human line. - constructor(public status: number, msg: string, public body?: any) { super(msg); this.name = 'ApiError' } + constructor(public status: number, msg: string, public body?: any, public context?: ErrorContext) { super(msg); this.name = 'ApiError' } +} + +/** The request never got an answer: DNS, TCP, TLS, or a timeout. + * + * undici collapses all of those into `TypeError: fetch failed`, which the CLI printed verbatim — + * no host, no reason, no hint that the target was not the cloud. This names the host it tried and + * why it failed, and keeps the original as `cause` so telemetry still lifts `cause.code`. */ +export class NetworkError extends Error { + constructor(public url: string, cause: unknown, public context?: ErrorContext) { + const reason = failureReason(cause) + super(`cannot reach ${hostOf(url)}${reason ? ` (${reason})` : ''}`) + this.name = 'NetworkError' + this.cause = cause + } } export class AgentApprovalRequired extends Error { constructor(public body: any) { super(body.message ?? `approval required: ${body.approvalId}`) } @@ -57,17 +76,27 @@ export class ApiClient { async request(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise { const res = await this.raw(method, path, body, opts.auth ?? true) if (agentMode() && res.status === 202 && res.body?.status === 'approval_required') throw new AgentApprovalRequired(res.body) - if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body) + if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body, await this.targetContext()) return res.body as T } // Like request but returns {status, body} so callers can branch on 202 (approval_required). async rawRequest(method: string, path: string, body?: unknown, opts: { auth?: boolean } = {}): Promise { const res = await this.raw(method, path, body, opts.auth ?? true) - if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body) + if (res.status >= 400) throw new ApiError(res.status, res.body?.error ?? `HTTP ${res.status}`, res.body, await this.targetContext()) return res } + // Computed once, and only on the failure path: a run that succeeds pays nothing, and the config + // file is not re-read per error. Empty on the cloud default, so nothing is added to the common case. + private noteCache?: ErrorContext + private async targetContext(): Promise { + if (!this.noteCache) { + try { this.noteCache = targetLines(await describeTarget(this.apiUrl)) } catch { this.noteCache = [] } + } + return this.noteCache + } + private async raw(method: string, path: string, body: unknown, auth: boolean): Promise { let r = await this.fetch(method, path, body, auth) if (r.status === 401 && auth && this.cfg.refreshToken) { @@ -80,11 +109,18 @@ export class ApiClient { const headers: Record = { 'Content-Type': 'application/json', 'Insta-Hints': '1', 'User-Agent': USER_AGENT } if (auth && this.cfg.accessToken) headers.Authorization = `Bearer ${this.cfg.accessToken}` if (auth) Object.assign(headers, await agentHeaders(this, method, path, body === undefined ? '' : JSON.stringify(body))) - const res = await this.fetchImpl(this.apiUrl + path, { - method, - headers, - body: body === undefined ? undefined : JSON.stringify(body), - }) + let res: Response + try { + res = await this.fetchImpl(this.apiUrl + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch (e) { + // A transport failure, not an HTTP status: there is no response to parse and no 401 to + // refresh past, so it goes straight out as a NetworkError naming the host and the setting. + throw new NetworkError(this.apiUrl, (e as { cause?: unknown })?.cause ?? e, await this.targetContext()) + } const text = await res.text() let parsed: any = null try { parsed = text ? JSON.parse(text) : null } catch { parsed = { raw: text } } diff --git a/src/commands/auth.ts b/src/commands/auth.ts index 4f5cf7f..02d209d 100644 --- a/src/commands/auth.ts +++ b/src/commands/auth.ts @@ -1,6 +1,7 @@ import { createServer } from 'node:http' import { randomBytes } from 'node:crypto' -import { ApiClient, ApiError, linkedProject } from '../api.js' +import { ApiClient, ApiError, NetworkError, linkedProject } from '../api.js' +import { describeTarget } from '../target.js' import { ENVS, ENV_NAMES, envForApiUrl, isEnvName } from '../env.js' import { info, die, printJson, promptPassword, openUrl } from '../util.js' @@ -233,14 +234,23 @@ export async function logout(): Promise { export async function status(opts: { json?: boolean }): Promise { const api = await ApiClient.load() let user: any = null - try { user = (await api.request('GET', '/me')).user } catch { /* not logged in */ } + // A host that never answered is not the same as a rejected credential, and reporting the first + // as "(not logged in)" is what sent the user hunting for a login problem that did not exist. + let unreachable: string | null = null + try { user = (await api.request('GET', '/me')).user } catch (e) { + if (e instanceof NetworkError) unreachable = e.message + } const project = await linkedProject() // Surface the environment name alongside the URL: "api: https://api.staging.instacloud.com" is // easy to skim past, and mistaking staging for prod is the mistake worth making loud. const env = envForApiUrl(api.apiUrl) - if (opts.json) return printJson({ env, apiUrl: api.apiUrl, user, project }) - info(`env: ${env ?? '(custom)'}`) + const target = await describeTarget(api.apiUrl) + // The stable token in json, the prose on a terminal. See envShow. + if (opts.json) return printJson({ env, apiUrl: api.apiUrl, source: target.kind, unreachable, user, project }) + info(`env: ${env ?? 'custom'}`) info(`api: ${api.apiUrl}`) - info(`user: ${user ? (user.email ?? user.id) : '(not logged in)'}`) + info(`source: ${target.source}`) + info(`user: ${unreachable ?? (user ? (user.email ?? user.id) : '(not logged in)')}`) info(`project: ${project ? `${project.projectId} (branch ${project.branch})` : '(none linked)'}`) + if (!env) info(`switch: ${target.recovery}`) } diff --git a/src/commands/env.ts b/src/commands/env.ts index 7e2c6bf..3cf1fd1 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -7,17 +7,27 @@ // same file `login --api-url` already writes, so this adds a surface, not a concept. import { readPersistedGlobal, resolveEnv, writeGlobal, type GlobalConfig } from '../config.js' import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, isEnvName, mcpServerName, normalizeUrl, type EnvName } from '../env.js' +import { describeTarget } from '../target.js' import { die, info, printJson } from '../util.js' export async function envShow(opts: { json?: boolean }): Promise { const { apiUrl, env, mcpUrl, skills } = await resolveEnv() const mcpServer = mcpServerName(env ?? DEFAULT_ENV) - if (opts.json) return printJson({ env, apiUrl, mcpUrl, mcpServer, skills }) - info(`env: ${env ?? '(custom)'}`) + // `source` answers the question the old output left open: the URL was on screen, but nothing + // said which of INSTA_API_URL / INSTA_ENV / a months-old `login --api-url` had chosen it. + const target = await describeTarget(apiUrl) + // json gets the stable token, a terminal gets the prose. An agent that branched on the wording of + // "saved by `insta login --api-url`" would break the next time that sentence is reworded, and the + // backticks in it are noise in a machine field. + if (opts.json) return printJson({ env, apiUrl, source: target.kind, mcpUrl, mcpServer, skills }) + info(`env: ${env ?? 'custom'}`) info(`api: ${apiUrl}`) - info(`mcp: ${mcpUrl} (${mcpServer})`) - info(`skills: ${skills}`) - if (!env) info(' (custom apiUrl — `insta env use ` to switch to a named environment)') + info(`source: ${target.source}`) + // A custom apiUrl has no matching mcp/skills entry, so resolveEnv falls back to the cloud's. + // Say so: an unlabelled cloud mcp host under an insta-oss api host reads as a matched pair. + info(`mcp: ${mcpUrl} (${mcpServer}${env ? '' : ', cloud fallback'})`) + info(`skills: ${skills}${env ? '' : ' (cloud fallback)'}`) + if (!env) info(`switch: ${target.recovery}`) } // One stable schema for BOTH envUse outcomes (no-op and real switch), so a scripted caller can key diff --git a/src/config.ts b/src/config.ts index df92610..fafdf25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -100,6 +100,20 @@ export async function readPersistedGlobal(): Promise { } } +/** The apiUrl the config FILE holds, or null when there is no file or it names none. + * + * Distinct from readPersistedGlobal, which substitutes DEFAULT_API: "prod because nothing was + * ever chosen" and "prod because a login saved it" are the same host but different provenance, + * and the messages in target.ts have to say which. */ +export async function storedApiUrl(): Promise { + try { + const parsed = JSON.parse(await readFile(GLOBAL_FILE, 'utf8')) as Partial + return typeof parsed.apiUrl === 'string' && parsed.apiUrl !== '' ? parsed.apiUrl : null + } catch { + return null + } +} + export async function writeGlobal(c: GlobalConfig): Promise { await mkdir(GLOBAL_DIR, { recursive: true }) await writeFile(GLOBAL_FILE, JSON.stringify(c, null, 2)) diff --git a/src/index.ts b/src/index.ts index f293382..f5db0ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { configureAgent, detectAgent } from './agent.js' import * as agentPolicy from './commands/agent-policy.js' -import { ApiError, AgentApprovalRequired } from './api.js' +import { ApiError, AgentApprovalRequired, NetworkError } from './api.js' import { CliCancel, CliExit, fail, relayedExitCode } from './util.js' import { trackCommand } from './telemetry.js' import { cliVersion } from './version.js' @@ -43,7 +43,8 @@ function onError(e: unknown): void { return } if (e instanceof CliExit || e instanceof CliCancel) return - if (e instanceof ApiError) return fail(`${e.message} (HTTP ${e.status})`) + if (e instanceof NetworkError) return fail(e.message, e.context) + if (e instanceof ApiError) return fail(`${e.message} (HTTP ${e.status})`, e.context) fail(e instanceof Error ? e.message : String(e)) } diff --git a/src/target.ts b/src/target.ts new file mode 100644 index 0000000..54e3b70 --- /dev/null +++ b/src/target.ts @@ -0,0 +1,146 @@ +// Where the CLI is pointed, and which setting put it there. +// +// Everything here is DERIVED: nothing is stored, and nothing is asked of the host. That is +// deliberate on both counts. The failure this exists for is a host that does not answer, so an +// answer from the host is exactly what cannot be relied on; and a stored label ("this URL is an +// insta-oss box") goes stale the moment the box is rebuilt or the URL reused, which is worse than +// no label at all. Provenance needs neither: `env use` only ever writes a host from the env table, +// so a persisted apiUrl that is not one of those can only have come from `login --api-url`. +import { DEFAULT_ENV, ENVS, ENV_NAMES, envForApiUrl, normalizeUrl, type EnvName } from './env.js' +import { storedApiUrl } from './config.js' + +/** Which setting chose the target. A stable token, so `--json` consumers (agents, mostly) can + * branch on it: the human `source` string is prose and will be reworded. */ +export type TargetSource = + | 'env-api-url' // $INSTA_API_URL + | 'env-name' // $INSTA_ENV + | 'saved-api-url' // persisted by `insta login --api-url` + | 'saved-env' // persisted by `insta env use` / `login --env` + | 'default' // nothing was ever chosen + | 'flag' // --api-url on the running command, not yet persisted + +export type Target = { + apiUrl: string + host: string + /** null for a host no environment name covers: an insta-oss daemon, a preview, a tunnel. */ + env: EnvName | null + /** Machine-stable; `source` is the same fact as prose. */ + kind: TargetSource + /** The setting that chose apiUrl, phrased to sit after a `source:` label. */ + source: string + /** The one command that gets back to InstaCloud from here. */ + recovery: string +} + +export const hostOf = (url: string): string => { + try { return new URL(url).host } catch { return url } +} + +/** Pure core (unit-tested). `stored` is what the config FILE holds, null when it holds nothing. */ +export function buildTarget(i: { + apiUrl: string + stored: string | null + envApiUrl?: string + envName?: string +}): Target { + const want = normalizeUrl(i.apiUrl) + const env = envForApiUrl(i.apiUrl) + const named = i.envName?.trim().toLowerCase() + const namedApi = named && (ENV_NAMES as string[]).includes(named) ? ENVS[named as EnvName].api : undefined + + let kind: TargetSource + if (i.envApiUrl && normalizeUrl(i.envApiUrl) === want) kind = 'env-api-url' + else if (namedApi && normalizeUrl(namedApi) === want) kind = 'env-name' + else if (i.stored && normalizeUrl(i.stored) === want) { + // A stored host the env table knows was written by `env use` (or by `login --env`, which + // writes the same value); anything else was a literal URL the user typed at `login --api-url`. + kind = envForApiUrl(i.stored) ? 'saved-env' : 'saved-api-url' + } else if (i.stored === null && env === DEFAULT_ENV) kind = 'default' + // Nothing in the environment or on disk accounts for this URL, so it came from the flag the + // running command was given (`login --api-url`, before anything is persisted). + else kind = 'flag' + + const source = + kind === 'env-api-url' ? 'INSTA_API_URL' + : kind === 'env-name' ? `INSTA_ENV=${named}` + : kind === 'saved-env' ? 'saved by `insta env use`' + : kind === 'saved-api-url' ? 'saved by `insta login --api-url`' + : kind === 'default' ? 'built-in default' + : '--api-url flag' + + // Undo the thing that actually chose this host, not the thing that usually does. `env use prod` + // is the right advice only when something is PERSISTED; against a bare `--api-url` on the + // running command it is a no-op that prints "already on prod" and deepens the confusion. + const recovery = + kind === 'env-api-url' ? 'unset INSTA_API_URL' + : kind === 'env-name' ? 'unset INSTA_ENV' + : kind === 'flag' ? 'drop --api-url' + : 'insta env use prod' + + return { apiUrl: i.apiUrl, host: hostOf(i.apiUrl), env, kind, source, recovery } +} + +/** The live target, from the resolved apiUrl plus the config file and the environment. */ +export async function describeTarget(apiUrl: string): Promise { + return buildTarget({ + apiUrl, + stored: await storedApiUrl(), + envApiUrl: process.env.INSTA_API_URL, + envName: process.env.INSTA_ENV, + }) +} + +/** The context an error adds beneath its first line. Empty on the cloud default: the overwhelming + * majority of runs are there, and a run that is where it expects to be has nothing to explain. */ +export function targetLines(t: Target): string[] { + if (t.env === DEFAULT_ENV) return [] + const lines = [` target: ${t.apiUrl} (${t.source})`] + if (!t.env) lines.push(` for InstaCloud: ${t.recovery}`) + return lines +} + +// Two runtimes, two vocabularies, and the shipped artifact is the one that is easy to forget. +// +// On Node (`npm i -g insta`) undici reports every transport failure as the same +// `TypeError: fetch failed`, with the code that says WHICH failure on `.cause`. On Bun (the +// compiled binaries install.sh serves, so: most users) it is a plain `Error` carrying the code on +// the error ITSELF, no cause at all, spelled in Bun's own CamelCase. +// +// Bun is also coarser, and that part needs care rather than a translation. It reports a connect +// TIMEOUT as `ConnectionRefused`, so it cannot tell a terminated box from a refused port from a +// dead DNS name. Rendering that as "connection refused" would be a confident lie about the exact +// host this feature exists for, so it gets the honest, non-committal "could not connect". Node's +// own ECONNREFUSED really does mean refused and keeps the precise wording. +// +// Anything unmapped falls through to the code, then the message, so a new runtime code degrades to +// a raw-but-present reason rather than to nothing. +const REASONS: Record = { + // Bun (the compiled binaries). + ConnectionRefused: 'could not connect', + ConnectionClosed: 'connection closed', + FailedToOpenSocket: 'could not open a socket', + Timeout: 'timed out', + // Node / undici (the npm install). + UND_ERR_CONNECT_TIMEOUT: 'connect timeout', + UND_ERR_HEADERS_TIMEOUT: 'no response headers', + UND_ERR_SOCKET: 'socket closed', + ECONNREFUSED: 'connection refused', + ECONNRESET: 'connection reset', + EHOSTUNREACH: 'host unreachable', + ENETUNREACH: 'network unreachable', + ETIMEDOUT: 'timed out', + ENOTFOUND: 'DNS lookup failed', + EAI_AGAIN: 'DNS temporarily unavailable', + CERT_HAS_EXPIRED: 'TLS certificate expired', + DEPTH_ZERO_SELF_SIGNED_CERT: 'self-signed TLS certificate', + UNABLE_TO_VERIFY_LEAF_SIGNATURE: 'TLS certificate not trusted', + ERR_TLS_CERT_ALTNAME_INVALID: 'TLS certificate does not cover this host', +} + +/** Why the connection failed, in a few words. Pure (unit-tested). */ +export function failureReason(cause: unknown): string | null { + const c = cause as { code?: string; message?: string } | undefined + if (!c) return null + const known = c.code ? REASONS[c.code] : undefined + return known ?? c.code ?? c.message ?? null +} diff --git a/src/util.ts b/src/util.ts index ef2b023..3fcb1de 100644 --- a/src/util.ts +++ b/src/util.ts @@ -78,8 +78,11 @@ export class CliCancel extends Error { } } -export function fail(msg: string): void { +// `context` lines print under the error, already indented, and say where the CLI was pointed and +// what pointed it there. Empty on the cloud default, so the common failure reads exactly as before. +export function fail(msg: string, context: string[] = []): void { process.stderr.write(`error: ${msg}\n`) + for (const line of context) process.stderr.write(line + '\n') process.exitCode = 1 } diff --git a/test/target.test.ts b/test/target.test.ts new file mode 100644 index 0000000..afb8083 --- /dev/null +++ b/test/target.test.ts @@ -0,0 +1,145 @@ +// Naming the target. The incident these cover: `insta login --device` against a terminated +// insta-oss box printed `error: fetch failed` and nothing else, while the config still held a +// months-old `login --api-url` host. Every assertion here is about a message saying which host, +// why it failed, and what pointed the CLI at it — without asking the (unreachable) host anything. +import { describe, expect, it } from 'vitest' +import { ApiClient, NetworkError } from '../src/api.js' +import { buildTarget, failureReason, targetLines } from '../src/target.js' + +const PROD = 'https://api.instacloud.com' +const STAGING = 'https://api.staging.instacloud.com' +const OSS = 'https://api.98-87-8-168.sslip.io' + +describe('target provenance', () => { + it('names INSTA_API_URL when the env var chose the host', () => { + const t = buildTarget({ apiUrl: OSS, stored: PROD, envApiUrl: OSS }) + expect(t.kind).toBe('env-api-url') + expect(t.source).toBe('INSTA_API_URL') + expect(t.recovery).toBe('unset INSTA_API_URL') + }) + + it('names INSTA_ENV when the named env var chose the host', () => { + const t = buildTarget({ apiUrl: STAGING, stored: PROD, envName: 'staging' }) + expect(t.kind).toBe('env-name') + expect(t.source).toBe('INSTA_ENV=staging') + expect(t.recovery).toBe('unset INSTA_ENV') + }) + + // `kind` is what --json publishes, so it must not drift with the prose. Every branch, once. + it('pairs a stable token with every prose source', () => { + const seen = new Map([ + [buildTarget({ apiUrl: OSS, stored: PROD, envApiUrl: OSS }).kind, 'INSTA_API_URL'], + [buildTarget({ apiUrl: STAGING, stored: PROD, envName: 'staging' }).kind, 'INSTA_ENV=staging'], + [buildTarget({ apiUrl: OSS, stored: OSS }).kind, 'saved by `insta login --api-url`'], + [buildTarget({ apiUrl: STAGING, stored: STAGING }).kind, 'saved by `insta env use`'], + [buildTarget({ apiUrl: PROD, stored: null }).kind, 'built-in default'], + [buildTarget({ apiUrl: OSS, stored: PROD }).kind, '--api-url flag'], + ]) + expect([...seen.keys()].sort()).toEqual( + ['default', 'env-api-url', 'env-name', 'flag', 'saved-api-url', 'saved-env'], + ) + }) + + // `env use` only ever writes a host from the env table, so a stored host OUTSIDE it can only + // have come from a literal `login --api-url`. That is the whole reason no field has to be stored. + it('distinguishes a stored custom URL from a stored named environment', () => { + expect(buildTarget({ apiUrl: OSS, stored: OSS }).source).toBe('saved by `insta login --api-url`') + expect(buildTarget({ apiUrl: STAGING, stored: STAGING }).source).toBe('saved by `insta env use`') + }) + + it('separates "prod was never chosen" from "prod was saved"', () => { + expect(buildTarget({ apiUrl: PROD, stored: null }).source).toBe('built-in default') + expect(buildTarget({ apiUrl: PROD, stored: PROD }).source).toBe('saved by `insta env use`') + }) + + // login --api-url sets the client's host before anything is persisted. + it('attributes a host that matches nothing on disk to the flag', () => { + expect(buildTarget({ apiUrl: OSS, stored: PROD }).source).toBe('--api-url flag') + }) + + // Caught by driving the real binary: with prod already persisted, "insta env use prod" is a + // no-op that prints "already on prod". The fix has to undo whatever actually chose the host. + it('tells the user to drop the flag, not to switch an env that is already set', () => { + expect(buildTarget({ apiUrl: OSS, stored: PROD }).recovery).toBe('drop --api-url') + expect(buildTarget({ apiUrl: OSS, stored: OSS }).recovery).toBe('insta env use prod') + }) + + it('ignores a trailing slash when matching the source', () => { + expect(buildTarget({ apiUrl: `${OSS}/`, stored: OSS }).source).toBe('saved by `insta login --api-url`') + }) + + it('survives an unparseable URL rather than throwing inside an error path', () => { + expect(buildTarget({ apiUrl: 'not a url', stored: null }).host).toBe('not a url') + }) +}) + +describe('target lines', () => { + // The cloud default is where nearly every run happens; a run that is where it expects to be has + // nothing to explain, so the common error stays exactly one line. + it('adds nothing on the cloud default', () => { + expect(targetLines(buildTarget({ apiUrl: PROD, stored: null }))).toEqual([]) + }) + + it('names the target on a named non-default environment', () => { + const lines = targetLines(buildTarget({ apiUrl: STAGING, stored: STAGING })) + expect(lines).toEqual([' target: https://api.staging.instacloud.com (saved by `insta env use`)']) + }) + + it('names the target and the way back on a custom host', () => { + expect(targetLines(buildTarget({ apiUrl: OSS, stored: OSS }))).toEqual([ + ' target: https://api.98-87-8-168.sslip.io (saved by `insta login --api-url`)', + ' for InstaCloud: insta env use prod', + ]) + }) +}) + +describe('failureReason', () => { + it('translates the undici and libuv codes a dead host produces', () => { + expect(failureReason({ code: 'UND_ERR_CONNECT_TIMEOUT' })).toBe('connect timeout') + expect(failureReason({ code: 'ENOTFOUND' })).toBe('DNS lookup failed') + expect(failureReason({ code: 'ECONNREFUSED' })).toBe('connection refused') + }) + + // The compiled binaries run on Bun, which spells its codes differently and puts them on the + // error itself. Caught only by driving the real artifact: the suite runs on Node. + it('translates the Bun codes the compiled binary produces', () => { + expect(failureReason({ code: 'ConnectionRefused' })).toBe('could not connect') + expect(failureReason({ code: 'CERT_HAS_EXPIRED' })).toBe('TLS certificate expired') + }) + + // Bun reports a connect TIMEOUT as ConnectionRefused, so it cannot tell a terminated box from a + // refused port. Claiming "connection refused" there would be confidently wrong about the very + // host this exists for; Node's own ECONNREFUSED does mean refused and stays precise. + it('stays non-committal on Bun and precise on Node for the refused-looking codes', () => { + expect(failureReason({ code: 'ConnectionRefused' })).not.toContain('refused') + expect(failureReason({ code: 'ECONNREFUSED' })).toBe('connection refused') + }) + + // An unmapped code must still reach the user: a raw code beats a silently reasonless message. + it('falls back to the code, then the message, then nothing', () => { + expect(failureReason({ code: 'UND_ERR_SOMETHING_NEW' })).toBe('UND_ERR_SOMETHING_NEW') + expect(failureReason({ message: 'socket hang up' })).toBe('socket hang up') + expect(failureReason(undefined)).toBeNull() + }) +}) + +describe('NetworkError', () => { + it('names the host and the reason instead of "fetch failed"', () => { + const e = new NetworkError(OSS, { code: 'UND_ERR_CONNECT_TIMEOUT' }) + expect(e.message).toBe('cannot reach api.98-87-8-168.sslip.io (connect timeout)') + }) + + // telemetry.ts lifts error.cause.code; wrapping must not drop it. + it('keeps the original failure as cause so telemetry still sees the code', () => { + const cause = { code: 'ENOTFOUND' } + expect((new NetworkError(OSS, cause).cause as { code: string }).code).toBe('ENOTFOUND') + }) + + it('is what the client throws when the transport never answers', async () => { + const dead: typeof fetch = () => Promise.reject( + Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }), + ) + const api = new ApiClient({ apiUrl: OSS }, dead) + await expect(api.request('GET', '/me')).rejects.toThrow('cannot reach api.98-87-8-168.sslip.io (connection refused)') + }) +})