Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
185 changes: 185 additions & 0 deletions packages/opencode/test/cli/stats.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
/**
* Tests for `altimate-code stats` display formatting.
*
* displayStats() is the primary user-facing output for the stats command.
* formatNumber (module-private) converts token counts to human-readable
* format (e.g., 1500 → "1.5K"). These tests verify formatting via the
* exported displayStats function to catch regressions in CLI output.
*/
import { describe, test, expect } from "bun:test"
import { displayStats } from "../../src/cli/cmd/stats"

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Capture console.log output from a synchronous function. */
function captureOutput(fn: () => void): string {
const lines: string[] = []
const origLog = console.log
// displayStats also uses process.stdout.write for ANSI cursor movement
// in the model-usage section — we skip that branch by not passing modelLimit.
console.log = (...args: unknown[]) => lines.push(args.join(" "))
try {
fn()
} finally {
console.log = origLog
}
return lines.join("\n")
}

/** Minimal valid SessionStats — all zeroes. */
function emptyStats() {
return {
totalSessions: 0,
totalMessages: 0,
totalCost: 0,
totalTokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
toolUsage: {} as Record<string, number>,
modelUsage: {} as Record<string, { messages: number; tokens: { input: number; output: number; cache: { read: number; write: number } }; cost: number }>,
dateRange: { earliest: Date.now(), latest: Date.now() },
days: 1,
costPerDay: 0,
tokensPerSession: 0,
medianTokensPerSession: 0,
}
}

// ---------------------------------------------------------------------------
// formatNumber via displayStats
// ---------------------------------------------------------------------------

describe("stats: formatNumber rendering", () => {
test("values under 1000 display as plain integer", () => {
const stats = emptyStats()
stats.totalTokens.input = 999
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("999")
// Should not be formatted with K or M suffix
expect(out).not.toMatch(/999.*K/)
})

test("exactly 1000 displays as 1.0K", () => {
const stats = emptyStats()
stats.totalTokens.input = 1000
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("1.0K")
})

test("1500 displays as 1.5K", () => {
const stats = emptyStats()
stats.totalTokens.input = 1500
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("1.5K")
})

test("exactly 1000000 displays as 1.0M", () => {
const stats = emptyStats()
stats.totalTokens.input = 1_000_000
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("1.0M")
})

test("2500000 displays as 2.5M", () => {
const stats = emptyStats()
stats.totalTokens.input = 2_500_000
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("2.5M")
})

test("zero displays as 0", () => {
const stats = emptyStats()
const out = captureOutput(() => displayStats(stats))
// Input line should show 0, not "0K" or empty
expect(out).toMatch(/Input\s+0\s/)
})
})

// ---------------------------------------------------------------------------
// displayStats: cost and NaN safety
// ---------------------------------------------------------------------------

describe("stats: cost display safety", () => {
test("zero cost renders as $0.00, never NaN", () => {
const stats = emptyStats()
const out = captureOutput(() => displayStats(stats))
expect(out).not.toContain("NaN")
expect(out).toContain("$0.00")
})

test("fractional cost renders with two decimal places", () => {
const stats = emptyStats()
stats.totalCost = 1.234
stats.costPerDay = 0.617
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("$1.23")
expect(out).toContain("$0.62")
})
})

// ---------------------------------------------------------------------------
// displayStats: tool usage rendering
// ---------------------------------------------------------------------------

describe("stats: tool usage display", () => {
test("tool usage shows bar chart with percentages", () => {
const stats = emptyStats()
stats.toolUsage = { read: 50, write: 30, bash: 20 }
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("TOOL USAGE")
expect(out).toContain("read")
expect(out).toContain("write")
expect(out).toContain("bash")
// Percentages should be present
expect(out).toContain("%")
})

test("tool limit restricts number of tools shown", () => {
const stats = emptyStats()
stats.toolUsage = { read: 50, write: 30, bash: 20, edit: 10, glob: 5 }
const out = captureOutput(() => displayStats(stats, 2))
// Only top 2 tools should appear (read and write by count)
expect(out).toContain("read")
expect(out).toContain("write")
expect(out).not.toContain("glob")
})

test("empty tool usage omits TOOL USAGE section", () => {
const stats = emptyStats()
stats.toolUsage = {}
const out = captureOutput(() => displayStats(stats))
expect(out).not.toContain("TOOL USAGE")
})

test("long tool names are truncated", () => {
const stats = emptyStats()
stats.toolUsage = { "a_very_long_tool_name_that_exceeds_limit": 10 }
const out = captureOutput(() => displayStats(stats))
// Tool name should be truncated to fit the column
expect(out).toContain("..")
})
})

// ---------------------------------------------------------------------------
// displayStats: overview section
// ---------------------------------------------------------------------------

describe("stats: overview section", () => {
test("renders session and message counts", () => {
const stats = emptyStats()
stats.totalSessions = 42
stats.totalMessages = 1337
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("OVERVIEW")
expect(out).toContain("42")
expect(out).toContain("1,337")
})

test("renders box-drawing borders", () => {
const stats = emptyStats()
const out = captureOutput(() => displayStats(stats))
expect(out).toContain("┌")
expect(out).toContain("┘")
expect(out).toContain("│")
})
})
137 changes: 137 additions & 0 deletions packages/opencode/test/mcp/oauth-callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
/**
* Tests for MCP OAuth callback server — XSS prevention and HTTP behavior.
*
* The OAuth callback page renders error messages from external MCP servers.
* If escapeHtml (module-private) fails to sanitize these strings, a malicious
* server could inject scripts into the user's browser via error_description.
*
* Tests exercise the server at the HTTP level since escapeHtml is not exported.
*/
import { describe, test, expect, afterEach, beforeEach } from "bun:test"

const { McpOAuthCallback } = await import("../../src/mcp/oauth-callback")
const { OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } = await import("../../src/mcp/oauth-provider")

const BASE_URL = `http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`

beforeEach(async () => {
// Ensure clean state — stop any leftover server
await McpOAuthCallback.stop()
await McpOAuthCallback.ensureRunning()
})

afterEach(async () => {
await McpOAuthCallback.stop()
})

// ---------------------------------------------------------------------------
// XSS prevention
// ---------------------------------------------------------------------------

describe("OAuth callback: XSS prevention in error page", () => {
test("escapes <script> tags in error_description", async () => {
const xss = "<script>alert(1)</script>"
const url = `${BASE_URL}?error=access_denied&error_description=${encodeURIComponent(xss)}&state=test-state`
const res = await fetch(url)
const body = await res.text()

// The raw <script> must never appear in the response
expect(body).not.toContain("<script>")
expect(body).toContain("&lt;script&gt;")
})

test("escapes HTML entities in error_description", async () => {
const payload = 'foo & bar < baz > "qux"'
const url = `${BASE_URL}?error=access_denied&error_description=${encodeURIComponent(payload)}&state=test-state`
const res = await fetch(url)
const body = await res.text()

expect(body).toContain("foo &amp; bar")
expect(body).toContain("&lt; baz &gt;") // < and > escaped
expect(body).toContain("&quot;qux&quot;")
})

test("escapes img onerror XSS vector", async () => {
const xss = '<img src=x onerror="alert(1)">'
const url = `${BASE_URL}?error=access_denied&error_description=${encodeURIComponent(xss)}&state=test-state`
const res = await fetch(url)
const body = await res.text()

expect(body).not.toContain("<img")
expect(body).toContain("&lt;img")
})

test("escapes event handler injection", async () => {
const xss = '" onmouseover="alert(1)" data-x="'
const url = `${BASE_URL}?error=access_denied&error_description=${encodeURIComponent(xss)}&state=test-state`
const res = await fetch(url)
const body = await res.text()

// Double quotes must be escaped
expect(body).toContain("&quot;")
expect(body).not.toContain('onmouseover="alert')
})
})

// ---------------------------------------------------------------------------
// HTTP behavior
// ---------------------------------------------------------------------------

describe("OAuth callback: HTTP behavior", () => {
test("returns 404 for non-callback paths", async () => {
const res = await fetch(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}/not-a-callback`)
expect(res.status).toBe(404)
})

test("returns 400 when state parameter is missing", async () => {
const url = `${BASE_URL}?error=access_denied&error_description=test`
const res = await fetch(url)
expect(res.status).toBe(400)
const body = await res.text()
expect(body).toContain("Missing required state parameter")
})

test("returns 400 when code is missing (no error)", async () => {
const url = `${BASE_URL}?state=some-state`
const res = await fetch(url)
expect(res.status).toBe(400)
const body = await res.text()
expect(body).toContain("No authorization code")
})

test("returns HTML content type for error pages", async () => {
const url = `${BASE_URL}?error=access_denied&error_description=test&state=test-state`
const res = await fetch(url)
expect(res.headers.get("content-type")).toContain("text/html")
})

test("invalid state returns error about CSRF", async () => {
// Register no pending auth, so any state is invalid
const url = `${BASE_URL}?code=test-code&state=unknown-state`
const res = await fetch(url)
expect(res.status).toBe(400)
const body = await res.text()
expect(body).toContain("Invalid or expired state")
})
})

// ---------------------------------------------------------------------------
// Server lifecycle
// ---------------------------------------------------------------------------

describe("OAuth callback: server lifecycle", () => {
test("isRunning returns true after ensureRunning", () => {
expect(McpOAuthCallback.isRunning()).toBe(true)
})

test("isRunning returns false after stop", async () => {
await McpOAuthCallback.stop()
expect(McpOAuthCallback.isRunning()).toBe(false)
})

test("ensureRunning is idempotent", async () => {
// Server already running from beforeEach
await McpOAuthCallback.ensureRunning()
expect(McpOAuthCallback.isRunning()).toBe(true)
})
})
Loading