diff --git a/src/lib/headers.test.ts b/src/lib/headers.test.ts new file mode 100644 index 0000000..ea01f59 --- /dev/null +++ b/src/lib/headers.test.ts @@ -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) +}) diff --git a/src/lib/headers.ts b/src/lib/headers.ts new file mode 100644 index 0000000..3ffddb1 --- /dev/null +++ b/src/lib/headers.ts @@ -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 { + const headers: Record = { + "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 +} diff --git a/src/lib/logbuffer.test.ts b/src/lib/logbuffer.test.ts new file mode 100644 index 0000000..8bb9794 --- /dev/null +++ b/src/lib/logbuffer.test.ts @@ -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 = {} + 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([]), "") +}) diff --git a/src/lib/sdk.ts b/src/lib/sdk.ts index 5c9e1d7..33cfaa5 100644 --- a/src/lib/sdk.ts +++ b/src/lib/sdk.ts @@ -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(config: ClientConfig, path: string, options: RequestInit = {}): Promise {