test(httpapi): add route exerciser

This commit is contained in:
Kit Langton
2026-05-02 20:31:21 -04:00
committed by GitHub
parent d10fb88b66
commit fd01dc9c89
21 changed files with 2685 additions and 1013 deletions

View File

@@ -8,7 +8,7 @@ import { Ripgrep } from "../../src/file/ripgrep"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Truncate } from "@/tool/truncate"
import { Agent } from "../../src/agent/agent"
import { provideTmpdirInstance } from "../fixture/fixture"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const it = testEffect(
@@ -33,49 +33,47 @@ const ctx = {
}
describe("tool.glob", () => {
it.live("matches files from a directory path", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n"))
yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const result = yield* glob.execute(
it.instance("matches files from a directory path", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "a.ts"), "export const a = 1\n"))
yield* Effect.promise(() => Bun.write(path.join(test.directory, "b.txt"), "hello\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const result = yield* glob.execute(
{
pattern: "*.ts",
path: test.directory,
},
ctx,
)
expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(test.directory, "a.ts"))
expect(result.output).not.toContain(path.join(test.directory, "b.txt"))
}),
)
it.instance("rejects exact file paths", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "a.ts")
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const exit = yield* glob
.execute(
{
pattern: "*.ts",
path: dir,
path: file,
},
ctx,
)
expect(result.metadata.count).toBe(1)
expect(result.output).toContain(path.join(dir, "a.ts"))
expect(result.output).not.toContain(path.join(dir, "b.txt"))
}),
),
)
it.live("rejects exact file paths", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "a.ts")
yield* Effect.promise(() => Bun.write(file, "export const a = 1\n"))
const info = yield* GlobTool
const glob = yield* info.init()
const exit = yield* glob
.execute(
{
pattern: "*.ts",
path: file,
},
ctx,
)
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
}
}),
),
.pipe(Effect.exit)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory")
}
}),
)
})

View File

@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
import path from "path"
import { Effect, Layer } from "effect"
import { GrepTool } from "../../src/tool/grep"
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
import { provideInstance, TestInstance } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate"
@@ -54,61 +54,58 @@ describe("tool.grep", () => {
}),
)
it.live("no matches returns correct output", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "hello world"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: dir,
},
ctx,
)
expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found")
}),
),
it.instance("no matches returns correct output", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "hello world"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "xyznonexistentpatternxyz123",
path: test.directory,
},
ctx,
)
expect(result.metadata.matches).toBe(0)
expect(result.output).toBe("No files found")
}),
)
it.live("finds matches in tmp instance", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* Effect.promise(() => Bun.write(path.join(dir, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line",
path: dir,
},
ctx,
)
expect(result.metadata.matches).toBeGreaterThan(0)
}),
),
it.instance("finds matches in tmp instance", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* Effect.promise(() => Bun.write(path.join(test.directory, "test.txt"), "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line",
path: test.directory,
},
ctx,
)
expect(result.metadata.matches).toBeGreaterThan(0)
}),
)
it.live("supports exact file paths", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const file = path.join(dir, "test.txt")
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line2",
path: file,
},
ctx,
)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain(file)
expect(result.output).toContain("Line 2: line2")
}),
),
it.instance("supports exact file paths", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const file = path.join(test.directory, "test.txt")
yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3"))
const info = yield* GrepTool
const grep = yield* info.init()
const result = yield* grep.execute(
{
pattern: "line2",
path: file,
},
ctx,
)
expect(result.metadata.matches).toBe(1)
expect(result.output).toContain(file)
expect(result.output).toContain("Line 2: line2")
}),
)
})

View File

@@ -6,7 +6,6 @@ import { SessionID, MessageID } from "../../src/session/schema"
import { Agent } from "../../src/agent/agent"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Truncate } from "@/tool/truncate"
import { provideTmpdirInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx = {
@@ -34,56 +33,52 @@ const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Quest
})
describe("tool.question", () => {
it.live("should successfully execute with valid question parameters", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const questions = [
{
question: "What is your favorite color?",
header: "Color",
options: [
{ label: "Red", description: "The color of passion" },
{ label: "Blue", description: "The color of sky" },
],
multiple: false,
},
]
it.instance("should successfully execute with valid question parameters", () =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const questions = [
{
question: "What is your favorite color?",
header: "Color",
options: [
{ label: "Red", description: "The color of passion" },
{ label: "Blue", description: "The color of sky" },
],
multiple: false,
},
]
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Red"]] })
const result = yield* Fiber.join(fiber)
expect(result.title).toBe("Asked 1 question")
}),
),
const result = yield* Fiber.join(fiber)
expect(result.title).toBe("Asked 1 question")
}),
)
it.live("should now pass with a header longer than 12 but less than 30 chars", () =>
provideTmpdirInstance(() =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Over 12",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
it.instance("should now pass with a header longer than 12 but less than 30 chars", () =>
Effect.gen(function* () {
const question = yield* Question.Service
const toolInfo = yield* QuestionTool
const tool = yield* toolInfo.init()
const questions = [
{
question: "What is your favorite animal?",
header: "This Header is Over 12",
options: [{ label: "Dog", description: "Man's best friend" }],
},
]
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
const fiber = yield* tool.execute({ questions }, ctx).pipe(Effect.forkScoped)
const item = yield* pending(question)
yield* question.reply({ requestID: item.id, answers: [["Dog"]] })
const result = yield* Fiber.join(fiber)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
}),
),
const result = yield* Fiber.join(fiber)
expect(result.output).toContain(`"What is your favorite animal?"="Dog"`)
}),
)
// intentionally removed the zod validation due to tool call errors, hoping prompting is gonna be good enough

View File

@@ -13,7 +13,7 @@ import { ReadTool } from "../../src/tool/read"
import { Truncate } from "@/tool/truncate"
import { Tool } from "@/tool/tool"
import { Filesystem } from "@/util/filesystem"
import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/fixture"
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
@@ -255,28 +255,28 @@ describe("tool.read env file permissions", () => {
})
describe("tool.read truncation", () => {
it.live("truncates large file by bytes and sets truncated metadata", () =>
it.instance("truncates large file by bytes and sets truncated metadata", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const test = yield* TestInstance
const base = yield* load(path.join(FIXTURES_DIR, "models-api.json"))
const target = 60 * 1024
const content = base.length >= target ? base : base.repeat(Math.ceil(target / base.length))
yield* put(path.join(dir, "large.json"), content)
yield* put(path.join(test.directory, "large.json"), content)
const result = yield* exec(dir, { filePath: path.join(dir, "large.json") })
const result = yield* run({ filePath: path.join(test.directory, "large.json") })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Output capped at")
expect(result.output).toContain("Use offset=")
}),
)
it.live("truncates by line count when limit is specified", () =>
it.instance("truncates by line count when limit is specified", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
const test = yield* TestInstance
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
yield* put(path.join(dir, "many-lines.txt"), lines)
yield* put(path.join(test.directory, "many-lines.txt"), lines)
const result = yield* exec(dir, { filePath: path.join(dir, "many-lines.txt"), limit: 10 })
const result = yield* run({ filePath: path.join(test.directory, "many-lines.txt"), limit: 10 })
expect(result.metadata.truncated).toBe(true)
expect(result.output).toContain("Showing lines 1-10 of 100")
expect(result.output).toContain("Use offset=11")
@@ -286,12 +286,12 @@ describe("tool.read truncation", () => {
}),
)
it.live("does not truncate small file", () =>
it.instance("does not truncate small file", () =>
Effect.gen(function* () {
const dir = yield* tmpdirScoped()
yield* put(path.join(dir, "small.txt"), "hello world")
const test = yield* TestInstance
yield* put(path.join(test.directory, "small.txt"), "hello world")
const result = yield* exec(dir, { filePath: path.join(dir, "small.txt") })
const result = yield* run({ filePath: path.join(test.directory, "small.txt") })
expect(result.metadata.truncated).toBe(false)
expect(result.output).toContain("End of file")
}),

View File

@@ -2,10 +2,9 @@ import { afterEach, describe, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Effect, Layer } from "effect"
import { Instance } from "../../src/project/instance"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { ToolRegistry } from "@/tool/registry"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { TestConfig } from "../fixture/config"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
@@ -57,136 +56,133 @@ afterEach(async () => {
})
describe("tool.registry", () => {
it.live("loads tools from .opencode/tool (singular)", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const opencode = path.join(dir, ".opencode")
const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
),
it.instance("loads tools from .opencode/tool (singular)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tool = path.join(opencode, "tool")
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tool, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
)
it.live("loads tools from .opencode/tools (plural)", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const opencode = path.join(dir, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
),
it.instance("loads tools from .opencode/tools (plural)", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "hello.ts"),
[
"export default {",
" description: 'hello tool',",
" args: {},",
" execute: async () => {",
" return 'hello world'",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("hello")
}),
)
it.live("loads tools with external dependencies without crashing", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const opencode = path.join(dir, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package.json"),
JSON.stringify({
name: "custom-tools",
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
}),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
it.instance("loads tools with external dependencies without crashing", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const opencode = path.join(test.directory, ".opencode")
const tools = path.join(opencode, "tools")
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package.json"),
JSON.stringify({
name: "custom-tools",
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
}),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(opencode, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
}),
),
)
},
}),
),
)
const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "cowsay.ts"),
[
"import { say } from 'cowsay'",
"export default {",
" description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {",
" return say({ text })",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("cowsay")
}),
),
const cowsay = path.join(opencode, "node_modules", "cowsay")
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(cowsay, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
),
)
yield* Effect.promise(() =>
Bun.write(
path.join(tools, "cowsay.ts"),
[
"import { say } from 'cowsay'",
"export default {",
" description: 'tool that imports cowsay at top level',",
" args: { text: { type: 'string' } },",
" execute: async ({ text }: { text: string }) => {",
" return say({ text })",
" },",
"}",
"",
].join("\n"),
),
)
const registry = yield* ToolRegistry.Service
const ids = yield* registry.ids()
expect(ids).toContain("cowsay")
}),
)
})

View File

@@ -13,7 +13,7 @@ import { Tool } from "@/tool/tool"
import { Agent } from "../../src/agent/agent"
import { SessionID, MessageID } from "../../src/session/schema"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
import { disposeAllInstances, provideTmpdirInstance, TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
const ctx = {
@@ -58,66 +58,79 @@ const run = Effect.fn("WriteToolTest.run")(function* (
describe("tool.write", () => {
describe("new file creation", () => {
it.live("writes content to new file", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "newfile.txt")
const result = yield* run({ filePath: filepath, content: "Hello, World!" })
it.instance("writes content to new file", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "newfile.txt")
const result = yield* run({ filePath: filepath, content: "Hello, World!" })
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(false)
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(false)
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("Hello, World!")
}),
),
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("Hello, World!")
}),
)
it.live("creates parent directories if needed", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "nested", "deep", "file.txt")
yield* run({ filePath: filepath, content: "nested content" })
it.instance("creates parent directories if needed", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "nested", "deep", "file.txt")
yield* run({ filePath: filepath, content: "nested content" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("nested content")
}),
),
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("nested content")
}),
)
it.live("handles relative paths by resolving to instance directory", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
yield* run({ filePath: "relative.txt", content: "relative content" })
it.instance("handles relative paths by resolving to instance directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* run({ filePath: "relative.txt", content: "relative content" })
const content = yield* Effect.promise(() => fs.readFile(path.join(dir, "relative.txt"), "utf-8"))
expect(content).toBe("relative content")
}),
),
const content = yield* Effect.promise(() => fs.readFile(path.join(test.directory, "relative.txt"), "utf-8"))
expect(content).toBe("relative content")
}),
)
})
describe("existing file overwrite", () => {
it.live("overwrites existing file content", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "existing.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
const result = yield* run({ filePath: filepath, content: "new content" })
it.instance("overwrites existing file content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old content", "utf-8"))
const result = yield* run({ filePath: filepath, content: "new content" })
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(true)
expect(result.output).toContain("Wrote file successfully")
expect(result.metadata.exists).toBe(true)
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("new content")
}),
),
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("new content")
}),
)
it.live("preserves BOM when overwriting existing files", () =>
provideTmpdirInstance((dir) =>
it.instance("preserves BOM when overwriting existing files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "existing.cs")
const bom = String.fromCharCode(0xfeff)
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
yield* run({ filePath: filepath, content: "using Up;\n" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
}),
)
it.instance(
"restores BOM after formatter strips it",
() =>
Effect.gen(function* () {
const filepath = path.join(dir, "existing.cs")
const test = yield* TestInstance
const filepath = path.join(test.directory, "formatted.cs")
const bom = String.fromCharCode(0xfeff)
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
@@ -127,165 +140,138 @@ describe("tool.write", () => {
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
}),
),
)
it.live("restores BOM after formatter strips it", () =>
provideTmpdirInstance(
(dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "formatted.cs")
const bom = String.fromCharCode(0xfeff)
yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
yield* run({ filePath: filepath, content: "using Up;\n" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content.charCodeAt(0)).toBe(0xfeff)
expect(content.slice(1)).toBe("using Up;\n")
}),
{
config: {
formatter: {
stripbom: {
extensions: [".cs"],
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')",
"$FILE",
],
},
{
config: {
formatter: {
stripbom: {
extensions: [".cs"],
command: [
"node",
"-e",
"const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')",
"$FILE",
],
},
},
},
),
},
)
it.live("returns diff in metadata for existing files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
const result = yield* run({ filePath: filepath, content: "new" })
it.instance("returns diff in metadata for existing files", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "file.txt")
yield* Effect.promise(() => fs.writeFile(filepath, "old", "utf-8"))
const result = yield* run({ filePath: filepath, content: "new" })
expect(result.metadata).toHaveProperty("filepath", filepath)
expect(result.metadata).toHaveProperty("exists", true)
}),
),
expect(result.metadata).toHaveProperty("filepath", filepath)
expect(result.metadata).toHaveProperty("exists", true)
}),
)
})
describe("file permissions", () => {
it.live("sets file permissions when writing sensitive data", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "sensitive.json")
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
it.instance("sets file permissions when writing sensitive data", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "sensitive.json")
yield* run({ filePath: filepath, content: JSON.stringify({ secret: "data" }) })
if (process.platform !== "win32") {
const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.mode & 0o777).toBe(0o644)
}
}),
),
if (process.platform !== "win32") {
const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.mode & 0o777).toBe(0o644)
}
}),
)
})
describe("content types", () => {
it.live("writes JSON content", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "data.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
it.instance("writes JSON content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "data.json")
const data = { key: "value", nested: { array: [1, 2, 3] } }
yield* run({ filePath: filepath, content: JSON.stringify(data, null, 2) })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(JSON.parse(content)).toEqual(data)
}),
),
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(JSON.parse(content)).toEqual(data)
}),
)
it.live("writes binary-safe content", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "binary.bin")
const content = "Hello\x00World\x01\x02\x03"
yield* run({ filePath: filepath, content })
it.instance("writes binary-safe content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "binary.bin")
const content = "Hello\x00World\x01\x02\x03"
yield* run({ filePath: filepath, content })
const buf = yield* Effect.promise(() => fs.readFile(filepath))
expect(buf.toString()).toBe(content)
}),
),
const buf = yield* Effect.promise(() => fs.readFile(filepath))
expect(buf.toString()).toBe(content)
}),
)
it.live("writes empty content", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "empty.txt")
yield* run({ filePath: filepath, content: "" })
it.instance("writes empty content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "empty.txt")
yield* run({ filePath: filepath, content: "" })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("")
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe("")
const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.size).toBe(0)
}),
),
const stats = yield* Effect.promise(() => fs.stat(filepath))
expect(stats.size).toBe(0)
}),
)
it.live("writes multi-line content", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "multiline.txt")
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
yield* run({ filePath: filepath, content: lines })
it.instance("writes multi-line content", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "multiline.txt")
const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
yield* run({ filePath: filepath, content: lines })
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe(lines)
}),
),
const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
expect(content).toBe(lines)
}),
)
it.live("handles different line endings", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "crlf.txt")
const content = "Line 1\r\nLine 2\r\nLine 3"
yield* run({ filePath: filepath, content })
it.instance("handles different line endings", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "crlf.txt")
const content = "Line 1\r\nLine 2\r\nLine 3"
yield* run({ filePath: filepath, content })
const buf = yield* Effect.promise(() => fs.readFile(filepath))
expect(buf.toString()).toBe(content)
}),
),
const buf = yield* Effect.promise(() => fs.readFile(filepath))
expect(buf.toString()).toBe(content)
}),
)
})
describe("error handling", () => {
it.live("throws error when OS denies write access", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const readonlyPath = path.join(dir, "readonly.txt")
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
),
it.instance("throws error when OS denies write access", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const readonlyPath = path.join(test.directory, "readonly.txt")
yield* Effect.promise(() => fs.writeFile(readonlyPath, "test", "utf-8"))
yield* Effect.promise(() => fs.chmod(readonlyPath, 0o444))
const exit = yield* run({ filePath: readonlyPath, content: "new content" }).pipe(Effect.exit)
expect(exit._tag).toBe("Failure")
}),
)
})
describe("title generation", () => {
it.live("returns relative path as title", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
const filepath = path.join(dir, "src", "components", "Button.tsx")
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
it.instance("returns relative path as title", () =>
Effect.gen(function* () {
const test = yield* TestInstance
const filepath = path.join(test.directory, "src", "components", "Button.tsx")
yield* Effect.promise(() => fs.mkdir(path.dirname(filepath), { recursive: true }))
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
}),
),
const result = yield* run({ filePath: filepath, content: "export const Button = () => {}" })
expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
}),
)
})
})