test(lib): cover request-header building + log ring buffer (4→20 tests)

Extract the auth + directory-header encoding from sdk.ts into a pure, dep-free
headers.ts (sdk.ts now delegates — behavior unchanged) so the connection-critical
logic is unit-testable without expo/fetch. Add 8 header tests (ASCII passthrough,
non-ASCII/CJK percent-encoding stays header-safe, Basic auth, empty-dir handling)
and 8 logbuffer tests (serialization incl. circular-ref fallback, 200-entry ring
cap, copy semantics, formatting). typecheck clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
engineer
2026-06-03 13:55:31 -07:00
parent b2ca5793d1
commit 3499167b60
4 changed files with 181 additions and 15 deletions

59
src/lib/headers.test.ts Normal file
View File

@@ -0,0 +1,59 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { buildRequestHeaders } from "./headers.ts"
// These headers authenticate every request and tell the server which project
// directory to operate in. A bug here breaks connections or silently targets the
// wrong directory, so the auth + encoding rules are pinned.
test("always sets JSON content type", () => {
assert.equal(buildRequestHeaders({})["Content-Type"], "application/json")
})
test("omits directory and auth headers when not provided", () => {
const h = buildRequestHeaders({})
assert.equal("x-opencode-directory" in h, false)
assert.equal("Authorization" in h, false)
})
test("passes ASCII directory through unencoded (server gets a readable path)", () => {
const h = buildRequestHeaders({ directory: "/home/user/projects/app" })
assert.equal(h["x-opencode-directory"], "/home/user/projects/app")
})
test("percent-encodes a directory containing non-ASCII characters", () => {
const dir = "/home/usér/projç"
const h = buildRequestHeaders({ directory: dir })
assert.equal(h["x-opencode-directory"], encodeURIComponent(dir))
// Result must be header-safe (latin1 / ASCII only).
assert.match(h["x-opencode-directory"], /^[\x00-\x7F]*$/)
})
test("encodes CJK directory names header-safely", () => {
const dir = "/projects/プロジェクト"
const h = buildRequestHeaders({ directory: dir })
assert.match(h["x-opencode-directory"], /^[\x00-\x7F]*$/)
assert.equal(decodeURIComponent(h["x-opencode-directory"]), dir)
})
test("builds a Basic auth header from username and password", () => {
const h = buildRequestHeaders({ auth: { username: "alice", password: "s3cret" } })
assert.equal(h["Authorization"], `Basic ${btoa("alice:s3cret")}`)
// Round-trips back to the credentials.
assert.equal(atob(h["Authorization"].replace("Basic ", "")), "alice:s3cret")
})
test("sets both directory and auth headers together", () => {
const h = buildRequestHeaders({
directory: "/srv/app",
auth: { username: "u", password: "p" },
})
assert.equal(h["x-opencode-directory"], "/srv/app")
assert.equal(h["Authorization"], `Basic ${btoa("u:p")}`)
assert.equal(h["Content-Type"], "application/json")
})
test("empty-string directory is treated as absent (falsy)", () => {
const h = buildRequestHeaders({ directory: "" })
assert.equal("x-opencode-directory" in h, false)
})

33
src/lib/headers.ts Normal file
View File

@@ -0,0 +1,33 @@
// Pure request-header construction for the opencode client.
// Extracted from sdk.ts so the auth + directory-encoding rules are unit-testable
// without pulling in expo/fetch (which has no resolver outside Metro).
//
// Relies only on `btoa`, which is available in both Hermes (RN) and Node >= 16.
export interface HeaderConfig {
directory?: string
auth?: { username: string; password: string }
}
export function buildRequestHeaders(config: HeaderConfig): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
}
if (config.directory) {
// The directory travels in an HTTP header, which is latin1-only. ASCII paths
// pass through untouched (so the server sees a readable path); anything with
// non-ASCII bytes is percent-encoded to stay header-safe.
const encoded = /[^\x00-\x7F]/.test(config.directory)
? encodeURIComponent(config.directory)
: config.directory
headers["x-opencode-directory"] = encoded
}
if (config.auth) {
const credentials = btoa(`${config.auth.username}:${config.auth.password}`)
headers["Authorization"] = `Basic ${credentials}`
}
return headers
}

87
src/lib/logbuffer.test.ts Normal file
View File

@@ -0,0 +1,87 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { log, getLogEntries, formatLogLines, clearLog, type LogEntry } from "./logbuffer.ts"
// The log buffer backs diagnostic reports. A regression in its ring-buffer cap,
// message serialization, or formatting silently corrupts the attached logs, so
// these pin the observable behavior.
test("records entries with level, tag, and joined message", () => {
clearLog()
log.info("net", "connected to", "server")
const entries = getLogEntries()
assert.equal(entries.length, 1)
assert.equal(entries[0].level, "info")
assert.equal(entries[0].tag, "net")
assert.equal(entries[0].message, "connected to server")
assert.equal(typeof entries[0].ts, "number")
})
test("serializes non-string parts as JSON, falling back to String() when unserializable", () => {
clearLog()
log.debug("t", "obj", { a: 1 }, 42, true)
assert.equal(getLogEntries()[0].message, 'obj {"a":1} 42 true')
clearLog()
const circular: Record<string, unknown> = {}
circular.self = circular // JSON.stringify throws -> String() fallback
log.warn("t", circular)
// Should not throw and should produce a string (the [object Object] fallback).
assert.equal(typeof getLogEntries()[0].message, "string")
assert.equal(getLogEntries().length, 1)
})
test("each level helper is recorded with the matching level", () => {
clearLog()
log.debug("t", "d")
log.info("t", "i")
log.warn("t", "w")
log.error("t", "e")
assert.deepEqual(
getLogEntries().map((e) => e.level),
["debug", "info", "warn", "error"],
)
})
test("ring buffer is capped at 200 entries, keeping the most recent", () => {
clearLog()
for (let i = 0; i < 250; i++) log.info("t", `msg${i}`)
const entries = getLogEntries()
assert.equal(entries.length, 200)
// Oldest 50 dropped: first kept is msg50, last is msg249.
assert.equal(entries[0].message, "msg50")
assert.equal(entries[entries.length - 1].message, "msg249")
})
test("getLogEntries returns a copy — mutating it does not affect the buffer", () => {
clearLog()
log.info("t", "a")
const snapshot = getLogEntries()
snapshot.push({ ts: 0, level: "info", tag: "x", message: "injected" } as LogEntry)
assert.equal(getLogEntries().length, 1)
})
test("clearLog empties the buffer", () => {
clearLog()
log.info("t", "a")
log.info("t", "b")
assert.equal(getLogEntries().length, 2)
clearLog()
assert.equal(getLogEntries().length, 0)
})
test("formatLogLines renders ISO time, padded level, tag, and message", () => {
const entries: LogEntry[] = [
{ ts: Date.UTC(2026, 0, 2, 3, 4, 5, 678), level: "info", tag: "net", message: "hello" },
{ ts: Date.UTC(2026, 0, 2, 3, 4, 6, 0), level: "error", tag: "db", message: "boom" },
]
const out = formatLogLines(entries)
const lines = out.split("\n")
assert.equal(lines.length, 2)
assert.equal(lines[0], "03:04:05.678 INFO [net] hello")
assert.equal(lines[1], "03:04:06.000 ERROR [db] boom")
})
test("formatLogLines of an empty list is an empty string", () => {
assert.equal(formatLogLines([]), "")
})

View File

@@ -3,6 +3,7 @@
// but works in React Native environment // but works in React Native environment
// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE // expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
import { fetch as expoFetch } from "expo/fetch" import { fetch as expoFetch } from "expo/fetch"
import { buildRequestHeaders } from "./headers"
export interface ClientConfig { export interface ClientConfig {
baseUrl: string baseUrl: string
@@ -154,21 +155,7 @@ export interface HealthResponse {
const REQUEST_TIMEOUT_MS = 30_000 const REQUEST_TIMEOUT_MS = 30_000
function createHeaders(config: ClientConfig): HeadersInit { function createHeaders(config: ClientConfig): HeadersInit {
const headers: HeadersInit = { return buildRequestHeaders(config)
"Content-Type": "application/json",
}
if (config.directory) {
const encoded = /[^\x00-\x7F]/.test(config.directory) ? encodeURIComponent(config.directory) : config.directory
headers["x-opencode-directory"] = encoded
}
if (config.auth) {
const credentials = btoa(`${config.auth.username}:${config.auth.password}`)
headers["Authorization"] = `Basic ${credentials}`
}
return headers
} }
async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> { async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {