test(project): migrate VCS tests to Effect runner (#26965)

This commit is contained in:
Kit Langton
2026-05-11 20:59:34 -04:00
committed by GitHub
parent 0d9c534184
commit c4003579bb
+252 -275
View File
@@ -1,161 +1,139 @@
import { $ } from "bun" import { afterEach, describe, expect } from "bun:test"
import { afterEach, describe, expect, test } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { parsePatch } from "diff" import { parsePatch } from "diff"
import { Effect } from "effect" import { Deferred, Effect, Layer, Stream } from "effect"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import fs from "fs/promises" import fs from "fs/promises"
import path from "path" import path from "path"
import { disposeAllInstances, tmpdir } from "../fixture/fixture" import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
import { AppRuntime } from "../../src/effect/app-runtime" import { Bus } from "../../src/bus"
import { FileWatcher } from "../../src/file/watcher" import { FileWatcher } from "../../src/file/watcher"
import { Instance } from "../../src/project/instance" import { Git } from "../../src/git"
import { WithInstance } from "../../src/project/with-instance"
import { GlobalBus } from "../../src/bus/global"
import { Vcs } from "@/project/vcs" import { Vcs } from "@/project/vcs"
import { testEffect } from "../lib/effect"
// Skip in CI — native @parcel/watcher binding needed
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Helpers // Helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function withVcs(directory: string, body: () => Promise<void>) {
return WithInstance.provide({
directory,
fn: async () => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const watcher = yield* FileWatcher.Service
const vcs = yield* Vcs.Service
yield* watcher.init()
yield* vcs.init()
}),
)
await Bun.sleep(500)
await body()
},
})
}
function withVcsOnly(directory: string, body: () => Promise<void>) {
return WithInstance.provide({
directory,
fn: async () => {
await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
yield* vcs.init()
}),
)
await body()
},
})
}
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt" const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */ const layer = Layer.mergeAll(
function nextBranchUpdate(directory: string, timeout = 10_000) { Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)),
return new Promise<string | undefined>((resolve, reject) => { CrossSpawnSpawner.defaultLayer,
let settled = false AppFileSystem.defaultLayer,
)
const it = testEffect(layer)
const timer = setTimeout(() => { const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) {
if (settled) return const result = yield* Git.Service.use((git) => git.run(args, { cwd }))
settled = true if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
GlobalBus.off("event", on) })
reject(new Error("timed out waiting for BranchUpdated event"))
}, timeout)
function on(evt: BranchEvent) { const write = Effect.fn("VcsTest.write")(function* (file: string, content: string) {
if (evt.directory !== directory) return yield* AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return })
if (settled) return
settled = true
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties.branch)
}
GlobalBus.on("event", on) const remove = Effect.fn("VcsTest.remove")(function* (file: string) {
}) yield* AppFileSystem.Service.use((fs) => fs.remove(file))
} })
const symlink = (target: string, file: string) => Effect.promise(() => fs.symlink(target, file))
const init = Effect.fn("VcsTest.init")(function* () {
const vcs = yield* Vcs.Service
yield* vcs.init()
return vcs
})
const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () {
const bus = yield* Bus.Service
const updated = yield* Deferred.make<string | undefined>()
yield* Stream.runForEach(bus.subscribe(Vcs.Event.BranchUpdated), (evt) =>
Deferred.succeed(updated, evt.properties.branch),
).pipe(Effect.forkScoped)
return updated
})
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
describeVcs("Vcs", () => { describe("Vcs", () => {
afterEach(async () => { afterEach(async () => {
await disposeAllInstances() await disposeAllInstances()
}) })
test("branch() returns current branch name", async () => { it.instance(
await using tmp = await tmpdir({ git: true }) "branch() returns current branch name",
() =>
Effect.gen(function* () {
const vcs = yield* init()
const branch = yield* vcs.branch()
await withVcs(tmp.path, async () => { expect(branch).toBeDefined()
const branch = await AppRuntime.runPromise( expect(typeof branch).toBe("string")
Effect.gen(function* () { }),
const vcs = yield* Vcs.Service { git: true },
return yield* vcs.branch() )
}),
)
expect(branch).toBeDefined()
expect(typeof branch).toBe("string")
})
})
test("branch() returns undefined for non-git directories", async () => { it.instance("branch() returns undefined for non-git directories", () =>
await using tmp = await tmpdir() Effect.gen(function* () {
const vcs = yield* init()
const branch = yield* vcs.branch()
await withVcs(tmp.path, async () => {
const branch = await AppRuntime.runPromise(
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.branch()
}),
)
expect(branch).toBeUndefined() expect(branch).toBeUndefined()
}) }),
}) )
test("publishes BranchUpdated when .git/HEAD changes", async () => { it.instance(
await using tmp = await tmpdir({ git: true }) "publishes BranchUpdated when .git/HEAD changes",
const branch = `test-${Math.random().toString(36).slice(2)}` () =>
await $`git branch ${branch}`.cwd(tmp.path).quiet() Effect.gen(function* () {
const test = yield* TestInstance
const branch = `test-${Math.random().toString(36).slice(2)}`
yield* git(test.directory, ["branch", branch])
await withVcs(tmp.path, async () => { const vcs = yield* init()
const pending = nextBranchUpdate(tmp.path) yield* vcs.branch()
const pending = yield* nextBranchUpdate()
const bus = yield* Bus.Service
const head = path.join(tmp.path, ".git", "HEAD") const head = path.join(test.directory, ".git", "HEAD")
await fs.writeFile(head, `ref: refs/heads/${branch}\n`) yield* write(head, `ref: refs/heads/${branch}\n`)
yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
const updated = await pending const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
expect(updated).toBe(branch) expect(updated).toBe(branch)
}) }),
}) { git: true },
)
test("branch() reflects the new branch after HEAD change", async () => { it.instance(
await using tmp = await tmpdir({ git: true }) "branch() reflects the new branch after HEAD change",
const branch = `test-${Math.random().toString(36).slice(2)}` () =>
await $`git branch ${branch}`.cwd(tmp.path).quiet() Effect.gen(function* () {
const test = yield* TestInstance
const branch = `test-${Math.random().toString(36).slice(2)}`
yield* git(test.directory, ["branch", branch])
await withVcs(tmp.path, async () => { const vcs = yield* init()
const pending = nextBranchUpdate(tmp.path) yield* vcs.branch()
const pending = yield* nextBranchUpdate()
const bus = yield* Bus.Service
const head = path.join(tmp.path, ".git", "HEAD") const head = path.join(test.directory, ".git", "HEAD")
await fs.writeFile(head, `ref: refs/heads/${branch}\n`) yield* write(head, `ref: refs/heads/${branch}\n`)
yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
await pending const current = yield* vcs.branch()
const current = await AppRuntime.runPromise( expect(current).toBe(branch)
Effect.gen(function* () { }),
const vcs = yield* Vcs.Service { git: true },
return yield* vcs.branch() )
}),
)
expect(current).toBe(branch)
})
})
}) })
describe("Vcs diff", () => { describe("Vcs diff", () => {
@@ -163,177 +141,176 @@ describe("Vcs diff", () => {
await disposeAllInstances() await disposeAllInstances()
}) })
test("defaultBranch() falls back to main", async () => { it.instance(
await using tmp = await tmpdir({ git: true }) "defaultBranch() falls back to main",
await $`git branch -M main`.cwd(tmp.path).quiet() () =>
Effect.gen(function* () {
const test = yield* TestInstance
yield* git(test.directory, ["branch", "-M", "main"])
await withVcsOnly(tmp.path, async () => { const vcs = yield* init()
const branch = await AppRuntime.runPromise( const branch = yield* vcs.defaultBranch()
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.defaultBranch()
}),
)
expect(branch).toBe("main")
})
})
test("defaultBranch() uses init.defaultBranch when available", async () => { expect(branch).toBe("main")
await using tmp = await tmpdir({ git: true }) }),
await $`git branch -M trunk`.cwd(tmp.path).quiet() { git: true },
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet() )
await withVcsOnly(tmp.path, async () => { it.instance(
const branch = await AppRuntime.runPromise( "defaultBranch() uses init.defaultBranch when available",
Effect.gen(function* () { () =>
const vcs = yield* Vcs.Service Effect.gen(function* () {
return yield* vcs.defaultBranch() const test = yield* TestInstance
}), yield* git(test.directory, ["branch", "-M", "trunk"])
) yield* git(test.directory, ["config", "init.defaultBranch", "trunk"])
expect(branch).toBe("trunk")
})
})
test("detects current branch from the active worktree", async () => { const vcs = yield* init()
await using tmp = await tmpdir({ git: true }) const branch = yield* vcs.defaultBranch()
await using wt = await tmpdir()
await $`git branch -M main`.cwd(tmp.path).quiet()
const dir = path.join(wt.path, "feature")
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
await withVcsOnly(dir, async () => { expect(branch).toBe("trunk")
const [branch, base] = await AppRuntime.runPromise( }),
Effect.gen(function* () { { git: true },
const vcs = yield* Vcs.Service )
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
}), it.live("detects current branch from the active worktree", () =>
) Effect.gen(function* () {
const tmp = yield* tmpdirScoped({ git: true })
const wt = yield* tmpdirScoped()
yield* git(tmp, ["branch", "-M", "main"])
const dir = path.join(wt, "feature")
yield* git(tmp, ["worktree", "add", "-b", "feature/test", dir, "HEAD"])
const [branch, base] = yield* Effect.gen(function* () {
const vcs = yield* init()
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
}).pipe(provideInstance(dir))
expect(branch).toBeDefined()
expect(branch).toBe("feature/test") expect(branch).toBe("feature/test")
expect(base).toBe("main") expect(base).toBe("main")
}) }),
}) )
test("diff('git') returns uncommitted changes", async () => { it.instance(
await using tmp = await tmpdir({ git: true }) "diff('git') returns uncommitted changes",
await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8") () =>
await $`git add .`.cwd(tmp.path).quiet() Effect.gen(function* () {
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet() const test = yield* TestInstance
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8") yield* write(path.join(test.directory, "file.txt"), "original\n")
yield* git(test.directory, ["add", "."])
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
yield* write(path.join(test.directory, "file.txt"), "changed\n")
await withVcsOnly(tmp.path, async () => { const vcs = yield* init()
const diff = await AppRuntime.runPromise( const diff = yield* vcs.diff("git")
Effect.gen(function* () {
const vcs = yield* Vcs.Service
return yield* vcs.diff("git")
}),
)
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "file.txt",
status: "modified",
}),
]),
)
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
})
})
test("diff('git') handles special filenames", async () => { expect(diff).toEqual(
await using tmp = await tmpdir({ git: true }) expect.arrayContaining([
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8") expect.objectContaining({
file: "file.txt",
status: "modified",
}),
]),
)
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
}),
{ git: true },
)
await withVcsOnly(tmp.path, async () => { it.instance(
const diff = await AppRuntime.runPromise( "diff('git') handles special filenames",
Effect.gen(function* () { () =>
const vcs = yield* Vcs.Service Effect.gen(function* () {
return yield* vcs.diff("git") const test = yield* TestInstance
}), yield* write(path.join(test.directory, weird), "hello\n")
)
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: weird,
status: "added",
}),
]),
)
})
})
test("diff('git') keeps batched patches aligned for type changes", async () => { const vcs = yield* init()
if (process.platform === "win32") return const diff = yield* vcs.diff("git")
await using tmp = await tmpdir({ git: true }) expect(diff).toEqual(
await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8") expect.arrayContaining([
await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8") expect.objectContaining({
await $`git add .`.cwd(tmp.path).quiet() file: weird,
await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet() status: "added",
await fs.unlink(path.join(tmp.path, "a.txt")) }),
await fs.symlink("target", path.join(tmp.path, "a.txt")) ]),
await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8") )
}),
{ git: true },
)
await withVcsOnly(tmp.path, async () => { it.instance(
const diff = await AppRuntime.runPromise( "diff('git') keeps batched patches aligned for type changes",
Effect.gen(function* () { () =>
const vcs = yield* Vcs.Service Effect.gen(function* () {
return yield* vcs.diff("git") if (process.platform === "win32") return
}),
)
const a = diff.find((item) => item.file === "a.txt")
const b = diff.find((item) => item.file === "b.txt")
expect(a?.patch).toContain("deleted file mode") const test = yield* TestInstance
expect(a?.patch).toContain("new file mode") yield* write(path.join(test.directory, "a.txt"), "old\n")
expect(b?.patch).toContain("+new") yield* write(path.join(test.directory, "b.txt"), "old\n")
}) yield* git(test.directory, ["add", "."])
}) yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add files"])
yield* remove(path.join(test.directory, "a.txt"))
yield* symlink("target", path.join(test.directory, "a.txt"))
yield* write(path.join(test.directory, "b.txt"), "new\n")
test("diff('git') keeps carriage returns inside patch hunks", async () => { const vcs = yield* init()
await using tmp = await tmpdir({ git: true }) const diff = yield* vcs.diff("git")
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8") const a = diff.find((item) => item.file === "a.txt")
await $`git add .`.cwd(tmp.path).quiet() const b = diff.find((item) => item.file === "b.txt")
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8")
await withVcsOnly(tmp.path, async () => { expect(a?.patch).toContain("deleted file mode")
const diff = await AppRuntime.runPromise( expect(a?.patch).toContain("new file mode")
Effect.gen(function* () { expect(b?.patch).toContain("+new")
const vcs = yield* Vcs.Service }),
return yield* vcs.diff("git") { git: true },
}), )
)
const file = diff.find((item) => item.file === "file.txt")
expect(file?.patch).toContain(" same\rdiff --git inside") it.instance(
expect(file?.patch).toContain("-delete") "diff('git') keeps carriage returns inside patch hunks",
expect(() => parsePatch(file?.patch ?? "")).not.toThrow() () =>
}) Effect.gen(function* () {
}, 20_000) const test = yield* TestInstance
yield* write(path.join(test.directory, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n")
yield* git(test.directory, ["add", "."])
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
yield* write(path.join(test.directory, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n")
test("diff('branch') returns changes against default branch", async () => { const vcs = yield* init()
await using tmp = await tmpdir({ git: true }) const diff = yield* vcs.diff("git")
await $`git branch -M main`.cwd(tmp.path).quiet() const file = diff.find((item) => item.file === "file.txt")
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
await $`git add .`.cwd(tmp.path).quiet()
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
await withVcsOnly(tmp.path, async () => { expect(file?.patch).toContain(" same\rdiff --git inside")
const diff = await AppRuntime.runPromise( expect(file?.patch).toContain("-delete")
Effect.gen(function* () { expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
const vcs = yield* Vcs.Service }),
return yield* vcs.diff("branch") { git: true },
}), 20_000,
) )
expect(diff).toEqual(
expect.arrayContaining([ it.instance(
expect.objectContaining({ "diff('branch') returns changes against default branch",
file: "branch.txt", () =>
status: "added", Effect.gen(function* () {
}), const test = yield* TestInstance
]), yield* git(test.directory, ["branch", "-M", "main"])
) yield* git(test.directory, ["checkout", "-b", "feature/test"])
}) yield* write(path.join(test.directory, "branch.txt"), "hello\n")
}) yield* git(test.directory, ["add", "."])
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "branch file"])
const vcs = yield* init()
const diff = yield* vcs.diff("branch")
expect(diff).toEqual(
expect.arrayContaining([
expect.objectContaining({
file: "branch.txt",
status: "added",
}),
]),
)
}),
{ git: true },
)
}) })