fix: 修正 logo 中 N 和 G 字母造型
N 添加对角线笔画(█▄ █),G 添加内横杠(█ ▀█), 避免与 O 字母造型雷同。同步更新 ui.ts 中的硬编码 wordmark。
This commit is contained in:
14
packages/opencode/test/util/data-url.test.ts
Normal file
14
packages/opencode/test/util/data-url.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodeDataUrl } from "../../src/util/data-url"
|
||||
|
||||
describe("decodeDataUrl", () => {
|
||||
test("decodes base64 data URLs", () => {
|
||||
const body = '{\n "ok": true\n}\n'
|
||||
const url = `data:text/plain;base64,${Buffer.from(body).toString("base64")}`
|
||||
expect(decodeDataUrl(url)).toBe(body)
|
||||
})
|
||||
|
||||
test("decodes plain data URLs", () => {
|
||||
expect(decodeDataUrl("data:text/plain,hello%20world")).toBe("hello world")
|
||||
})
|
||||
})
|
||||
16
packages/opencode/test/util/error.test.ts
Normal file
16
packages/opencode/test/util/error.test.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { MessageError } from "../../src/session/message-error"
|
||||
|
||||
describe("util.error", () => {
|
||||
test("schema-backed named errors are real NamedError instances", () => {
|
||||
const error = new MessageError.AuthError({ providerID: "anthropic", message: "boom" })
|
||||
|
||||
expect(error).toBeInstanceOf(NamedError)
|
||||
expect(error.toObject()).toEqual({ name: "ProviderAuthError", data: { providerID: "anthropic", message: "boom" } })
|
||||
})
|
||||
|
||||
test("named errors without fields serialize data", () => {
|
||||
expect(new MessageError.OutputLengthError({}).toObject()).toEqual({ name: "MessageOutputLengthError", data: {} })
|
||||
})
|
||||
})
|
||||
656
packages/opencode/test/util/filesystem.test.ts
Normal file
656
packages/opencode/test/util/filesystem.test.ts
Normal file
@@ -0,0 +1,656 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("filesystem", () => {
|
||||
describe("exists()", () => {
|
||||
test("returns true for existing file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
expect(await Filesystem.exists(filepath)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for non-existent file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "does-not-exist.txt")
|
||||
|
||||
expect(await Filesystem.exists(filepath)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for existing directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirpath = path.join(tmp.path, "subdir")
|
||||
await fs.mkdir(dirpath)
|
||||
|
||||
expect(await Filesystem.exists(dirpath)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDir()", () => {
|
||||
test("returns true for directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirpath = path.join(tmp.path, "testdir")
|
||||
await fs.mkdir(dirpath)
|
||||
|
||||
expect(await Filesystem.isDir(dirpath)).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
await fs.writeFile(filepath, "content", "utf-8")
|
||||
|
||||
expect(await Filesystem.isDir(filepath)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for non-existent path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "does-not-exist")
|
||||
|
||||
expect(await Filesystem.isDir(filepath)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("size()", () => {
|
||||
test("returns file size", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
const content = "Hello, World!"
|
||||
await fs.writeFile(filepath, content, "utf-8")
|
||||
|
||||
expect(await Filesystem.size(filepath)).toBe(content.length)
|
||||
})
|
||||
|
||||
test("returns 0 for non-existent file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "does-not-exist.txt")
|
||||
|
||||
expect(await Filesystem.size(filepath)).toBe(0)
|
||||
})
|
||||
|
||||
test("returns directory size", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const dirpath = path.join(tmp.path, "testdir")
|
||||
await fs.mkdir(dirpath)
|
||||
|
||||
// Directories have size on some systems
|
||||
const size = await Filesystem.size(dirpath)
|
||||
expect(typeof size).toBe("number")
|
||||
})
|
||||
})
|
||||
|
||||
describe("findUp()", () => {
|
||||
test("keeps previous nearest-first behavior for single target", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
|
||||
await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp("marker", child, tmp.path)
|
||||
|
||||
expect(result).toEqual([path.join(parent, "marker"), path.join(tmp.path, "marker")])
|
||||
})
|
||||
|
||||
test("respects stop boundary", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "marker"), "root", "utf-8")
|
||||
await fs.writeFile(path.join(parent, "marker"), "parent", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp("marker", child, parent)
|
||||
|
||||
expect(result).toEqual([path.join(parent, "marker")])
|
||||
})
|
||||
|
||||
test("supports multiple targets with nearest-first default ordering", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path)
|
||||
|
||||
expect(result).toEqual([
|
||||
path.join(parent, "cfg.jsonc"),
|
||||
path.join(tmp.path, "cfg.json"),
|
||||
path.join(tmp.path, "cfg.jsonc"),
|
||||
])
|
||||
})
|
||||
|
||||
test("supports rootFirst ordering for multiple targets", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const parent = path.join(tmp.path, "parent")
|
||||
const child = path.join(parent, "child")
|
||||
await fs.mkdir(child, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(parent, "cfg.jsonc"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "cfg.jsonc"), "{}", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp(["cfg.json", "cfg.jsonc"], child, tmp.path, { rootFirst: true })
|
||||
|
||||
expect(result).toEqual([
|
||||
path.join(tmp.path, "cfg.json"),
|
||||
path.join(tmp.path, "cfg.jsonc"),
|
||||
path.join(parent, "cfg.jsonc"),
|
||||
])
|
||||
})
|
||||
|
||||
test("rootFirst preserves json then jsonc order per directory", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const project = path.join(tmp.path, "project")
|
||||
const nested = path.join(project, "nested")
|
||||
await fs.mkdir(nested, { recursive: true })
|
||||
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "opencode.jsonc"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(project, "opencode.json"), "{}", "utf-8")
|
||||
await fs.writeFile(path.join(project, "opencode.jsonc"), "{}", "utf-8")
|
||||
|
||||
const result = await Filesystem.findUp(["opencode.json", "opencode.jsonc"], nested, tmp.path, {
|
||||
rootFirst: true,
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
path.join(tmp.path, "opencode.jsonc"),
|
||||
path.join(project, "opencode.json"),
|
||||
path.join(project, "opencode.jsonc"),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("readText()", () => {
|
||||
test("reads file content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
const content = "Hello, World!"
|
||||
await fs.writeFile(filepath, content, "utf-8")
|
||||
|
||||
expect(await Filesystem.readText(filepath)).toBe(content)
|
||||
})
|
||||
|
||||
test("throws for non-existent file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "does-not-exist.txt")
|
||||
|
||||
await expect(Filesystem.readText(filepath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("reads UTF-8 content correctly", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "unicode.txt")
|
||||
const content = "Hello 世界 🌍"
|
||||
await fs.writeFile(filepath, content, "utf-8")
|
||||
|
||||
expect(await Filesystem.readText(filepath)).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readJson()", () => {
|
||||
test("reads and parses JSON", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.json")
|
||||
const data = { key: "value", nested: { array: [1, 2, 3] } }
|
||||
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
|
||||
|
||||
const result: typeof data = await Filesystem.readJson(filepath)
|
||||
expect(result).toEqual(data)
|
||||
})
|
||||
|
||||
test("throws for invalid JSON", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "invalid.json")
|
||||
await fs.writeFile(filepath, "{ invalid json", "utf-8")
|
||||
|
||||
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("throws for non-existent file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "does-not-exist.json")
|
||||
|
||||
await expect(Filesystem.readJson(filepath)).rejects.toThrow()
|
||||
})
|
||||
|
||||
test("returns typed data", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "typed.json")
|
||||
interface Config {
|
||||
name: string
|
||||
version: number
|
||||
}
|
||||
const data: Config = { name: "test", version: 1 }
|
||||
await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
|
||||
|
||||
const result = await Filesystem.readJson<Config>(filepath)
|
||||
expect(result.name).toBe("test")
|
||||
expect(result.version).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("readBytes()", () => {
|
||||
test("reads file as buffer", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
const content = "Hello, World!"
|
||||
await fs.writeFile(filepath, content, "utf-8")
|
||||
|
||||
const buffer = await Filesystem.readBytes(filepath)
|
||||
expect(buffer).toBeInstanceOf(Buffer)
|
||||
expect(buffer.toString("utf-8")).toBe(content)
|
||||
})
|
||||
|
||||
test("throws for non-existent file", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "does-not-exist.bin")
|
||||
|
||||
await expect(Filesystem.readBytes(filepath)).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("write()", () => {
|
||||
test("writes text content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.txt")
|
||||
const content = "Hello, World!"
|
||||
|
||||
await Filesystem.write(filepath, content)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||
})
|
||||
|
||||
test("writes buffer content", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "test.bin")
|
||||
const content = Buffer.from([0x00, 0x01, 0x02, 0x03])
|
||||
|
||||
await Filesystem.write(filepath, content)
|
||||
|
||||
const read = await fs.readFile(filepath)
|
||||
expect(read).toEqual(content)
|
||||
})
|
||||
|
||||
test("writes with permissions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "protected.txt")
|
||||
const content = "secret"
|
||||
|
||||
await Filesystem.write(filepath, content, 0o600)
|
||||
|
||||
const stats = await fs.stat(filepath)
|
||||
// Check permissions on Unix
|
||||
if (process.platform !== "win32") {
|
||||
expect(stats.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
test("creates parent directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
|
||||
const content = "nested content"
|
||||
|
||||
await Filesystem.write(filepath, content)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeJson()", () => {
|
||||
test("writes JSON data", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "data.json")
|
||||
const data = { key: "value", number: 42 }
|
||||
|
||||
await Filesystem.writeJson(filepath, data)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(JSON.parse(content)).toEqual(data)
|
||||
})
|
||||
|
||||
test("writes formatted JSON", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "pretty.json")
|
||||
const data = { key: "value" }
|
||||
|
||||
await Filesystem.writeJson(filepath, data)
|
||||
|
||||
const content = await fs.readFile(filepath, "utf-8")
|
||||
expect(content).toContain("\n")
|
||||
expect(content).toContain(" ")
|
||||
})
|
||||
|
||||
test("writes with permissions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "config.json")
|
||||
const data = { secret: "data" }
|
||||
|
||||
await Filesystem.writeJson(filepath, data, 0o600)
|
||||
|
||||
const stats = await fs.stat(filepath)
|
||||
if (process.platform !== "win32") {
|
||||
expect(stats.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("mimeType()", () => {
|
||||
test("returns correct MIME type for JSON", async () => {
|
||||
expect(await Filesystem.mimeType("test.json")).toContain("application/json")
|
||||
})
|
||||
|
||||
test("returns correct MIME type for JavaScript", async () => {
|
||||
expect(await Filesystem.mimeType("test.js")).toContain("javascript")
|
||||
})
|
||||
|
||||
test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", async () => {
|
||||
const mime = await Filesystem.mimeType("test.ts")
|
||||
// .ts is ambiguous: TypeScript vs MPEG-2 TS video
|
||||
expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
|
||||
})
|
||||
|
||||
test("returns correct MIME type for images", async () => {
|
||||
expect(await Filesystem.mimeType("test.png")).toContain("image/png")
|
||||
expect(await Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
|
||||
})
|
||||
|
||||
test("returns default for unknown extension", async () => {
|
||||
expect(await Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
|
||||
})
|
||||
|
||||
test("handles files without extension", async () => {
|
||||
expect(await Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
|
||||
})
|
||||
})
|
||||
|
||||
describe("windowsPath()", () => {
|
||||
test("converts Git Bash paths", () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(Filesystem.windowsPath("/c/Users/test")).toBe("C:/Users/test")
|
||||
expect(Filesystem.windowsPath("/d/dev/project")).toBe("D:/dev/project")
|
||||
} else {
|
||||
expect(Filesystem.windowsPath("/c/Users/test")).toBe("/c/Users/test")
|
||||
}
|
||||
})
|
||||
|
||||
test("converts Cygwin paths", () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("C:/Users/test")
|
||||
expect(Filesystem.windowsPath("/cygdrive/x/dev/project")).toBe("X:/dev/project")
|
||||
} else {
|
||||
expect(Filesystem.windowsPath("/cygdrive/c/Users/test")).toBe("/cygdrive/c/Users/test")
|
||||
}
|
||||
})
|
||||
|
||||
test("converts WSL paths", () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("C:/Users/test")
|
||||
expect(Filesystem.windowsPath("/mnt/z/dev/project")).toBe("Z:/dev/project")
|
||||
} else {
|
||||
expect(Filesystem.windowsPath("/mnt/c/Users/test")).toBe("/mnt/c/Users/test")
|
||||
}
|
||||
})
|
||||
|
||||
test("ignores normal Windows paths", () => {
|
||||
expect(Filesystem.windowsPath("C:/Users/test")).toBe("C:/Users/test")
|
||||
expect(Filesystem.windowsPath("D:\\dev\\project")).toBe("D:\\dev\\project")
|
||||
})
|
||||
})
|
||||
|
||||
describe("writeStream()", () => {
|
||||
test("writes from Web ReadableStream", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "streamed.txt")
|
||||
const content = "Hello from stream!"
|
||||
const encoder = new TextEncoder()
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(content))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.writeStream(filepath, stream)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||
})
|
||||
|
||||
test("writes from Node.js Readable stream", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "node-streamed.txt")
|
||||
const content = "Hello from Node stream!"
|
||||
const { Readable } = await import("stream")
|
||||
const stream = Readable.from([content])
|
||||
|
||||
await Filesystem.writeStream(filepath, stream)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||
})
|
||||
|
||||
test("writes binary data from Web ReadableStream", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "binary.dat")
|
||||
const binaryData = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff])
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(binaryData)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.writeStream(filepath, stream)
|
||||
|
||||
const read = await fs.readFile(filepath)
|
||||
expect(Buffer.from(read)).toEqual(Buffer.from(binaryData))
|
||||
})
|
||||
|
||||
test("writes large content in chunks", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "large.txt")
|
||||
const chunks = ["chunk1", "chunk2", "chunk3", "chunk4", "chunk5"]
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
controller.enqueue(new TextEncoder().encode(chunk))
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.writeStream(filepath, stream)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe(chunks.join(""))
|
||||
})
|
||||
|
||||
test("creates parent directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "nested", "deep", "streamed.txt")
|
||||
const content = "nested stream content"
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(content))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.writeStream(filepath, stream)
|
||||
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||
})
|
||||
|
||||
test("writes with permissions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "protected-stream.txt")
|
||||
const content = "secret stream content"
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(content))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.writeStream(filepath, stream, 0o600)
|
||||
|
||||
const stats = await fs.stat(filepath)
|
||||
if (process.platform !== "win32") {
|
||||
expect(stats.mode & 0o777).toBe(0o600)
|
||||
}
|
||||
})
|
||||
|
||||
test("writes executable with permissions", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const filepath = path.join(tmp.path, "script.sh")
|
||||
const content = "#!/bin/bash\necho hello"
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(content))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
|
||||
await Filesystem.writeStream(filepath, stream, 0o755)
|
||||
|
||||
const stats = await fs.stat(filepath)
|
||||
if (process.platform !== "win32") {
|
||||
expect(stats.mode & 0o777).toBe(0o755)
|
||||
}
|
||||
expect(await fs.readFile(filepath, "utf-8")).toBe(content)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolve()", () => {
|
||||
test("resolves slash-prefixed drive paths on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const forward = tmp.path.replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/${forward}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves slash-prefixed drive roots on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toUpperCase()
|
||||
expect(Filesystem.resolve(`/${drive}:`)).toBe(Filesystem.resolve(`${drive}:/`))
|
||||
})
|
||||
|
||||
test("resolves Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
const rest = tmp.path.slice(2).replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves Git Bash and MSYS2 drive roots on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive> paths on Windows.
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
expect(Filesystem.resolve(`/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
|
||||
})
|
||||
|
||||
test("resolves Cygwin paths on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
const rest = tmp.path.slice(2).replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/cygdrive/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves Cygwin drive roots on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
expect(Filesystem.resolve(`/cygdrive/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
|
||||
})
|
||||
|
||||
test("resolves WSL mount paths on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
const rest = tmp.path.slice(2).replaceAll("\\", "/")
|
||||
expect(Filesystem.resolve(`/mnt/${drive}${rest}`)).toBe(Filesystem.normalizePath(tmp.path))
|
||||
})
|
||||
|
||||
test("resolves WSL mount roots on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const drive = tmp.path[0].toLowerCase()
|
||||
expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`))
|
||||
})
|
||||
|
||||
test("resolves symlinked directory to canonical path", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const target = path.join(tmp.path, "real")
|
||||
await fs.mkdir(target)
|
||||
const link = path.join(tmp.path, "link")
|
||||
await fs.symlink(target, link)
|
||||
expect(Filesystem.resolve(link)).toBe(Filesystem.resolve(target))
|
||||
})
|
||||
|
||||
test("returns unresolved path when target does not exist", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const missing = path.join(tmp.path, "does-not-exist-" + Date.now())
|
||||
const result = Filesystem.resolve(missing)
|
||||
expect(result).toBe(Filesystem.normalizePath(path.resolve(missing)))
|
||||
})
|
||||
|
||||
test("throws ELOOP on symlink cycle", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a = path.join(tmp.path, "a")
|
||||
const b = path.join(tmp.path, "b")
|
||||
await fs.symlink(b, a)
|
||||
await fs.symlink(a, b)
|
||||
expect(() => Filesystem.resolve(a)).toThrow()
|
||||
})
|
||||
|
||||
// Windows: chmod(0o000) is a no-op, so EACCES cannot be triggered
|
||||
test("throws EACCES on permission-denied symlink target", async () => {
|
||||
if (process.platform === "win32") return
|
||||
if (process.getuid?.() === 0) return // skip when running as root
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "restricted")
|
||||
await fs.mkdir(dir)
|
||||
const link = path.join(tmp.path, "link")
|
||||
await fs.symlink(dir, link)
|
||||
await fs.chmod(dir, 0o000)
|
||||
try {
|
||||
expect(() => Filesystem.resolve(path.join(link, "child"))).toThrow()
|
||||
} finally {
|
||||
await fs.chmod(dir, 0o755)
|
||||
}
|
||||
})
|
||||
|
||||
// Windows: traversing through a file throws ENOENT (not ENOTDIR),
|
||||
// which resolve() catches as a fallback instead of rethrowing
|
||||
test("rethrows non-ENOENT errors", async () => {
|
||||
if (process.platform === "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "not-a-directory")
|
||||
await fs.writeFile(file, "x")
|
||||
expect(() => Filesystem.resolve(path.join(file, "child"))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe("normalizePathPattern()", () => {
|
||||
test("preserves drive root globs on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.parse(tmp.path).root
|
||||
expect(Filesystem.normalizePathPattern(path.join(root, "*"))).toBe(path.join(root, "*"))
|
||||
})
|
||||
})
|
||||
})
|
||||
164
packages/opencode/test/util/glob.test.ts
Normal file
164
packages/opencode/test/util/glob.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("Glob", () => {
|
||||
describe("scan()", () => {
|
||||
test("finds files matching pattern", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "c.md"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results.sort()).toEqual(["a.txt", "b.txt"])
|
||||
})
|
||||
|
||||
test("returns absolute paths when absolute option is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*.txt", { cwd: tmp.path, absolute: true })
|
||||
|
||||
expect(results[0]).toBe(path.join(tmp.path, "file.txt"))
|
||||
})
|
||||
|
||||
test("excludes directories by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual(["file.txt"])
|
||||
})
|
||||
|
||||
test("excludes directories when include is 'file'", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, include: "file" })
|
||||
|
||||
expect(results).toEqual(["file.txt"])
|
||||
})
|
||||
|
||||
test("includes directories when include is 'all'", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, include: "all" })
|
||||
|
||||
expect(results.sort()).toEqual(["file.txt", "subdir"])
|
||||
})
|
||||
|
||||
test("handles nested patterns", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "nested"), { recursive: true })
|
||||
await fs.writeFile(path.join(tmp.path, "nested", "deep.txt"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual([path.join("nested", "deep.txt")])
|
||||
})
|
||||
|
||||
test("returns empty array for no matches", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const results = await Glob.scan("*.nonexistent", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
test("does not follow symlinks by default", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "realdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
|
||||
await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results).toEqual([path.join("realdir", "file.txt")])
|
||||
})
|
||||
|
||||
test("follows symlinks when symlink option is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "realdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
|
||||
await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
|
||||
|
||||
const results = await Glob.scan("**/*.txt", { cwd: tmp.path, symlink: true })
|
||||
|
||||
expect(results.sort()).toEqual([path.join("linkdir", "file.txt"), path.join("realdir", "file.txt")])
|
||||
})
|
||||
|
||||
test("includes dotfiles when dot option is true", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, dot: true })
|
||||
|
||||
expect(results.sort()).toEqual([".hidden", "visible"])
|
||||
})
|
||||
|
||||
test("excludes dotfiles when dot option is false", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
|
||||
|
||||
const results = await Glob.scan("*", { cwd: tmp.path, dot: false })
|
||||
|
||||
expect(results).toEqual(["visible"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("scanSync()", () => {
|
||||
test("finds files matching pattern synchronously", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
|
||||
|
||||
const results = Glob.scanSync("*.txt", { cwd: tmp.path })
|
||||
|
||||
expect(results.sort()).toEqual(["a.txt", "b.txt"])
|
||||
})
|
||||
|
||||
test("respects options", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
await fs.mkdir(path.join(tmp.path, "subdir"))
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
|
||||
|
||||
const results = Glob.scanSync("*", { cwd: tmp.path, include: "all" })
|
||||
|
||||
expect(results.sort()).toEqual(["file.txt", "subdir"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("match()", () => {
|
||||
test("matches simple patterns", () => {
|
||||
expect(Glob.match("*.txt", "file.txt")).toBe(true)
|
||||
expect(Glob.match("*.txt", "file.js")).toBe(false)
|
||||
})
|
||||
|
||||
test("matches directory patterns", () => {
|
||||
expect(Glob.match("**/*.js", "src/index.js")).toBe(true)
|
||||
expect(Glob.match("**/*.js", "src/index.ts")).toBe(false)
|
||||
})
|
||||
|
||||
test("matches dot files", () => {
|
||||
expect(Glob.match(".*", ".gitignore")).toBe(true)
|
||||
expect(Glob.match("**/*.md", ".github/README.md")).toBe(true)
|
||||
})
|
||||
|
||||
test("matches brace expansion", () => {
|
||||
expect(Glob.match("*.{js,ts}", "file.js")).toBe(true)
|
||||
expect(Glob.match("*.{js,ts}", "file.ts")).toBe(true)
|
||||
expect(Glob.match("*.{js,ts}", "file.py")).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
36
packages/opencode/test/util/iife.test.ts
Normal file
36
packages/opencode/test/util/iife.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { iife } from "../../src/util/iife"
|
||||
|
||||
describe("util.iife", () => {
|
||||
test("should execute function immediately and return result", () => {
|
||||
let called = false
|
||||
const result = iife(() => {
|
||||
called = true
|
||||
return 42
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
test("should work with async functions", async () => {
|
||||
let called = false
|
||||
const result = await iife(async () => {
|
||||
called = true
|
||||
return "async result"
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(result).toBe("async result")
|
||||
})
|
||||
|
||||
test("should handle functions with no return value", () => {
|
||||
let called = false
|
||||
const result = iife(() => {
|
||||
called = true
|
||||
})
|
||||
|
||||
expect(called).toBe(true)
|
||||
expect(result).toBeUndefined()
|
||||
})
|
||||
})
|
||||
50
packages/opencode/test/util/lazy.test.ts
Normal file
50
packages/opencode/test/util/lazy.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { lazy } from "../../src/util/lazy"
|
||||
|
||||
describe("util.lazy", () => {
|
||||
test("should call function only once", () => {
|
||||
let callCount = 0
|
||||
const getValue = () => {
|
||||
callCount++
|
||||
return "expensive value"
|
||||
}
|
||||
|
||||
const lazyValue = lazy(getValue)
|
||||
|
||||
expect(callCount).toBe(0)
|
||||
|
||||
const result1 = lazyValue()
|
||||
expect(result1).toBe("expensive value")
|
||||
expect(callCount).toBe(1)
|
||||
|
||||
const result2 = lazyValue()
|
||||
expect(result2).toBe("expensive value")
|
||||
expect(callCount).toBe(1)
|
||||
})
|
||||
|
||||
test("should preserve the same reference", () => {
|
||||
const obj = { value: 42 }
|
||||
const lazyObj = lazy(() => obj)
|
||||
|
||||
const result1 = lazyObj()
|
||||
const result2 = lazyObj()
|
||||
|
||||
expect(result1).toBe(obj)
|
||||
expect(result2).toBe(obj)
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
|
||||
test("should work with different return types", () => {
|
||||
const lazyString = lazy(() => "string")
|
||||
const lazyNumber = lazy(() => 123)
|
||||
const lazyBoolean = lazy(() => true)
|
||||
const lazyNull = lazy(() => null)
|
||||
const lazyUndefined = lazy(() => undefined)
|
||||
|
||||
expect(lazyString()).toBe("string")
|
||||
expect(lazyNumber()).toBe(123)
|
||||
expect(lazyBoolean()).toBe(true)
|
||||
expect(lazyNull()).toBe(null)
|
||||
expect(lazyUndefined()).toBe(undefined)
|
||||
})
|
||||
})
|
||||
59
packages/opencode/test/util/module.test.ts
Normal file
59
packages/opencode/test/util/module.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Module } from "@opencode-ai/core/util/module"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("util.module", () => {
|
||||
test("resolves package subpaths from the provided dir", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.join(tmp.path, "proj")
|
||||
const file = path.join(root, "node_modules/typescript/lib/tsserver.js")
|
||||
await Filesystem.write(file, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(root, "node_modules/typescript/package.json"), { name: "typescript" })
|
||||
|
||||
expect(Module.resolve("typescript/lib/tsserver.js", root)).toBe(file)
|
||||
})
|
||||
|
||||
test("resolves packages through ancestor node_modules", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const root = path.join(tmp.path, "proj")
|
||||
const cwd = path.join(root, "apps/web")
|
||||
const file = path.join(root, "node_modules/eslint/lib/api.js")
|
||||
await Filesystem.write(file, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(root, "node_modules/eslint/package.json"), {
|
||||
name: "eslint",
|
||||
main: "lib/api.js",
|
||||
})
|
||||
await Filesystem.write(path.join(cwd, ".keep"), "")
|
||||
|
||||
expect(Module.resolve("eslint", cwd)).toBe(file)
|
||||
})
|
||||
|
||||
test("resolves relative to the provided dir", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const a = path.join(tmp.path, "a")
|
||||
const b = path.join(tmp.path, "b")
|
||||
const left = path.join(a, "node_modules/biome/index.js")
|
||||
const right = path.join(b, "node_modules/biome/index.js")
|
||||
await Filesystem.write(left, "export {}\n")
|
||||
await Filesystem.write(right, "export {}\n")
|
||||
await Filesystem.writeJson(path.join(a, "node_modules/biome/package.json"), {
|
||||
name: "biome",
|
||||
main: "index.js",
|
||||
})
|
||||
await Filesystem.writeJson(path.join(b, "node_modules/biome/package.json"), {
|
||||
name: "biome",
|
||||
main: "index.js",
|
||||
})
|
||||
|
||||
expect(Module.resolve("biome", a)).toBe(left)
|
||||
expect(Module.resolve("biome", b)).toBe(right)
|
||||
expect(Module.resolve("biome", a)).not.toBe(Module.resolve("biome", b))
|
||||
})
|
||||
|
||||
test("returns undefined when resolution fails", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
expect(Module.resolve("missing-package", tmp.path)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
128
packages/opencode/test/util/process.test.ts
Normal file
128
packages/opencode/test/util/process.test.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Process } from "@/util/process"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
function node(script: string) {
|
||||
return [process.execPath, "-e", script]
|
||||
}
|
||||
|
||||
describe("util.process", () => {
|
||||
test("captures stdout and stderr", async () => {
|
||||
const out = await Process.run(node('process.stdout.write("out");process.stderr.write("err")'))
|
||||
expect(out.code).toBe(0)
|
||||
expect(out.stdout.toString()).toBe("out")
|
||||
expect(out.stderr.toString()).toBe("err")
|
||||
})
|
||||
|
||||
test("returns code when nothrow is enabled", async () => {
|
||||
const out = await Process.run(node("process.exit(7)"), { nothrow: true })
|
||||
expect(out.code).toBe(7)
|
||||
})
|
||||
|
||||
test("throws RunFailedError on non-zero exit", async () => {
|
||||
const err = await Process.run(node('process.stderr.write("bad");process.exit(3)')).catch((error) => error)
|
||||
expect(err).toBeInstanceOf(Process.RunFailedError)
|
||||
if (!(err instanceof Process.RunFailedError)) throw err
|
||||
expect(err.code).toBe(3)
|
||||
expect(err.stderr.toString()).toBe("bad")
|
||||
})
|
||||
|
||||
test("aborts a running process", async () => {
|
||||
const abort = new AbortController()
|
||||
const started = Date.now()
|
||||
setTimeout(() => abort.abort(), 25)
|
||||
|
||||
const out = await Process.run(node("setInterval(() => {}, 1000)"), {
|
||||
abort: abort.signal,
|
||||
nothrow: true,
|
||||
})
|
||||
|
||||
expect(out.code).not.toBe(0)
|
||||
expect(Date.now() - started).toBeLessThan(1000)
|
||||
}, 3000)
|
||||
|
||||
test("kills after timeout when process ignores terminate signal", async () => {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
const abort = new AbortController()
|
||||
const started = Date.now()
|
||||
setTimeout(() => abort.abort(), 25)
|
||||
|
||||
const out = await Process.run(node('process.on("SIGTERM", () => {}); setInterval(() => {}, 1000)'), {
|
||||
abort: abort.signal,
|
||||
nothrow: true,
|
||||
timeout: 25,
|
||||
})
|
||||
|
||||
expect(out.code).not.toBe(0)
|
||||
expect(Date.now() - started).toBeLessThan(1000)
|
||||
}, 3000)
|
||||
|
||||
test("uses cwd when spawning commands", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const out = await Process.run(node("process.stdout.write(process.cwd())"), {
|
||||
cwd: tmp.path,
|
||||
})
|
||||
expect(out.stdout.toString()).toBe(tmp.path)
|
||||
})
|
||||
|
||||
test("merges environment overrides", async () => {
|
||||
const out = await Process.run(node('process.stdout.write(process.env.OPENCODE_TEST ?? "")'), {
|
||||
env: {
|
||||
OPENCODE_TEST: "set",
|
||||
},
|
||||
})
|
||||
expect(out.stdout.toString()).toBe("set")
|
||||
})
|
||||
|
||||
test("uses shell in run on Windows", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const out = await Process.run(["set", "OPENCODE_TEST_SHELL"], {
|
||||
shell: true,
|
||||
env: {
|
||||
OPENCODE_TEST_SHELL: "ok",
|
||||
},
|
||||
})
|
||||
|
||||
expect(out.code).toBe(0)
|
||||
expect(out.stdout.toString()).toContain("OPENCODE_TEST_SHELL=ok")
|
||||
})
|
||||
|
||||
test("runs cmd scripts with spaces on Windows without shell", async () => {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
await using tmp = await tmpdir()
|
||||
const dir = path.join(tmp.path, "with space")
|
||||
const file = path.join(dir, "echo cmd.cmd")
|
||||
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
await Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n")
|
||||
|
||||
const proc = Process.spawn([file, "--stdio"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
expect(await proc.exited).toBe(0)
|
||||
})
|
||||
|
||||
test("rejects missing commands without leaking unhandled errors", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
const cmd = path.join(tmp.path, "missing" + (process.platform === "win32" ? ".cmd" : ""))
|
||||
const err = await Process.spawn([cmd], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}).exited.catch((err) => err)
|
||||
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
if (!(err instanceof Error)) throw err
|
||||
expect(err).toMatchObject({
|
||||
code: "ENOENT",
|
||||
})
|
||||
})
|
||||
})
|
||||
93
packages/opencode/test/util/repository.test.ts
Normal file
93
packages/opencode/test/util/repository.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import {
|
||||
InvalidRepositoryBranchError,
|
||||
InvalidRepositoryReferenceError,
|
||||
UnsupportedLocalRepositoryError,
|
||||
isFileRepositoryReference,
|
||||
isRemoteRepositoryReference,
|
||||
parseRemoteRepositoryReference,
|
||||
parseRepositoryReference,
|
||||
repositoryCacheIdentity,
|
||||
repositoryCachePath,
|
||||
sameRepositoryReference,
|
||||
validateRepositoryBranch,
|
||||
} from "../../src/util/repository"
|
||||
|
||||
describe("util.repository", () => {
|
||||
test("parses github shorthand and preserves cache path", () => {
|
||||
const reference = parseRemoteRepositoryReference("owner/repo")
|
||||
|
||||
expect(reference).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "owner/repo",
|
||||
segments: ["owner", "repo"],
|
||||
owner: "owner",
|
||||
repo: "repo",
|
||||
label: "owner/repo",
|
||||
})
|
||||
expect(repositoryCachePath(reference)).toBe(path.join(Global.Path.repos, "github.com", "owner", "repo"))
|
||||
expect(repositoryCacheIdentity(reference)).toBe("github.com/owner/repo")
|
||||
})
|
||||
|
||||
test("parses host path and scp remote references", () => {
|
||||
const hostPath = parseRemoteRepositoryReference("gitlab.com/group/repo")
|
||||
const scp = parseRemoteRepositoryReference("git@github.com:owner/repo.git")
|
||||
|
||||
expect(hostPath).toMatchObject({
|
||||
host: "gitlab.com",
|
||||
path: "group/repo",
|
||||
remote: "https://gitlab.com/group/repo.git",
|
||||
label: "gitlab.com/group/repo",
|
||||
})
|
||||
expect(scp).toMatchObject({
|
||||
host: "github.com",
|
||||
path: "owner/repo",
|
||||
remote: "git@github.com:owner/repo.git",
|
||||
label: "owner/repo",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps local file repositories distinct from remote repositories", () => {
|
||||
const localPath = path.resolve("repo.git")
|
||||
const reference = parseRepositoryReference(pathToFileURL(localPath).href)
|
||||
|
||||
expect(reference).toMatchObject({
|
||||
host: "file",
|
||||
protocol: "file:",
|
||||
label: localPath,
|
||||
})
|
||||
expect(reference && isFileRepositoryReference(reference)).toBe(true)
|
||||
expect(reference && isRemoteRepositoryReference(reference)).toBe(false)
|
||||
expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow(
|
||||
"Local file repositories are not supported",
|
||||
)
|
||||
expect(() => parseRemoteRepositoryReference(pathToFileURL(localPath).href)).toThrow(UnsupportedLocalRepositoryError)
|
||||
})
|
||||
|
||||
test("rejects invalid remote repository references with typed errors", () => {
|
||||
expect(() => parseRemoteRepositoryReference("not-a-repo")).toThrow(InvalidRepositoryReferenceError)
|
||||
expect(() => parseRemoteRepositoryReference("git@github.com:../../../etc/passwd")).toThrow(
|
||||
InvalidRepositoryReferenceError,
|
||||
)
|
||||
})
|
||||
|
||||
test("compares cache identity independent of input spelling", () => {
|
||||
const shorthand = parseRemoteRepositoryReference("owner/repo")
|
||||
const url = parseRemoteRepositoryReference("https://github.com/owner/repo.git")
|
||||
const hostPath = parseRemoteRepositoryReference("github.com/owner/repo")
|
||||
|
||||
expect(sameRepositoryReference(shorthand, url)).toBe(true)
|
||||
expect(sameRepositoryReference(shorthand, hostPath)).toBe(true)
|
||||
})
|
||||
|
||||
test("validates repository branch names", () => {
|
||||
expect(() => validateRepositoryBranch("feature/docs.v1")).not.toThrow()
|
||||
expect(() => validateRepositoryBranch("-bad")).toThrow("Branch must contain only alphanumeric characters")
|
||||
expect(() => validateRepositoryBranch("bad..branch")).toThrow("Branch must contain only alphanumeric characters")
|
||||
expect(() => validateRepositoryBranch("bad branch")).toThrow("Branch must contain only alphanumeric characters")
|
||||
expect(() => validateRepositoryBranch("bad branch")).toThrow(InvalidRepositoryBranchError)
|
||||
})
|
||||
})
|
||||
21
packages/opencode/test/util/timeout.test.ts
Normal file
21
packages/opencode/test/util/timeout.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { withTimeout } from "../../src/util/timeout"
|
||||
|
||||
describe("util.timeout", () => {
|
||||
test("should resolve when promise completes before timeout", async () => {
|
||||
const fastPromise = new Promise<string>((resolve) => {
|
||||
setTimeout(() => resolve("fast"), 10)
|
||||
})
|
||||
|
||||
const result = await withTimeout(fastPromise, 100)
|
||||
expect(result).toBe("fast")
|
||||
})
|
||||
|
||||
test("should reject when promise exceeds timeout", async () => {
|
||||
const slowPromise = new Promise<string>((resolve) => {
|
||||
setTimeout(() => resolve("slow"), 200)
|
||||
})
|
||||
|
||||
await expect(withTimeout(slowPromise, 50)).rejects.toThrow("Operation timed out after 50ms")
|
||||
})
|
||||
})
|
||||
90
packages/opencode/test/util/wildcard.test.ts
Normal file
90
packages/opencode/test/util/wildcard.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { Wildcard } from "@/util/wildcard"
|
||||
|
||||
test("match handles glob tokens", () => {
|
||||
expect(Wildcard.match("file1.txt", "file?.txt")).toBe(true)
|
||||
expect(Wildcard.match("file12.txt", "file?.txt")).toBe(false)
|
||||
expect(Wildcard.match("foo+bar", "foo+bar")).toBe(true)
|
||||
})
|
||||
|
||||
test("match with trailing space+wildcard matches command with or without args", () => {
|
||||
// "ls *" should match "ls" (no args) and "ls -la" (with args)
|
||||
expect(Wildcard.match("ls", "ls *")).toBe(true)
|
||||
expect(Wildcard.match("ls -la", "ls *")).toBe(true)
|
||||
expect(Wildcard.match("ls foo bar", "ls *")).toBe(true)
|
||||
|
||||
// "ls*" (no space) should NOT match "ls" alone — wait, it should because .* matches empty
|
||||
// but it WILL match "lstmeval" which is the dangerous case users should avoid
|
||||
expect(Wildcard.match("ls", "ls*")).toBe(true)
|
||||
expect(Wildcard.match("lstmeval", "ls*")).toBe(true)
|
||||
|
||||
// "ls *" (with space) should NOT match "lstmeval"
|
||||
expect(Wildcard.match("lstmeval", "ls *")).toBe(false)
|
||||
|
||||
// multi-word commands
|
||||
expect(Wildcard.match("git status", "git *")).toBe(true)
|
||||
expect(Wildcard.match("git", "git *")).toBe(true)
|
||||
expect(Wildcard.match("git commit -m foo", "git *")).toBe(true)
|
||||
})
|
||||
|
||||
test("all picks the most specific pattern", () => {
|
||||
const rules = {
|
||||
"*": "deny",
|
||||
"git *": "ask",
|
||||
"git status": "allow",
|
||||
}
|
||||
expect(Wildcard.all("git status", rules)).toBe("allow")
|
||||
expect(Wildcard.all("git log", rules)).toBe("ask")
|
||||
expect(Wildcard.all("echo hi", rules)).toBe("deny")
|
||||
})
|
||||
|
||||
test("allStructured matches command sequences", () => {
|
||||
const rules = {
|
||||
"git *": "ask",
|
||||
"git status*": "allow",
|
||||
}
|
||||
expect(Wildcard.allStructured({ head: "git", tail: ["status", "--short"] }, rules)).toBe("allow")
|
||||
expect(Wildcard.allStructured({ head: "npm", tail: ["run", "build", "--watch"] }, { "npm run *": "allow" })).toBe(
|
||||
"allow",
|
||||
)
|
||||
expect(Wildcard.allStructured({ head: "ls", tail: ["-la"] }, rules)).toBeUndefined()
|
||||
})
|
||||
|
||||
test("allStructured prioritizes flag-specific patterns", () => {
|
||||
const rules = {
|
||||
"find *": "allow",
|
||||
"find * -delete*": "ask",
|
||||
"sort*": "allow",
|
||||
"sort -o *": "ask",
|
||||
}
|
||||
expect(Wildcard.allStructured({ head: "find", tail: ["src", "-delete"] }, rules)).toBe("ask")
|
||||
expect(Wildcard.allStructured({ head: "find", tail: ["src", "-print"] }, rules)).toBe("allow")
|
||||
expect(Wildcard.allStructured({ head: "sort", tail: ["-o", "out.txt"] }, rules)).toBe("ask")
|
||||
expect(Wildcard.allStructured({ head: "sort", tail: ["--reverse"] }, rules)).toBe("allow")
|
||||
})
|
||||
|
||||
test("allStructured handles sed flags", () => {
|
||||
const rules = {
|
||||
"sed * -i*": "ask",
|
||||
"sed -n*": "allow",
|
||||
}
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "file"] }, rules)).toBe("ask")
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-i.bak", "file"] }, rules)).toBe("ask")
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-n", "1p", "file"] }, rules)).toBe("allow")
|
||||
expect(Wildcard.allStructured({ head: "sed", tail: ["-i", "-n", "/./p", "myfile.txt"] }, rules)).toBe("ask")
|
||||
})
|
||||
|
||||
test("match normalizes slashes for cross-platform globbing", () => {
|
||||
expect(Wildcard.match("C:\\Windows\\System32\\*", "C:/Windows/System32/*")).toBe(true)
|
||||
expect(Wildcard.match("C:/Windows/System32/drivers", "C:\\Windows\\System32\\*")).toBe(true)
|
||||
})
|
||||
|
||||
test("match handles case-insensitivity on Windows", () => {
|
||||
if (process.platform === "win32") {
|
||||
expect(Wildcard.match("C:\\windows\\system32\\hosts", "C:/Windows/System32/*")).toBe(true)
|
||||
expect(Wildcard.match("c:/windows/system32/hosts", "C:\\Windows\\System32\\*")).toBe(true)
|
||||
} else {
|
||||
// Unix paths are case-sensitive
|
||||
expect(Wildcard.match("/users/test/file", "/Users/test/*")).toBe(false)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user