diff --git a/.opencode/skills/github-triage/SKILL.md b/.opencode/skills/github-triage/SKILL.md
index ae534022a..371ce750c 100644
--- a/.opencode/skills/github-triage/SKILL.md
+++ b/.opencode/skills/github-triage/SKILL.md
@@ -136,7 +136,36 @@ fi
---
-## Phase 3: Spawn Subagents
+## Phase 3: Spawn Subagents (Individual Tool Calls)
+
+**CRITICAL: Create tasks ONE BY ONE using individual `task_create` tool calls. NEVER batch or script.**
+
+For each item, execute these steps sequentially:
+
+### Step 3.1: Create Task Record
+```typescript
+task_create(
+ subject="Triage: #{number} {title}",
+ description="GitHub {issue|PR} triage analysis - {type}",
+ metadata={"type": "{ISSUE_QUESTION|ISSUE_BUG|ISSUE_FEATURE|ISSUE_OTHER|PR_BUGFIX|PR_OTHER}", "number": {number}}
+)
+```
+
+### Step 3.2: Spawn Analysis Subagent (Background)
+```typescript
+task(
+ category="quick",
+ run_in_background=true,
+ load_skills=[],
+ prompt=SUBAGENT_PROMPT
+)
+```
+
+**ABSOLUTE RULES for Subagents:**
+- **ONLY ANALYZE** - Never take action on GitHub (no comments, merges, closes)
+- **READ-ONLY** - Use tools only for reading code/GitHub data
+- **WRITE REPORT ONLY** - Output goes to `{REPORT_DIR}/{issue|pr}-{number}.md` via Write tool
+- **EVIDENCE REQUIRED** - Every claim must have GitHub permalink as proof
```
For each item:
@@ -170,6 +199,7 @@ ABSOLUTE RULES (violating ANY = critical failure):
- Your ONLY writable output: {REPORT_DIR}/{issue|pr}-{number}.md via the Write tool
```
+
---
### ISSUE_QUESTION
diff --git a/src/agents/builtin-agents/resolve-file-uri.test.ts b/src/agents/builtin-agents/resolve-file-uri.test.ts
index 9c045babd..22e4bd88e 100644
--- a/src/agents/builtin-agents/resolve-file-uri.test.ts
+++ b/src/agents/builtin-agents/resolve-file-uri.test.ts
@@ -1,20 +1,32 @@
-import { afterAll, beforeAll, describe, expect, test } from "bun:test"
+import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
-import { homedir, tmpdir } from "node:os"
+import * as os from "node:os"
+import { tmpdir } from "node:os"
import { join } from "node:path"
-import { resolvePromptAppend } from "./resolve-file-uri"
+
+const originalHomedir = os.homedir.bind(os)
+let mockedHomeDir = ""
+let moduleImportCounter = 0
+let resolvePromptAppend: typeof import("./resolve-file-uri").resolvePromptAppend
+
+mock.module("node:os", () => ({
+ ...os,
+ homedir: () => mockedHomeDir || originalHomedir(),
+}))
describe("resolvePromptAppend", () => {
const fixtureRoot = join(tmpdir(), `resolve-file-uri-${Date.now()}`)
const configDir = join(fixtureRoot, "config")
- const homeFixtureDir = join(homedir(), `.resolve-file-uri-home-${Date.now()}`)
+ const homeFixtureRoot = join(fixtureRoot, "home")
+ const homeFixtureDir = join(homeFixtureRoot, "fixture-home")
const absoluteFilePath = join(fixtureRoot, "absolute.txt")
const relativeFilePath = join(configDir, "relative.txt")
const spacedFilePath = join(fixtureRoot, "with space.txt")
const homeFilePath = join(homeFixtureDir, "home.txt")
- beforeAll(() => {
+ beforeAll(async () => {
+ mockedHomeDir = homeFixtureRoot
mkdirSync(fixtureRoot, { recursive: true })
mkdirSync(configDir, { recursive: true })
mkdirSync(homeFixtureDir, { recursive: true })
@@ -23,11 +35,14 @@ describe("resolvePromptAppend", () => {
writeFileSync(relativeFilePath, "relative-content", "utf8")
writeFileSync(spacedFilePath, "encoded-content", "utf8")
writeFileSync(homeFilePath, "home-content", "utf8")
+
+ moduleImportCounter += 1
+ ;({ resolvePromptAppend } = await import(`./resolve-file-uri?test=${moduleImportCounter}`))
})
afterAll(() => {
rmSync(fixtureRoot, { recursive: true, force: true })
- rmSync(homeFixtureDir, { recursive: true, force: true })
+ mock.restore()
})
test("returns non-file URI strings unchanged", () => {
@@ -65,7 +80,7 @@ describe("resolvePromptAppend", () => {
test("resolves home directory URI path", () => {
//#given
- const input = `file://~/${homeFixtureDir.split("/").pop()}/home.txt`
+ const input = "file://~/fixture-home/home.txt"
//#when
const resolved = resolvePromptAppend(input)
diff --git a/src/shared/connected-providers-cache.test.ts b/src/shared/connected-providers-cache.test.ts
index 10774c9b3..183f9c712 100644
--- a/src/shared/connected-providers-cache.test.ts
+++ b/src/shared/connected-providers-cache.test.ts
@@ -1,45 +1,30 @@
///
-import { beforeAll, beforeEach, afterEach, describe, expect, mock, test } from "bun:test"
+import { beforeEach, afterEach, describe, expect, test } from "bun:test"
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
-import * as dataPath from "./data-path"
+import {
+ createConnectedProvidersCacheStore,
+} from "./connected-providers-cache"
+let fakeUserCacheRoot = ""
let testCacheDir = ""
-let moduleImportCounter = 0
-
-const getOmoOpenCodeCacheDirMock = mock(() => testCacheDir)
-
-let updateConnectedProvidersCache: typeof import("./connected-providers-cache").updateConnectedProvidersCache
-let readProviderModelsCache: typeof import("./connected-providers-cache").readProviderModelsCache
-
-async function prepareConnectedProvidersCacheTestModule(): Promise {
- testCacheDir = mkdtempSync(join(tmpdir(), "connected-providers-cache-test-"))
- getOmoOpenCodeCacheDirMock.mockClear()
- mock.module("./data-path", () => ({
- getOmoOpenCodeCacheDir: getOmoOpenCodeCacheDirMock,
- }))
- moduleImportCounter += 1
- ;({ updateConnectedProvidersCache, readProviderModelsCache } = await import(`./connected-providers-cache?test=${moduleImportCounter}`))
-}
+let testCacheStore: ReturnType
describe("updateConnectedProvidersCache", () => {
- beforeAll(() => {
- mock.restore()
- })
-
- beforeEach(async () => {
- mock.restore()
- await prepareConnectedProvidersCacheTestModule()
+ beforeEach(() => {
+ fakeUserCacheRoot = mkdtempSync(join(tmpdir(), "connected-providers-user-cache-"))
+ testCacheDir = join(fakeUserCacheRoot, "oh-my-opencode")
+ testCacheStore = createConnectedProvidersCacheStore(() => testCacheDir)
})
afterEach(() => {
- mock.restore()
- if (existsSync(testCacheDir)) {
- rmSync(testCacheDir, { recursive: true, force: true })
+ if (existsSync(fakeUserCacheRoot)) {
+ rmSync(fakeUserCacheRoot, { recursive: true, force: true })
}
+ fakeUserCacheRoot = ""
testCacheDir = ""
})
@@ -76,10 +61,10 @@ describe("updateConnectedProvidersCache", () => {
}
//#when
- await updateConnectedProvidersCache(mockClient)
+ await testCacheStore.updateConnectedProvidersCache(mockClient)
//#then
- const cache = readProviderModelsCache()
+ const cache = testCacheStore.readProviderModelsCache()
expect(cache).not.toBeNull()
expect(cache!.connected).toEqual(["openai", "anthropic"])
expect(cache!.models).toEqual({
@@ -109,10 +94,10 @@ describe("updateConnectedProvidersCache", () => {
}
//#when
- await updateConnectedProvidersCache(mockClient)
+ await testCacheStore.updateConnectedProvidersCache(mockClient)
//#then
- const cache = readProviderModelsCache()
+ const cache = testCacheStore.readProviderModelsCache()
expect(cache).not.toBeNull()
expect(cache!.models).toEqual({})
})
@@ -130,10 +115,10 @@ describe("updateConnectedProvidersCache", () => {
}
//#when
- await updateConnectedProvidersCache(mockClient)
+ await testCacheStore.updateConnectedProvidersCache(mockClient)
//#then
- const cache = readProviderModelsCache()
+ const cache = testCacheStore.readProviderModelsCache()
expect(cache).not.toBeNull()
expect(cache!.models).toEqual({})
})
@@ -143,25 +128,44 @@ describe("updateConnectedProvidersCache", () => {
const mockClient = {}
//#when
- await updateConnectedProvidersCache(mockClient)
+ await testCacheStore.updateConnectedProvidersCache(mockClient)
//#then
- const cache = readProviderModelsCache()
+ const cache = testCacheStore.readProviderModelsCache()
expect(cache).toBeNull()
})
- test("does not remove the user's real cache directory during test setup", async () => {
+ test("does not remove unrelated files in the cache directory", async () => {
//#given
- const realCacheDir = join(dataPath.getCacheDir(), "oh-my-opencode")
+ const realCacheDir = join(fakeUserCacheRoot, "oh-my-opencode")
const sentinelPath = join(realCacheDir, "connected-providers-cache.test-sentinel.json")
mkdirSync(realCacheDir, { recursive: true })
writeFileSync(sentinelPath, JSON.stringify({ keep: true }))
+ const mockClient = {
+ provider: {
+ list: async () => ({
+ data: {
+ connected: ["openai"],
+ all: [
+ {
+ id: "openai",
+ models: {
+ "gpt-5.4": { id: "gpt-5.4" },
+ },
+ },
+ ],
+ },
+ }),
+ },
+ }
+
try {
//#when
- await prepareConnectedProvidersCacheTestModule()
+ await testCacheStore.updateConnectedProvidersCache(mockClient)
//#then
+ expect(testCacheStore.readConnectedProvidersCache()).toEqual(["openai"])
expect(existsSync(sentinelPath)).toBe(true)
expect(readFileSync(sentinelPath, "utf-8")).toBe(JSON.stringify({ keep: true }))
} finally {
diff --git a/src/shared/connected-providers-cache.ts b/src/shared/connected-providers-cache.ts
index 9a47ba5a9..b86926a1c 100644
--- a/src/shared/connected-providers-cache.ts
+++ b/src/shared/connected-providers-cache.ts
@@ -25,172 +25,177 @@ interface ProviderModelsCache {
updatedAt: string
}
-function getCacheFilePath(filename: string): string {
- return join(dataPath.getOmoOpenCodeCacheDir(), filename)
-}
-
-function ensureCacheDir(): void {
- const cacheDir = dataPath.getOmoOpenCodeCacheDir()
- if (!existsSync(cacheDir)) {
- mkdirSync(cacheDir, { recursive: true })
- }
-}
-
-/**
- * Read the connected providers cache.
- * Returns the list of connected provider IDs, or null if cache doesn't exist.
- */
-export function readConnectedProvidersCache(): string[] | null {
- const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
-
- if (!existsSync(cacheFile)) {
- log("[connected-providers-cache] Cache file not found", { cacheFile })
- return null
+export function createConnectedProvidersCacheStore(
+ getCacheDir: () => string = dataPath.getOmoOpenCodeCacheDir
+) {
+ function getCacheFilePath(filename: string): string {
+ return join(getCacheDir(), filename)
}
- try {
- const content = readFileSync(cacheFile, "utf-8")
- const data = JSON.parse(content) as ConnectedProvidersCache
- log("[connected-providers-cache] Read cache", { count: data.connected.length, updatedAt: data.updatedAt })
- return data.connected
- } catch (err) {
- log("[connected-providers-cache] Error reading cache", { error: String(err) })
- return null
- }
-}
-
-/**
- * Check if connected providers cache exists.
- */
-export function hasConnectedProvidersCache(): boolean {
- const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
- return existsSync(cacheFile)
-}
-
-/**
- * Write the connected providers cache.
- */
-function writeConnectedProvidersCache(connected: string[]): void {
- ensureCacheDir()
- const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
-
- const data: ConnectedProvidersCache = {
- connected,
- updatedAt: new Date().toISOString(),
+ function ensureCacheDir(): void {
+ const cacheDir = getCacheDir()
+ if (!existsSync(cacheDir)) {
+ mkdirSync(cacheDir, { recursive: true })
+ }
}
- try {
- writeFileSync(cacheFile, JSON.stringify(data, null, 2))
- log("[connected-providers-cache] Cache written", { count: connected.length })
- } catch (err) {
- log("[connected-providers-cache] Error writing cache", { error: String(err) })
- }
-}
+ function readConnectedProvidersCache(): string[] | null {
+ const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
-/**
- * Read the provider-models cache.
- * Returns the cache data, or null if cache doesn't exist.
- */
-export function readProviderModelsCache(): ProviderModelsCache | null {
- const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
-
- if (!existsSync(cacheFile)) {
- log("[connected-providers-cache] Provider-models cache file not found", { cacheFile })
- return null
- }
-
- try {
- const content = readFileSync(cacheFile, "utf-8")
- const data = JSON.parse(content) as ProviderModelsCache
- log("[connected-providers-cache] Read provider-models cache", {
- providerCount: Object.keys(data.models).length,
- updatedAt: data.updatedAt
- })
- return data
- } catch (err) {
- log("[connected-providers-cache] Error reading provider-models cache", { error: String(err) })
- return null
- }
-}
-
-/**
- * Check if provider-models cache exists.
- */
-export function hasProviderModelsCache(): boolean {
- const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
- return existsSync(cacheFile)
-}
-
-/**
- * Write the provider-models cache.
- */
-export function writeProviderModelsCache(data: { models: Record; connected: string[] }): void {
- ensureCacheDir()
- const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
-
- const cacheData: ProviderModelsCache = {
- ...data,
- updatedAt: new Date().toISOString(),
- }
-
- try {
- writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2))
- log("[connected-providers-cache] Provider-models cache written", {
- providerCount: Object.keys(data.models).length
- })
- } catch (err) {
- log("[connected-providers-cache] Error writing provider-models cache", { error: String(err) })
- }
-}
-
-/**
- * Update the connected providers cache by fetching from the client.
- * Also updates the provider-models cache with model lists per provider.
- */
-export async function updateConnectedProvidersCache(client: {
- provider?: {
- list?: () => Promise<{
- data?: {
- connected?: string[]
- all?: Array<{ id: string; models?: Record }>
- }
- }>
- }
-}): Promise {
- if (!client?.provider?.list) {
- log("[connected-providers-cache] client.provider.list not available")
- return
- }
-
- try {
- const result = await client.provider.list()
- const connected = result.data?.connected ?? []
- log("[connected-providers-cache] Fetched connected providers", { count: connected.length, providers: connected })
-
- writeConnectedProvidersCache(connected)
-
- const modelsByProvider: Record = {}
- const allProviders = result.data?.all ?? []
-
- for (const provider of allProviders) {
- if (provider.models) {
- const modelIds = Object.keys(provider.models)
- if (modelIds.length > 0) {
- modelsByProvider[provider.id] = modelIds
- }
- }
+ if (!existsSync(cacheFile)) {
+ log("[connected-providers-cache] Cache file not found", { cacheFile })
+ return null
}
- log("[connected-providers-cache] Extracted models from provider list", {
- providerCount: Object.keys(modelsByProvider).length,
- totalModels: Object.values(modelsByProvider).reduce((sum, ids) => sum + ids.length, 0),
- })
+ try {
+ const content = readFileSync(cacheFile, "utf-8")
+ const data = JSON.parse(content) as ConnectedProvidersCache
+ log("[connected-providers-cache] Read cache", { count: data.connected.length, updatedAt: data.updatedAt })
+ return data.connected
+ } catch (err) {
+ log("[connected-providers-cache] Error reading cache", { error: String(err) })
+ return null
+ }
+ }
- writeProviderModelsCache({
- models: modelsByProvider,
+ function hasConnectedProvidersCache(): boolean {
+ const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
+ return existsSync(cacheFile)
+ }
+
+ function writeConnectedProvidersCache(connected: string[]): void {
+ ensureCacheDir()
+ const cacheFile = getCacheFilePath(CONNECTED_PROVIDERS_CACHE_FILE)
+
+ const data: ConnectedProvidersCache = {
connected,
- })
- } catch (err) {
- log("[connected-providers-cache] Error updating cache", { error: String(err) })
+ updatedAt: new Date().toISOString(),
+ }
+
+ try {
+ writeFileSync(cacheFile, JSON.stringify(data, null, 2))
+ log("[connected-providers-cache] Cache written", { count: connected.length })
+ } catch (err) {
+ log("[connected-providers-cache] Error writing cache", { error: String(err) })
+ }
+ }
+
+ function readProviderModelsCache(): ProviderModelsCache | null {
+ const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
+
+ if (!existsSync(cacheFile)) {
+ log("[connected-providers-cache] Provider-models cache file not found", { cacheFile })
+ return null
+ }
+
+ try {
+ const content = readFileSync(cacheFile, "utf-8")
+ const data = JSON.parse(content) as ProviderModelsCache
+ log("[connected-providers-cache] Read provider-models cache", {
+ providerCount: Object.keys(data.models).length,
+ updatedAt: data.updatedAt,
+ })
+ return data
+ } catch (err) {
+ log("[connected-providers-cache] Error reading provider-models cache", { error: String(err) })
+ return null
+ }
+ }
+
+ function hasProviderModelsCache(): boolean {
+ const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
+ return existsSync(cacheFile)
+ }
+
+ function writeProviderModelsCache(data: { models: Record; connected: string[] }): void {
+ ensureCacheDir()
+ const cacheFile = getCacheFilePath(PROVIDER_MODELS_CACHE_FILE)
+
+ const cacheData: ProviderModelsCache = {
+ ...data,
+ updatedAt: new Date().toISOString(),
+ }
+
+ try {
+ writeFileSync(cacheFile, JSON.stringify(cacheData, null, 2))
+ log("[connected-providers-cache] Provider-models cache written", {
+ providerCount: Object.keys(data.models).length,
+ })
+ } catch (err) {
+ log("[connected-providers-cache] Error writing provider-models cache", { error: String(err) })
+ }
+ }
+
+ async function updateConnectedProvidersCache(client: {
+ provider?: {
+ list?: () => Promise<{
+ data?: {
+ connected?: string[]
+ all?: Array<{ id: string; models?: Record }>
+ }
+ }>
+ }
+ }): Promise {
+ if (!client?.provider?.list) {
+ log("[connected-providers-cache] client.provider.list not available")
+ return
+ }
+
+ try {
+ const result = await client.provider.list()
+ const connected = result.data?.connected ?? []
+ log("[connected-providers-cache] Fetched connected providers", {
+ count: connected.length,
+ providers: connected,
+ })
+
+ writeConnectedProvidersCache(connected)
+
+ const modelsByProvider: Record = {}
+ const allProviders = result.data?.all ?? []
+
+ for (const provider of allProviders) {
+ if (provider.models) {
+ const modelIds = Object.keys(provider.models)
+ if (modelIds.length > 0) {
+ modelsByProvider[provider.id] = modelIds
+ }
+ }
+ }
+
+ log("[connected-providers-cache] Extracted models from provider list", {
+ providerCount: Object.keys(modelsByProvider).length,
+ totalModels: Object.values(modelsByProvider).reduce((sum, ids) => sum + ids.length, 0),
+ })
+
+ writeProviderModelsCache({
+ models: modelsByProvider,
+ connected,
+ })
+ } catch (err) {
+ log("[connected-providers-cache] Error updating cache", { error: String(err) })
+ }
+ }
+
+ return {
+ readConnectedProvidersCache,
+ hasConnectedProvidersCache,
+ readProviderModelsCache,
+ hasProviderModelsCache,
+ writeProviderModelsCache,
+ updateConnectedProvidersCache,
}
}
+
+const defaultConnectedProvidersCacheStore = createConnectedProvidersCacheStore(
+ () => dataPath.getOmoOpenCodeCacheDir()
+)
+
+export const {
+ readConnectedProvidersCache,
+ hasConnectedProvidersCache,
+ readProviderModelsCache,
+ hasProviderModelsCache,
+ writeProviderModelsCache,
+ updateConnectedProvidersCache,
+} = defaultConnectedProvidersCacheStore
diff --git a/src/shared/port-utils.test.ts b/src/shared/port-utils.test.ts
index 34ef34b9f..3b1be1cf9 100644
--- a/src/shared/port-utils.test.ts
+++ b/src/shared/port-utils.test.ts
@@ -1,4 +1,4 @@
-import { describe, it, expect, beforeAll, afterAll } from "bun:test"
+import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"
import {
isPortAvailable,
findAvailablePort,
@@ -6,96 +6,283 @@ import {
DEFAULT_SERVER_PORT,
} from "./port-utils"
+const HOSTNAME = "127.0.0.1"
+const REAL_PORT_SEARCH_WINDOW = 200
+
+function supportsRealSocketBinding(): boolean {
+ try {
+ const server = Bun.serve({
+ port: 0,
+ hostname: HOSTNAME,
+ fetch: () => new Response("probe"),
+ })
+ server.stop(true)
+ return true
+ } catch {
+ return false
+ }
+}
+
+const canBindRealSockets = supportsRealSocketBinding()
+
describe("port-utils", () => {
- describe("isPortAvailable", () => {
- it("#given unused port #when checking availability #then returns true", async () => {
- const port = 59999
- const result = await isPortAvailable(port)
- expect(result).toBe(true)
- })
-
- it("#given port in use #when checking availability #then returns false", async () => {
- const port = 59998
- const blocker = Bun.serve({
+ if (canBindRealSockets) {
+ function startRealBlocker(port: number = 0) {
+ return Bun.serve({
port,
- hostname: "127.0.0.1",
+ hostname: HOSTNAME,
fetch: () => new Response("blocked"),
})
+ }
- try {
- const result = await isPortAvailable(port)
- expect(result).toBe(false)
- } finally {
- blocker.stop(true)
+ async function findContiguousAvailableStart(length: number): Promise {
+ const probe = startRealBlocker()
+ const seedPort = probe.port
+ probe.stop(true)
+
+ for (let candidate = seedPort; candidate < seedPort + REAL_PORT_SEARCH_WINDOW; candidate++) {
+ const checks = await Promise.all(
+ Array.from({ length }, async (_, offset) => isPortAvailable(candidate + offset, HOSTNAME))
+ )
+ if (checks.every(Boolean)) {
+ return candidate
+ }
}
- })
- })
- describe("findAvailablePort", () => {
- it("#given start port available #when finding port #then returns start port", async () => {
- const startPort = 59997
- const result = await findAvailablePort(startPort)
- expect(result).toBe(startPort)
- })
+ throw new Error(`Could not find ${length} contiguous available ports`)
+ }
- it("#given start port blocked #when finding port #then returns next available", async () => {
- const startPort = 59996
- const blocker = Bun.serve({
- port: startPort,
- hostname: "127.0.0.1",
- fetch: () => new Response("blocked"),
+ describe("with real sockets", () => {
+ describe("isPortAvailable", () => {
+ it("#given unused port #when checking availability #then returns true", async () => {
+ const blocker = startRealBlocker()
+ const port = blocker.port
+ blocker.stop(true)
+
+ const result = await isPortAvailable(port)
+ expect(result).toBe(true)
+ })
+
+ it("#given port in use #when checking availability #then returns false", async () => {
+ const blocker = startRealBlocker()
+ const port = blocker.port
+
+ try {
+ const result = await isPortAvailable(port)
+ expect(result).toBe(false)
+ } finally {
+ blocker.stop(true)
+ }
+ })
})
- try {
- const result = await findAvailablePort(startPort)
- expect(result).toBe(startPort + 1)
- } finally {
- blocker.stop(true)
- }
- })
+ describe("findAvailablePort", () => {
+ it("#given start port available #when finding port #then returns start port", async () => {
+ const startPort = await findContiguousAvailableStart(1)
+ const result = await findAvailablePort(startPort)
+ expect(result).toBe(startPort)
+ })
- it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
- const startPort = 59993
- const blockers = [
- Bun.serve({ port: startPort, hostname: "127.0.0.1", fetch: () => new Response() }),
- Bun.serve({ port: startPort + 1, hostname: "127.0.0.1", fetch: () => new Response() }),
- Bun.serve({ port: startPort + 2, hostname: "127.0.0.1", fetch: () => new Response() }),
- ]
+ it("#given start port blocked #when finding port #then returns next available", async () => {
+ const startPort = await findContiguousAvailableStart(2)
+ const blocker = startRealBlocker(startPort)
- try {
- const result = await findAvailablePort(startPort)
- expect(result).toBe(startPort + 3)
- } finally {
- blockers.forEach((b) => b.stop(true))
- }
- })
- })
+ try {
+ const result = await findAvailablePort(startPort)
+ expect(result).toBe(startPort + 1)
+ } finally {
+ blocker.stop(true)
+ }
+ })
- describe("getAvailableServerPort", () => {
- it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
- const preferredPort = 59990
- const result = await getAvailableServerPort(preferredPort)
- expect(result.port).toBe(preferredPort)
- expect(result.wasAutoSelected).toBe(false)
- })
+ it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
+ const startPort = await findContiguousAvailableStart(4)
+ const blockers = [
+ startRealBlocker(startPort),
+ startRealBlocker(startPort + 1),
+ startRealBlocker(startPort + 2),
+ ]
- it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
- const preferredPort = 59989
- const blocker = Bun.serve({
- port: preferredPort,
- hostname: "127.0.0.1",
- fetch: () => new Response("blocked"),
+ try {
+ const result = await findAvailablePort(startPort)
+ expect(result).toBe(startPort + 3)
+ } finally {
+ blockers.forEach((blocker) => blocker.stop(true))
+ }
+ })
})
- try {
- const result = await getAvailableServerPort(preferredPort)
- expect(result.port).toBeGreaterThan(preferredPort)
- expect(result.wasAutoSelected).toBe(true)
- } finally {
- blocker.stop(true)
- }
+ describe("getAvailableServerPort", () => {
+ it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
+ const preferredPort = await findContiguousAvailableStart(1)
+ const result = await getAvailableServerPort(preferredPort)
+ expect(result.port).toBe(preferredPort)
+ expect(result.wasAutoSelected).toBe(false)
+ })
+
+ it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
+ const preferredPort = await findContiguousAvailableStart(2)
+ const blocker = startRealBlocker(preferredPort)
+
+ try {
+ const result = await getAvailableServerPort(preferredPort)
+ expect(result.port).toBe(preferredPort + 1)
+ expect(result.wasAutoSelected).toBe(true)
+ } finally {
+ blocker.stop(true)
+ }
+ })
+ })
})
- })
+ } else {
+ const blockedSockets = new Set()
+ let serveSpy: ReturnType
+
+ function getSocketKey(port: number, hostname: string): string {
+ return `${hostname}:${port}`
+ }
+
+ beforeEach(() => {
+ blockedSockets.clear()
+ serveSpy = spyOn(Bun, "serve").mockImplementation(({ port, hostname }) => {
+ if (typeof port !== "number") {
+ throw new Error("Test expected numeric port")
+ }
+ const resolvedHostname = typeof hostname === "string" ? hostname : HOSTNAME
+ const socketKey = getSocketKey(port, resolvedHostname)
+
+ if (blockedSockets.has(socketKey)) {
+ const error = new Error(`Failed to start server. Is port ${port} in use?`) as Error & {
+ code?: string
+ syscall?: string
+ errno?: number
+ address?: string
+ port?: number
+ }
+ error.code = "EADDRINUSE"
+ error.syscall = "listen"
+ error.errno = 0
+ error.address = resolvedHostname
+ error.port = port
+ throw error
+ }
+
+ blockedSockets.add(socketKey)
+ return {
+ stop: (_force?: boolean) => {
+ blockedSockets.delete(socketKey)
+ },
+ } as { stop: (force?: boolean) => void }
+ })
+ })
+
+ afterEach(() => {
+ expect(blockedSockets.size).toBe(0)
+ serveSpy.mockRestore()
+ blockedSockets.clear()
+ })
+
+ describe("with mocked sockets fallback", () => {
+ describe("isPortAvailable", () => {
+ it("#given unused port #when checking availability #then returns true", async () => {
+ const port = 59999
+
+ const result = await isPortAvailable(port)
+ expect(result).toBe(true)
+ expect(blockedSockets.size).toBe(0)
+ })
+
+ it("#given port in use #when checking availability #then returns false", async () => {
+ const port = 59998
+ const blocker = Bun.serve({
+ port,
+ hostname: HOSTNAME,
+ fetch: () => new Response("blocked"),
+ })
+
+ try {
+ const result = await isPortAvailable(port)
+ expect(result).toBe(false)
+ } finally {
+ blocker.stop(true)
+ }
+ })
+
+ it("#given custom hostname #when checking availability #then passes hostname through to Bun.serve", async () => {
+ const hostname = "192.0.2.10"
+ await isPortAvailable(59995, hostname)
+
+ expect(serveSpy.mock.calls[0]?.[0]?.hostname).toBe(hostname)
+ })
+ })
+
+ describe("findAvailablePort", () => {
+ it("#given start port available #when finding port #then returns start port", async () => {
+ const startPort = 59997
+ const result = await findAvailablePort(startPort)
+ expect(result).toBe(startPort)
+ })
+
+ it("#given start port blocked #when finding port #then returns next available", async () => {
+ const startPort = 59996
+ const blocker = Bun.serve({
+ port: startPort,
+ hostname: HOSTNAME,
+ fetch: () => new Response("blocked"),
+ })
+
+ try {
+ const result = await findAvailablePort(startPort)
+ expect(result).toBe(startPort + 1)
+ } finally {
+ blocker.stop(true)
+ }
+ })
+
+ it("#given multiple ports blocked #when finding port #then skips all blocked", async () => {
+ const startPort = 59993
+ const blockers = [
+ Bun.serve({ port: startPort, hostname: HOSTNAME, fetch: () => new Response() }),
+ Bun.serve({ port: startPort + 1, hostname: HOSTNAME, fetch: () => new Response() }),
+ Bun.serve({ port: startPort + 2, hostname: HOSTNAME, fetch: () => new Response() }),
+ ]
+
+ try {
+ const result = await findAvailablePort(startPort)
+ expect(result).toBe(startPort + 3)
+ } finally {
+ blockers.forEach((blocker) => blocker.stop(true))
+ }
+ })
+ })
+
+ describe("getAvailableServerPort", () => {
+ it("#given preferred port available #when getting port #then returns preferred with wasAutoSelected=false", async () => {
+ const preferredPort = 59990
+ const result = await getAvailableServerPort(preferredPort)
+ expect(result.port).toBe(preferredPort)
+ expect(result.wasAutoSelected).toBe(false)
+ })
+
+ it("#given preferred port blocked #when getting port #then returns alternative with wasAutoSelected=true", async () => {
+ const preferredPort = 59989
+ const blocker = Bun.serve({
+ port: preferredPort,
+ hostname: HOSTNAME,
+ fetch: () => new Response("blocked"),
+ })
+
+ try {
+ const result = await getAvailableServerPort(preferredPort)
+ expect(result.port).toBe(preferredPort + 1)
+ expect(result.wasAutoSelected).toBe(true)
+ } finally {
+ blocker.stop(true)
+ }
+ })
+ })
+ })
+ }
describe("DEFAULT_SERVER_PORT", () => {
it("#given constant #when accessed #then returns 4096", () => {