test(server): migrate worktree endpoint repro to effect runner (#27220)
This commit is contained in:
@@ -1,13 +1,14 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect, Layer } from "effect"
|
import { Effect, Layer, Queue } from "effect"
|
||||||
import { HttpRouter } from "effect/unstable/http"
|
import { HttpRouter } from "effect/unstable/http"
|
||||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||||
|
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||||
|
import { Worktree } from "@/worktree"
|
||||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||||
import { withTimeout } from "../../src/util/timeout"
|
|
||||||
import { resetDatabase } from "../fixture/db"
|
import { resetDatabase } from "../fixture/db"
|
||||||
import { TestInstance } from "../fixture/fixture"
|
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
const stateLayer = Layer.effectDiscard(
|
const stateLayer = Layer.effectDiscard(
|
||||||
@@ -28,7 +29,10 @@ const stateLayer = Layer.effectDiscard(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const it = testEffect(stateLayer)
|
const it = testEffect(stateLayer)
|
||||||
|
const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance
|
||||||
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
|
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
|
||||||
|
type CreatedWorktree = { directory: string }
|
||||||
|
type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect<void, Error> }
|
||||||
|
|
||||||
function serverScoped() {
|
function serverScoped() {
|
||||||
return Effect.acquireRelease(
|
return Effect.acquireRelease(
|
||||||
@@ -44,14 +48,106 @@ function request(server: TestServer, input: string, init?: RequestInit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
|
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
|
||||||
return Effect.promise(() => withTimeout(Effect.runPromise(effect), ms, label))
|
return effect.pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: `${ms} millis`,
|
||||||
|
orElse: () => Effect.fail(new Error(`${label} timed out after ${ms}ms`)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function json<T>(response: Response) {
|
||||||
|
return Effect.promise(() => response.json() as Promise<T>)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readyWatcher() {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const events = yield* Queue.bounded<GlobalEvent>(1)
|
||||||
|
const on = (event: GlobalEvent) => {
|
||||||
|
if (event.payload.type === Worktree.Event.Ready.type) Queue.offerUnsafe(events, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
GlobalBus.on("event", on)
|
||||||
|
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||||
|
|
||||||
|
return (directory: string) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
while (true) {
|
||||||
|
const event = yield* Queue.take(events)
|
||||||
|
if (event.directory === directory) return
|
||||||
|
}
|
||||||
|
}).pipe(
|
||||||
|
Effect.timeoutOrElse({
|
||||||
|
duration: "10 seconds",
|
||||||
|
orElse: () => Effect.fail(new Error(`timed out waiting for worktree.ready: ${directory}`)),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCreatedWorktree(input: {
|
||||||
|
server: TestServer
|
||||||
|
rootDirectory: string
|
||||||
|
worktreeDirectory: string
|
||||||
|
ready: Effect.Effect<void, Error>
|
||||||
|
}) {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
yield* input.ready.pipe(Effect.timeout("1 second"), Effect.ignore)
|
||||||
|
yield* Effect.promise(() => disposeAllInstances()).pipe(Effect.ignore)
|
||||||
|
|
||||||
|
const removed = yield* request(
|
||||||
|
input.server,
|
||||||
|
`${ExperimentalPaths.worktree}?directory=${encodeURIComponent(input.rootDirectory)}`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ directory: input.worktreeDirectory }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if (removed.status !== 200) {
|
||||||
|
const message = yield* Effect.promise(() => removed.text())
|
||||||
|
throw new Error(`failed to remove worktree: ${removed.status} ${message}`)
|
||||||
|
}
|
||||||
|
const ok = yield* json<boolean>(removed)
|
||||||
|
if (!ok) throw new Error(`failed to remove worktree ${input.worktreeDirectory}`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWorktreeScoped(input: {
|
||||||
|
server: TestServer
|
||||||
|
directory: string
|
||||||
|
path: string
|
||||||
|
init: RequestInit
|
||||||
|
timeoutLabel: string
|
||||||
|
timeoutMs?: number
|
||||||
|
}) {
|
||||||
|
return Effect.acquireRelease(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const waitReady = yield* readyWatcher()
|
||||||
|
const response = yield* withRequestTimeout(
|
||||||
|
request(input.server, input.path, input.init),
|
||||||
|
input.timeoutLabel,
|
||||||
|
input.timeoutMs,
|
||||||
|
)
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const body = yield* json<CreatedWorktree>(response)
|
||||||
|
return { directory: body.directory, body, ready: waitReady(body.directory) } satisfies ScopedWorktree
|
||||||
|
}),
|
||||||
|
(created) =>
|
||||||
|
removeCreatedWorktree({
|
||||||
|
server: input.server,
|
||||||
|
rootDirectory: input.directory,
|
||||||
|
worktreeDirectory: created.directory,
|
||||||
|
ready: created.ready,
|
||||||
|
}).pipe(Effect.orDie),
|
||||||
|
).pipe(Effect.map((created) => created.body))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
|
function setProjectStartCommand(input: { server: TestServer; directory: string; command: string }) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
|
const current = yield* request(input.server, `/project/current?directory=${encodeURIComponent(input.directory)}`)
|
||||||
expect(current.status).toBe(200)
|
expect(current.status).toBe(200)
|
||||||
const project = (yield* Effect.promise(() => current.json())) as { id: string }
|
const project = yield* json<{ id: string }>(current)
|
||||||
const updated = yield* request(
|
const updated = yield* request(
|
||||||
input.server,
|
input.server,
|
||||||
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
|
`/project/${project.id}?directory=${encodeURIComponent(input.directory)}`,
|
||||||
@@ -66,47 +162,51 @@ function setProjectStartCommand(input: { server: TestServer; directory: string;
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("worktree endpoint reproduction", () => {
|
describe("worktree endpoint reproduction", () => {
|
||||||
it.instance(
|
worktreeTest(
|
||||||
"direct HttpApi worktree create returns without waiting for boot",
|
"direct HttpApi worktree create returns without waiting for boot",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
const server = yield* serverScoped()
|
const server = yield* serverScoped()
|
||||||
|
|
||||||
const response = yield* withRequestTimeout(
|
const response = yield* createWorktreeScoped({
|
||||||
request(server, `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`, {
|
server,
|
||||||
|
directory: test.directory,
|
||||||
|
path: `${ExperimentalPaths.worktree}?directory=${encodeURIComponent(test.directory)}`,
|
||||||
|
init: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
}),
|
},
|
||||||
"direct worktree create",
|
timeoutLabel: "direct worktree create",
|
||||||
)
|
})
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response).toMatchObject({ directory: expect.any(String) })
|
||||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: expect.any(String) })
|
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
worktreeTest(
|
||||||
"workspace worktree create does not hang",
|
"workspace worktree create does not hang",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const test = yield* TestInstance
|
const test = yield* TestInstance
|
||||||
const server = yield* serverScoped()
|
const server = yield* serverScoped()
|
||||||
|
|
||||||
const response = yield* withRequestTimeout(
|
const response = yield* createWorktreeScoped({
|
||||||
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
server,
|
||||||
|
directory: test.directory,
|
||||||
|
path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
|
||||||
|
init: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||||
}),
|
},
|
||||||
"workspace worktree create",
|
timeoutLabel: "workspace worktree create",
|
||||||
8_000,
|
timeoutMs: 8_000,
|
||||||
)
|
})
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
expect(response).toMatchObject({
|
||||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({
|
|
||||||
type: "worktree",
|
type: "worktree",
|
||||||
directory: expect.any(String),
|
directory: expect.any(String),
|
||||||
})
|
})
|
||||||
@@ -114,7 +214,7 @@ describe("worktree endpoint reproduction", () => {
|
|||||||
{ git: true },
|
{ git: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
it.instance(
|
worktreeTest(
|
||||||
"workspace worktree create returns without waiting for project start command",
|
"workspace worktree create returns without waiting for project start command",
|
||||||
() =>
|
() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -127,17 +227,19 @@ describe("worktree endpoint reproduction", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const started = Date.now()
|
const started = Date.now()
|
||||||
const response = yield* withRequestTimeout(
|
yield* createWorktreeScoped({
|
||||||
request(server, `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`, {
|
server,
|
||||||
|
directory: test.directory,
|
||||||
|
path: `${WorkspacePaths.list}?directory=${encodeURIComponent(test.directory)}`,
|
||||||
|
init: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||||
}),
|
},
|
||||||
"workspace worktree create with project start command",
|
timeoutLabel: "workspace worktree create with project start command",
|
||||||
6_000,
|
timeoutMs: 6_000,
|
||||||
)
|
})
|
||||||
|
|
||||||
expect(response.status).toBe(200)
|
|
||||||
expect(Date.now() - started).toBeLessThan(1_500)
|
expect(Date.now() - started).toBeLessThan(1_500)
|
||||||
}),
|
}),
|
||||||
{ git: true },
|
{ git: true },
|
||||||
|
|||||||
Reference in New Issue
Block a user