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:
59
src/lib/headers.test.ts
Normal file
59
src/lib/headers.test.ts
Normal 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
33
src/lib/headers.ts
Normal 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
87
src/lib/logbuffer.test.ts
Normal 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([]), "")
|
||||
})
|
||||
@@ -3,6 +3,7 @@
|
||||
// but works in React Native environment
|
||||
// expo/fetch provides WinterCG-compliant fetch with ReadableStream support for SSE
|
||||
import { fetch as expoFetch } from "expo/fetch"
|
||||
import { buildRequestHeaders } from "./headers"
|
||||
|
||||
export interface ClientConfig {
|
||||
baseUrl: string
|
||||
@@ -154,21 +155,7 @@ export interface HealthResponse {
|
||||
const REQUEST_TIMEOUT_MS = 30_000
|
||||
|
||||
function createHeaders(config: ClientConfig): HeadersInit {
|
||||
const headers: HeadersInit = {
|
||||
"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
|
||||
return buildRequestHeaders(config)
|
||||
}
|
||||
|
||||
async function request<T>(config: ClientConfig, path: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
Reference in New Issue
Block a user