refactor(core): move database schema ownership (#29068)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent
6bcb9cb9bb
commit
7f571d36ea
@@ -27,7 +27,7 @@ describe("session.listGlobal", () => {
|
||||
const firstSession = yield* withSession({ title: "first-session" })
|
||||
const secondSession = yield* withSession({ title: "second-session" }).pipe(provideInstance(second))
|
||||
|
||||
const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })])
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(firstSession.id)
|
||||
@@ -56,12 +56,14 @@ describe("session.listGlobal", () => {
|
||||
|
||||
yield* SessionNs.Service.use((session) => session.setArchived({ sessionID: archived.id, time: Date.now() }))
|
||||
|
||||
const sessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200 })])
|
||||
const sessions = yield* SessionNs.Service.use((session) => session.listGlobal({ limit: 200 }))
|
||||
const ids = sessions.map((session) => session.id)
|
||||
|
||||
expect(ids).not.toContain(archived.id)
|
||||
|
||||
const allSessions = yield* Effect.sync(() => [...SessionNs.listGlobal({ limit: 200, archived: true })])
|
||||
const allSessions = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ limit: 200, archived: true }),
|
||||
)
|
||||
const allIds = allSessions.map((session) => session.id)
|
||||
|
||||
expect(allIds).toContain(archived.id)
|
||||
@@ -86,13 +88,15 @@ describe("session.listGlobal", () => {
|
||||
)
|
||||
const second = yield* withSession({ title: "page-two" })
|
||||
|
||||
const page = yield* Effect.sync(() => [...SessionNs.listGlobal({ directory: test.directory, limit: 1 })])
|
||||
const page = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ directory: test.directory, limit: 1 }),
|
||||
)
|
||||
expect(page.length).toBe(1)
|
||||
expect(page[0].id).toBe(second.id)
|
||||
|
||||
const next = yield* Effect.sync(() => [
|
||||
...SessionNs.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
|
||||
])
|
||||
const next = yield* SessionNs.Service.use((session) =>
|
||||
session.listGlobal({ directory: test.directory, limit: 10, cursor: page[0].time.updated }),
|
||||
)
|
||||
const ids = next.map((session) => session.id)
|
||||
|
||||
expect(ids).toContain(first.id)
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
// Diagnostic suite for /event SSE delivery.
|
||||
//
|
||||
// Each test isolates ONE variable in the publisher chain while keeping the
|
||||
// subscriber path constant (in-process HttpApi via Server.Default reading the
|
||||
// SSE body). The pass/fail pattern across tests tells us where the bug lives:
|
||||
//
|
||||
// D1 (baseline): publish via Bus.use.publish — mirror of httpapi-event.test.ts
|
||||
// test 3. Confirms /event SSE delivery works for SOME publish path.
|
||||
//
|
||||
// D2: publish N times in quick succession via Bus.use.publish. If the bus
|
||||
// subscription is acquired correctly there should be no message loss.
|
||||
//
|
||||
// D3: publish via SyncEvent.use.run — exercises the same path the HTTP
|
||||
// handlers use (Session.updatePart → sync.run → bus.publish) without
|
||||
// the HTTP roundtrip. Tells us whether the sync path itself can deliver
|
||||
// in-process.
|
||||
//
|
||||
// D4: publish via SyncEvent.use.run; subscriber is an in-process Bus
|
||||
// callback. Confirms pub/sub identity end-to-end without /event SSE.
|
||||
//
|
||||
// D5: in-process Bus callback subscriber AND raw /event SSE subscriber
|
||||
// receive the same publish. If both receive: no bug. If only the
|
||||
// callback receives: the /event handler has an acquisition race.
|
||||
//
|
||||
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
|
||||
// subscription is established. Order-of-setup variable.
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Layer, Schema } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Event as ServerEvent } from "../../src/server/event"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SyncEvent } from "../../src/sync"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffectShared } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const SseEvent = Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
type: Schema.String,
|
||||
properties: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
|
||||
type SseEvent = Schema.Schema.Type<typeof SseEvent>
|
||||
type BusEvent = { type: string; properties: unknown }
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const it = testEffectShared(Layer.mergeAll(Bus.defaultLayer, SyncEvent.defaultLayer))
|
||||
|
||||
const publishConnected = Bus.use.publish(ServerEvent.Connected, {})
|
||||
|
||||
const publishPartUpdated = (partID: ReturnType<typeof PartID.ascending>) => {
|
||||
const sessionID = SessionID.make(`ses_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`)
|
||||
return SyncEvent.use.run(MessageV2.Event.PartUpdated, {
|
||||
sessionID,
|
||||
part: { id: partID, sessionID, messageID: MessageID.ascending(), type: "text", text: "diag" },
|
||||
time: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
const subscribeAllCallback = (handler: (event: BusEvent) => void) =>
|
||||
Effect.acquireRelease(Bus.use.subscribeAllCallback(handler), (dispose) => Effect.sync(() => dispose()))
|
||||
|
||||
const openEventStream = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
|
||||
)
|
||||
if (!response.body) return yield* Effect.die("missing SSE response body")
|
||||
const reader = response.body.getReader()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
|
||||
return reader
|
||||
})
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
function decodeFrame(value: Uint8Array): SseEvent[] {
|
||||
return decoder
|
||||
.decode(value)
|
||||
.split(/\n\n+/)
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part.length > 0)
|
||||
.map((part) => Schema.decodeUnknownSync(SseEvent)(JSON.parse(part.replace(/^data: /, ""))))
|
||||
}
|
||||
|
||||
const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
|
||||
Effect.promise(() => reader.read()).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "3 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out reading SSE chunk")),
|
||||
}),
|
||||
Effect.flatMap((result) => {
|
||||
if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
|
||||
const frames = decodeFrame(result.value)
|
||||
if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
|
||||
return Effect.succeed(frames[0]!)
|
||||
}),
|
||||
)
|
||||
|
||||
const collectUntilEvent = (reader: ReadableStreamDefaultReader<Uint8Array>, predicate: (event: SseEvent) => boolean) =>
|
||||
Effect.gen(function* () {
|
||||
const events: SseEvent[] = []
|
||||
while (true) {
|
||||
const event = yield* readNextEvent(reader)
|
||||
events.push(event)
|
||||
if (predicate(event)) return events
|
||||
}
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "4 seconds",
|
||||
orElse: () => Effect.fail(new Error("collectUntil deadline exceeded")),
|
||||
}),
|
||||
)
|
||||
|
||||
const isPartUpdated = (event: { type: string }) => event.type === MessageV2.Event.PartUpdated.type
|
||||
|
||||
describe("/event SSE delivery diagnostics", () => {
|
||||
// Sanity: baseline same as httpapi-event.test.ts test 3 (already known to pass)
|
||||
// but explicit about timing — publish happens with NO wait after reading
|
||||
// server.connected. If this fails we have a deeper problem than just sync.
|
||||
it.instance(
|
||||
"D1: delivers a single bus event published right after server.connected",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openEventStream(directory)
|
||||
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
yield* publishConnected
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// If D1 passes but D2 fails, we have a queue-drain or partial-loss issue.
|
||||
it.instance(
|
||||
"D2: delivers all N bus events published in rapid succession",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const N = 5
|
||||
yield* Effect.replicateEffect(publishConnected, N)
|
||||
|
||||
const received = yield* Effect.replicateEffect(readNextEvent(reader), N)
|
||||
expect(received).toHaveLength(N)
|
||||
for (const event of received) expect(event.type).toBe("server.connected")
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// The critical test. If D1 passes but this fails, the bus-identity fix is
|
||||
// incomplete OR the sync.run publish path doesn't reach the same bus
|
||||
// /event subscribes to, even when both share the memoMap.
|
||||
it.instance(
|
||||
"D3: delivers a SyncEvent published via SyncEvent.use.run after server.connected",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const partID = PartID.ascending()
|
||||
yield* publishPartUpdated(partID)
|
||||
|
||||
const collected = yield* collectUntilEvent(reader, isPartUpdated)
|
||||
const updated = collected.find(isPartUpdated)
|
||||
expect(updated?.properties.part.id).toBe(partID)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// If D3 passes but D5 (the SDK E2E in httpapi-sdk.test.ts) fails, then the
|
||||
// bug is specifically in the cross-request / cross-fiber HTTP path, not in
|
||||
// the publish itself. If D3 also fails, the publish chain is broken.
|
||||
//
|
||||
// D4: ensure the publish reaches an in-process Bus subscriber too. Confirms
|
||||
// pub/sub identity end-to-end without involving /event SSE.
|
||||
it.instance(
|
||||
"D4: SyncEvent.use.run publish reaches an in-process Bus callback",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const received = yield* Deferred.make<BusEvent>()
|
||||
yield* subscribeAllCallback((event) => {
|
||||
if (isPartUpdated(event)) Deferred.doneUnsafe(received, Effect.succeed(event))
|
||||
})
|
||||
|
||||
const partID = PartID.ascending()
|
||||
yield* publishPartUpdated(partID)
|
||||
|
||||
const event = yield* Deferred.await(received).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "3 seconds",
|
||||
orElse: () => Effect.fail(new Error("D4 timed out waiting for callback")),
|
||||
}),
|
||||
)
|
||||
expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
|
||||
expect(event.properties).toMatchObject({ part: { id: partID } })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// D5: BOTH subscribers attached simultaneously. Trigger ONE publish via
|
||||
// SyncEvent.use.run. Both subscribers should receive it. If only one does
|
||||
// we know exactly which side of the chain is failing.
|
||||
it.instance(
|
||||
"D5: same SyncEvent.use.run publish reaches BOTH /event SSE and in-process callback",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const callbackReceived = yield* Deferred.make<BusEvent>()
|
||||
yield* subscribeAllCallback((event) => {
|
||||
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
|
||||
})
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const partID = PartID.ascending()
|
||||
yield* publishPartUpdated(partID)
|
||||
|
||||
const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
|
||||
Effect.map((events) => events.some(isPartUpdated)),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
|
||||
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
|
||||
Effect.map((event) => event !== undefined),
|
||||
)
|
||||
|
||||
// Single assert with the boolean pair so the failure message tells us
|
||||
// exactly which side broke.
|
||||
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
// D6: same as D5 but the callback subscriber is attached AFTER /event SSE
|
||||
// subscription is established. If D5 fails and D6 passes, the order of
|
||||
// subscriber setup is the determining factor.
|
||||
it.instance(
|
||||
"D6: /event SSE receives sync.run publish when callback is attached AFTER /event opens",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const reader = yield* openEventStream(directory)
|
||||
expect((yield* readNextEvent(reader)).type).toBe("server.connected")
|
||||
|
||||
const callbackReceived = yield* Deferred.make<BusEvent>()
|
||||
yield* subscribeAllCallback((event) => {
|
||||
if (isPartUpdated(event)) Deferred.doneUnsafe(callbackReceived, Effect.succeed(event))
|
||||
})
|
||||
|
||||
const partID = PartID.ascending()
|
||||
yield* publishPartUpdated(partID)
|
||||
|
||||
const sseSaw = yield* collectUntilEvent(reader, isPartUpdated).pipe(
|
||||
Effect.map((events) => events.some(isPartUpdated)),
|
||||
Effect.catch(() => Effect.succeed(false)),
|
||||
)
|
||||
const callbackSaw = yield* Deferred.await(callbackReceived).pipe(
|
||||
Effect.timeoutOrElse({ duration: "1 second", orElse: () => Effect.succeed(undefined) }),
|
||||
Effect.map((event) => event !== undefined),
|
||||
)
|
||||
expect({ sseSaw, callbackSaw }).toEqual({ sseSaw: true, callbackSaw: true })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
@@ -1,13 +1,11 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Layer, Queue, Schema, Stream } from "effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Event as ServerEvent } from "../../src/server/event"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffectShared } from "../lib/effect"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -17,28 +15,25 @@ const EventData = Schema.Struct({
|
||||
properties: Schema.Record(Schema.String, Schema.Any),
|
||||
})
|
||||
|
||||
const readEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
|
||||
const readEvent = (reader: Queue.Dequeue<Uint8Array>) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* Effect.promise(() => reader.read()).pipe(
|
||||
const value = yield* Queue.take(reader).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "5 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for event")),
|
||||
}),
|
||||
)
|
||||
if (result.done || !result.value) return yield* Effect.fail(new Error("event stream closed"))
|
||||
return Schema.decodeUnknownSync(EventData)(
|
||||
JSON.parse(new TextDecoder().decode(result.value).replace(/^data: /, "")),
|
||||
)
|
||||
return Schema.decodeUnknownSync(EventData)(JSON.parse(new TextDecoder().decode(value).replace(/^data: /, "")))
|
||||
})
|
||||
|
||||
const openEventStream = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(EventPaths.event, { headers: { "x-opencode-directory": directory } }),
|
||||
const response = yield* requestInDirectory(EventPaths.event, directory)
|
||||
const reader = yield* Queue.unbounded<Uint8Array>()
|
||||
yield* response.stream.pipe(
|
||||
Stream.runForEach((value) => Queue.offer(reader, value)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
if (!response.body) return yield* Effect.die("missing SSE response body")
|
||||
const reader = response.body.getReader()
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => reader.cancel().catch(() => undefined)))
|
||||
return { response, reader }
|
||||
})
|
||||
|
||||
@@ -47,7 +42,7 @@ afterEach(async () => {
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const it = testEffectShared(Bus.defaultLayer)
|
||||
const it = testEffect(httpApiLayer)
|
||||
|
||||
describe("event HttpApi", () => {
|
||||
it.instance(
|
||||
@@ -58,10 +53,10 @@ describe("event HttpApi", () => {
|
||||
const { response, reader } = yield* openEventStream(directory)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get("content-type")).toContain("text/event-stream")
|
||||
expect(response.headers.get("cache-control")).toBe("no-cache, no-transform")
|
||||
expect(response.headers.get("x-accel-buffering")).toBe("no")
|
||||
expect(response.headers.get("x-content-type-options")).toBe("nosniff")
|
||||
expect(response.headers["content-type"]).toContain("text/event-stream")
|
||||
expect(response.headers["cache-control"]).toBe("no-cache, no-transform")
|
||||
expect(response.headers["x-accel-buffering"]).toBe("no")
|
||||
expect(response.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
@@ -76,8 +71,8 @@ describe("event HttpApi", () => {
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
|
||||
// If no second event arrives within 250ms, the stream is still open.
|
||||
const status = yield* Effect.promise(() => reader.read()).pipe(
|
||||
Effect.map((result) => (result.done ? ("closed" as const) : ("event" as const))),
|
||||
const status = yield* Queue.take(reader).pipe(
|
||||
Effect.as("event" as const),
|
||||
Effect.timeoutOrElse({ duration: "250 millis", orElse: () => Effect.succeed("open" as const) }),
|
||||
)
|
||||
expect(status).toBe("open")
|
||||
@@ -86,16 +81,18 @@ describe("event HttpApi", () => {
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"delivers instance bus events after the initial event",
|
||||
"delivers instance events after the initial event",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const { directory } = yield* TestInstance
|
||||
const { reader } = yield* openEventStream(directory)
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
|
||||
yield* Bus.use.publish(ServerEvent.Connected, {})
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "server.connected", properties: {} })
|
||||
const created = yield* requestInDirectory("/session", directory, { method: "POST" })
|
||||
expect(created.status).toBe(200)
|
||||
expect(yield* readEvent(reader)).toMatchObject({ type: "session.created" })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
@@ -56,7 +56,7 @@ function app(modules: Runtime, options: CallOptions) {
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
{ disableLogger: true, memoMap: modules.memoMap },
|
||||
).handler
|
||||
return (appCache[cacheKey] = {
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Cause, Duration, Effect } from "effect"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Cause, Duration, Effect, Layer, Scope } from "effect"
|
||||
import { TestLLMServer } from "../../lib/llm-server"
|
||||
import type { Config } from "../../../src/config/config"
|
||||
import { ModelID, ProviderID } from "../../../src/provider/schema"
|
||||
|
||||
import type { MessageV2 } from "../../../src/session/message-v2"
|
||||
import { MessageID, PartID } from "../../../src/session/schema"
|
||||
import { call, callAuthProbe } from "./backend"
|
||||
import { original } from "./environment"
|
||||
import { runtime } from "./runtime"
|
||||
import type { ActiveScenario, Options, ProjectOptions, Result, Scenario, ScenarioContext, SeededContext } from "./types"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
|
||||
export function runScenario(options: Options) {
|
||||
return (scenario: Scenario) => {
|
||||
@@ -85,18 +87,20 @@ function withContext<A, E>(
|
||||
Effect.gen(function* () {
|
||||
yield* trace(options, scenario, `${label} runtime start`)
|
||||
const modules = yield* Effect.promise(() => runtime())
|
||||
const scope = yield* Scope.Scope
|
||||
const app = yield* Layer.buildWithMemoMap(modules.AppLayer, modules.memoMap, scope)
|
||||
yield* trace(options, scenario, `${label} runtime done`)
|
||||
const path = context.dir?.path
|
||||
const instance = path
|
||||
? yield* trace(options, scenario, `${label} instance load start`).pipe(
|
||||
Effect.andThen(
|
||||
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
|
||||
Effect.provide(modules.AppLayer),
|
||||
Effect.provide(app),
|
||||
Effect.catchCause((cause) =>
|
||||
Effect.sleep("100 millis").pipe(
|
||||
Effect.andThen(
|
||||
modules.InstanceStore.Service.use((store) => store.load({ directory: path })).pipe(
|
||||
Effect.provide(modules.AppLayer),
|
||||
Effect.provide(app),
|
||||
),
|
||||
),
|
||||
Effect.catchCause(() => Effect.failCause(cause)),
|
||||
@@ -108,7 +112,7 @@ function withContext<A, E>(
|
||||
)
|
||||
: undefined
|
||||
const run = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(modules.AppLayer))
|
||||
effect.pipe(Effect.provideService(modules.InstanceRef, instance), Effect.provide(app))
|
||||
const directory = () => {
|
||||
if (!context.dir?.path) throw new Error("scenario needs a project directory")
|
||||
return context.dir.path
|
||||
@@ -140,18 +144,18 @@ function withContext<A, E>(
|
||||
}),
|
||||
message: (sessionID, input) =>
|
||||
Effect.gen(function* () {
|
||||
const info: MessageV2.User = {
|
||||
const info: SessionLegacy.User = {
|
||||
id: MessageID.ascending(),
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: {
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("test"),
|
||||
providerID: ProviderV2.ID.opencode,
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
},
|
||||
}
|
||||
const part: MessageV2.TextPart = {
|
||||
const part: SessionLegacy.TextPart = {
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: info.id,
|
||||
|
||||
@@ -2,6 +2,7 @@ export type Runtime = {
|
||||
PublicApi: (typeof import("../../../src/server/routes/instance/httpapi/public"))["PublicApi"]
|
||||
HttpApiApp: (typeof import("../../../src/server/routes/instance/httpapi/server"))["HttpApiApp"]
|
||||
AppLayer: (typeof import("../../../src/effect/app-runtime"))["AppLayer"]
|
||||
memoMap: (typeof import("@opencode-ai/core/effect/memo-map"))["memoMap"]
|
||||
InstanceRef: (typeof import("../../../src/effect/instance-ref"))["InstanceRef"]
|
||||
InstanceStore: (typeof import("../../../src/project/instance-store"))["InstanceStore"]
|
||||
Session: (typeof import("../../../src/session/session"))["Session"]
|
||||
@@ -21,6 +22,7 @@ export function runtime() {
|
||||
const publicApi = await import("../../../src/server/routes/instance/httpapi/public")
|
||||
const httpApiServer = await import("../../../src/server/routes/instance/httpapi/server")
|
||||
const appRuntime = await import("../../../src/effect/app-runtime")
|
||||
const memoMap = await import("@opencode-ai/core/effect/memo-map")
|
||||
const instanceRef = await import("../../../src/effect/instance-ref")
|
||||
const instanceStore = await import("../../../src/project/instance-store")
|
||||
const session = await import("../../../src/session/session")
|
||||
@@ -34,6 +36,7 @@ export function runtime() {
|
||||
PublicApi: publicApi.PublicApi,
|
||||
HttpApiApp: httpApiServer.HttpApiApp,
|
||||
AppLayer: appRuntime.AppLayer,
|
||||
memoMap: memoMap.memoMap,
|
||||
InstanceRef: instanceRef.InstanceRef,
|
||||
InstanceStore: instanceStore.InstanceStore,
|
||||
Session: session.Session,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Duration, Effect } from "effect"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import type { Config } from "../../../src/config/config"
|
||||
import type { Project } from "../../../src/project/project"
|
||||
import type { Worktree } from "../../../src/worktree"
|
||||
@@ -57,7 +58,7 @@ export type ScenarioContext = {
|
||||
sessionGet: (sessionID: SessionID) => Effect.Effect<SessionInfo | undefined>
|
||||
project: () => Effect.Effect<Project.Info>
|
||||
message: (sessionID: SessionID, input?: { text?: string }) => Effect.Effect<MessageSeed>
|
||||
messages: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts[]>
|
||||
messages: (sessionID: SessionID) => Effect.Effect<SessionLegacy.WithParts[]>
|
||||
todos: (sessionID: SessionID, todos: TodoInfo[]) => Effect.Effect<void>
|
||||
worktree: (input?: { name?: string }) => Effect.Effect<Worktree.Info>
|
||||
worktreeRemove: (directory: string) => Effect.Effect<void>
|
||||
@@ -118,4 +119,4 @@ export type Result =
|
||||
|
||||
export type SessionInfo = { id: SessionID; title: string; parentID?: SessionID }
|
||||
export type TodoInfo = { content: string; status: string; priority: string }
|
||||
export type MessageSeed = { info: MessageV2.User; part: MessageV2.TextPart }
|
||||
export type MessageSeed = { info: SessionLegacy.User; part: SessionLegacy.TextPart }
|
||||
|
||||
@@ -1,41 +1,36 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AccountV2 } from "@opencode-ai/core/account"
|
||||
import { AccountTable } from "@opencode-ai/core/account/sql"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
return Effect.promise(() => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Promise.resolve(app().request(path, { ...init, headers }))
|
||||
})
|
||||
return requestInDirectory(path, directory, init)
|
||||
}
|
||||
|
||||
function createSession(input?: Session.CreateInput) {
|
||||
return Session.use.create(input)
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(() => response.json() as Promise<T>)
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
function waitReady(input: { directory?: string; name?: string }) {
|
||||
@@ -62,38 +57,50 @@ function waitReady(input: { directory?: string; name?: string }) {
|
||||
|
||||
function insertAccount() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
Database.Client()
|
||||
.$client.prepare(
|
||||
"INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
"account-test",
|
||||
"test@example.com",
|
||||
"https://console.example.com",
|
||||
"access",
|
||||
"refresh",
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(AccountTable)
|
||||
.values({
|
||||
id: AccountV2.ID.make("account-test"),
|
||||
email: "test@example.com",
|
||||
url: "https://console.example.com",
|
||||
access_token: AccountV2.AccessToken.make("access"),
|
||||
refresh_token: AccountV2.RefreshToken.make("refresh"),
|
||||
time_created: Date.now(),
|
||||
time_updated: Date.now(),
|
||||
})
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return "account-test"
|
||||
}),
|
||||
(id) =>
|
||||
Effect.sync(() => {
|
||||
Database.Client().$client.prepare("DELETE FROM account WHERE id = ?").run(id)
|
||||
}),
|
||||
Database.Service.use(({ db }) =>
|
||||
db
|
||||
.delete(AccountTable)
|
||||
.where(eq(AccountTable.id, AccountV2.ID.make(id)))
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function setSessionUpdated(session: Session.Info, updated: number) {
|
||||
return Effect.sync(() => {
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run(),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({ time_updated: updated })
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
}
|
||||
|
||||
function withCreatedWorktree(directory: string, use: (info: Worktree.Info) => Effect.Effect<void, unknown, never>) {
|
||||
function withCreatedWorktree(
|
||||
directory: string,
|
||||
use: (info: Worktree.Info) => Effect.Effect<void, unknown, HttpClient.HttpClient>,
|
||||
) {
|
||||
const name = "api-test"
|
||||
const headers = { "content-type": "application/json" }
|
||||
return Effect.acquireUseRelease(
|
||||
@@ -242,7 +249,7 @@ describe("experimental HttpApi", () => {
|
||||
tmp.directory,
|
||||
)
|
||||
expect(page.status).toBe(200)
|
||||
expect(page.headers.get("x-next-cursor")).toBeTruthy()
|
||||
expect(page.headers["x-next-cursor"]).toBeTruthy()
|
||||
|
||||
const body = yield* json<Session.GlobalInfo[]>(page)
|
||||
expect(body.map((session) => session.id)).toEqual([second.id])
|
||||
|
||||
@@ -7,7 +7,7 @@ import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { InstanceRef, WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
@@ -236,7 +236,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
|
||||
it.live("uses configured workspace id instead of routing to the requested workspace", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixedWorkspaceID = WorkspaceID.ascending()
|
||||
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
yield* withFixedWorkspaceID(fixedWorkspaceID)
|
||||
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
@@ -264,7 +264,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
|
||||
it.live("falls through to local instead of MissingWorkspace when configured workspace id is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixedWorkspaceID = WorkspaceID.ascending()
|
||||
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
yield* withFixedWorkspaceID(fixedWorkspaceID)
|
||||
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
@@ -276,7 +276,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
// MissingWorkspace response. With the env set, planRequest must skip the
|
||||
// MissingWorkspace branch and fall through to Local with the configured
|
||||
// workspace id.
|
||||
const unknownWorkspaceID = WorkspaceID.ascending()
|
||||
const unknownWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
const response = yield* HttpClientRequest.get(`/probe?workspace=${unknownWorkspaceID}`).pipe(
|
||||
HttpClientRequest.setHeader("x-opencode-directory", dir),
|
||||
HttpClient.execute,
|
||||
@@ -292,7 +292,7 @@ describe("HttpApi instance context middleware", () => {
|
||||
|
||||
it.live("keeps configured workspace id on control-plane routes without remote routing", () =>
|
||||
Effect.gen(function* () {
|
||||
const fixedWorkspaceID = WorkspaceID.ascending()
|
||||
const fixedWorkspaceID = WorkspaceV2.ID.ascending()
|
||||
yield* withFixedWorkspaceID(fixedWorkspaceID)
|
||||
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
|
||||
@@ -4,12 +4,12 @@ import { describe, expect } from "bun:test"
|
||||
import { Config, Context, Effect, FileSystem, Layer, Path } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import * as Socket from "effect/unstable/socket/Socket"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { ProjectV2 } from "@opencode-ai/core/project"
|
||||
import { QuestionID } from "../../src/question/schema"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
@@ -17,7 +17,7 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// Flip the experimental workspaces flag so SyncEvent.run actually writes to
|
||||
// Flip the experimental workspaces flag so EventV2.run actually writes to
|
||||
// EventSequenceTable (the source of truth the fence middleware reads). Reset
|
||||
// the database around the test so per-instance state does not leak between
|
||||
// runs. resetDatabase() already calls disposeAllInstances(), so we don't
|
||||
@@ -76,7 +76,7 @@ describe("instance HttpApi", () => {
|
||||
it.live("emits a sync fence header for fixed-workspace mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
|
||||
Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
|
||||
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
|
||||
@@ -98,7 +98,7 @@ describe("instance HttpApi", () => {
|
||||
it.live("does not emit sync fence headers for fixed-workspace reads or no-op mutations", () =>
|
||||
Effect.gen(function* () {
|
||||
const originalWorkspaceID = Flag.OPENCODE_WORKSPACE_ID
|
||||
Flag.OPENCODE_WORKSPACE_ID = WorkspaceID.ascending()
|
||||
Flag.OPENCODE_WORKSPACE_ID = WorkspaceV2.ID.ascending()
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
Flag.OPENCODE_WORKSPACE_ID = originalWorkspaceID
|
||||
@@ -209,7 +209,7 @@ describe("instance HttpApi", () => {
|
||||
it.live("returns typed not found bodies for missing projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const projectID = ProjectID.make("project_missing")
|
||||
const projectID = ProjectV2.ID.make("project_missing")
|
||||
const response = yield* Effect.promise(() =>
|
||||
HttpApiApp.webHandler().handler(
|
||||
new Request(`http://localhost/project/${projectID}`, {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { Config, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{
|
||||
disableListenLog: true,
|
||||
disableLogger: true,
|
||||
},
|
||||
)
|
||||
|
||||
export const httpApiLayer = servedRoutes.pipe(
|
||||
Layer.provide(layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
)
|
||||
|
||||
export function request(path: string, init?: RequestInit) {
|
||||
const url = new URL(path, "http://localhost")
|
||||
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
|
||||
HttpClientRequest.setUrl(url.pathname),
|
||||
HttpClient.execute,
|
||||
)
|
||||
}
|
||||
|
||||
export function requestInDirectory(path: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return request(path, { ...init, headers })
|
||||
}
|
||||
@@ -2,12 +2,12 @@ import { describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { markPluginDependenciesReady } from "../fixture/plugin"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, request } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -18,16 +18,12 @@ const testStateLayer = Layer.effectDiscard(
|
||||
),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, httpApiLayer))
|
||||
const projectOptions = { config: { formatter: false, lsp: false } }
|
||||
const providerID = "test-oauth-parity"
|
||||
const oauthURL = "https://example.com/oauth"
|
||||
const oauthInstructions = "Finish OAuth"
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function providerListHasFetch(list: unknown) {
|
||||
if (!Array.isArray(list)) return false
|
||||
return list.some((item: unknown) => {
|
||||
@@ -77,41 +73,34 @@ function hasProviderMutationMarker(input: unknown, key: "all" | "providers", id:
|
||||
}
|
||||
|
||||
function requestAuthorize(input: {
|
||||
app: ReturnType<typeof app>
|
||||
providerID: string
|
||||
method: number
|
||||
headers: HeadersInit
|
||||
inputs?: Record<string, string>
|
||||
}) {
|
||||
return Effect.promise(async () => {
|
||||
const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* request(`/provider/${input.providerID}/oauth/authorize`, {
|
||||
method: "POST",
|
||||
headers: input.headers,
|
||||
body: JSON.stringify({ method: input.method, ...(input.inputs ? { inputs: input.inputs } : {}) }),
|
||||
})
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
body: yield* response.text,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function requestCallback(input: {
|
||||
app: ReturnType<typeof app>
|
||||
providerID: string
|
||||
method: number
|
||||
headers: HeadersInit
|
||||
code?: string
|
||||
}) {
|
||||
return Effect.promise(async () => {
|
||||
const response = await input.app.request(`/provider/${input.providerID}/oauth/callback`, {
|
||||
function requestCallback(input: { providerID: string; method: number; headers: HeadersInit; code?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* request(`/provider/${input.providerID}/oauth/callback`, {
|
||||
method: "POST",
|
||||
headers: input.headers,
|
||||
body: JSON.stringify({ method: input.method, ...(input.code ? { code: input.code } : {}) }),
|
||||
})
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.text(),
|
||||
body: yield* response.text,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -277,15 +266,13 @@ describe("provider HttpApi", () => {
|
||||
it.instance.skip(
|
||||
"returns public v2 provider not found errors",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request("/api/provider/missing", { headers: { "x-opencode-directory": instance.directory } }),
|
||||
),
|
||||
)
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* request("/api/provider/missing", {
|
||||
headers: { "x-opencode-directory": directory },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
expect(yield* response.json).toEqual({
|
||||
_tag: "ProviderNotFoundError",
|
||||
providerID: "missing",
|
||||
message: "Provider not found: missing",
|
||||
@@ -297,13 +284,9 @@ describe("provider HttpApi", () => {
|
||||
it.instance(
|
||||
"serves OAuth authorize response shapes",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeProviderAuthPlugin(instance.directory)
|
||||
const headers = { "x-opencode-directory": instance.directory, "content-type": "application/json" }
|
||||
const server = app()
|
||||
|
||||
const directory = (yield* TestInstance).directory
|
||||
const headers = { "x-opencode-directory": directory, "content-type": "application/json" }
|
||||
const api = yield* requestAuthorize({
|
||||
app: server,
|
||||
providerID,
|
||||
method: 0,
|
||||
headers,
|
||||
@@ -315,7 +298,6 @@ describe("provider HttpApi", () => {
|
||||
expect(api).toEqual({ status: 200, body: "null" })
|
||||
|
||||
const oauth = yield* requestAuthorize({
|
||||
app: server,
|
||||
providerID,
|
||||
method: 1,
|
||||
headers,
|
||||
@@ -326,21 +308,19 @@ describe("provider HttpApi", () => {
|
||||
instructions: oauthInstructions,
|
||||
})
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeProviderAuthPlugin },
|
||||
30000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns declared provider auth validation errors",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeProviderAuthValidationPlugin(instance.directory)
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* requestAuthorize({
|
||||
app: app(),
|
||||
providerID: "test-oauth-validation",
|
||||
method: 0,
|
||||
inputs: { token: "nope" },
|
||||
headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
@@ -349,19 +329,18 @@ describe("provider HttpApi", () => {
|
||||
data: { field: "token", message: "Token must be ok" },
|
||||
})
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeProviderAuthValidationPlugin },
|
||||
30000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"returns declared provider auth callback errors",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
const directory = (yield* TestInstance).directory
|
||||
const response = yield* requestCallback({
|
||||
app: app(),
|
||||
providerID,
|
||||
method: 0,
|
||||
headers: { "x-opencode-directory": instance.directory, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": directory, "content-type": "application/json" },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
@@ -377,54 +356,48 @@ describe("provider HttpApi", () => {
|
||||
it.instance(
|
||||
"serves provider lists when auth loaders add runtime fetch options",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeFunctionOptionsPlugin(instance.directory)
|
||||
const directory = (yield* TestInstance).directory
|
||||
yield* setEnvScoped(
|
||||
"OPENCODE_AUTH_CONTENT",
|
||||
JSON.stringify({
|
||||
google: { type: "oauth", refresh: "dummy", access: "dummy", expires: 9999999999999 },
|
||||
}),
|
||||
)
|
||||
const headers = { "x-opencode-directory": instance.directory }
|
||||
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
|
||||
const configResponse = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request("/config/providers", { headers })),
|
||||
)
|
||||
const headers = { "x-opencode-directory": directory }
|
||||
const providerResponse = yield* request("/provider", { headers })
|
||||
const configResponse = yield* request("/config/providers", { headers })
|
||||
|
||||
expect(providerResponse.status).toBe(200)
|
||||
expect(configResponse.status).toBe(200)
|
||||
|
||||
const providerBody = yield* Effect.promise(() => providerResponse.json())
|
||||
const configBody = yield* Effect.promise(() => configResponse.json())
|
||||
const providerBody = yield* providerResponse.json
|
||||
const configBody = yield* configResponse.json
|
||||
expect(hasProviderWithFetch(providerBody, "all")).toBe(false)
|
||||
expect(hasProviderWithFetch(configBody, "providers")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
expect(hasNonZeroModelCost(configBody, "providers", "google")).toBe(true)
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeFunctionOptionsPlugin },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"keeps provider.models hook input mutations out of provider state",
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* writeProviderModelsMutationPlugin(instance.directory)
|
||||
const directory = (yield* TestInstance).directory
|
||||
|
||||
const headers = { "x-opencode-directory": instance.directory }
|
||||
const providerResponse = yield* Effect.promise(() => Promise.resolve(app().request("/provider", { headers })))
|
||||
const configResponse = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request("/config/providers", { headers })),
|
||||
)
|
||||
const headers = { "x-opencode-directory": directory }
|
||||
const providerResponse = yield* request("/provider", { headers })
|
||||
const configResponse = yield* request("/config/providers", { headers })
|
||||
|
||||
expect(providerResponse.status).toBe(200)
|
||||
expect(configResponse.status).toBe(200)
|
||||
|
||||
const providerBody = yield* Effect.promise(() => providerResponse.json())
|
||||
const configBody = yield* Effect.promise(() => configResponse.json())
|
||||
const providerBody = yield* providerResponse.json
|
||||
const configBody = yield* configResponse.json
|
||||
expect(hasProviderMutationMarker(providerBody, "all", "google")).toBe(false)
|
||||
expect(hasProviderMutationMarker(configBody, "providers", "google")).toBe(false)
|
||||
expect(hasNonZeroModelCost(providerBody, "all", "google")).toBe(true)
|
||||
}),
|
||||
projectOptions,
|
||||
{ ...projectOptions, init: writeProviderModelsMutationPlugin },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { eq } from "drizzle-orm"
|
||||
import * as Database from "@/storage/db"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { PartTable } from "@/session/session.sql"
|
||||
import { PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
|
||||
const text = (response: HttpClientResponse.HttpClientResponse) => response.text
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -28,7 +32,7 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
@@ -43,22 +47,20 @@ const seedCorruptStepFinishPart = Effect.gen(function* () {
|
||||
})
|
||||
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
|
||||
// that broke the user's session in the OMO/Windows bug.
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never, // drizzle's .set() can't narrow the discriminated union
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never, // drizzle's .set() can't narrow the discriminated union
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return info.id
|
||||
})
|
||||
|
||||
@@ -68,16 +70,14 @@ describe("schema-rejection wire shape", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
}),
|
||||
)
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
})
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers.get("content-type") ?? "").toContain("application/json")
|
||||
expect(res.headers["content-type"] ?? "").toContain("application/json")
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({
|
||||
name: "BadRequest",
|
||||
@@ -96,8 +96,8 @@ describe("schema-rejection wire shape", () => {
|
||||
const test = yield* TestInstance
|
||||
// /find/file?limit=999999 violates the limit constraint check.
|
||||
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
|
||||
@@ -110,12 +110,8 @@ describe("schema-rejection wire shape", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request("/api/session?limit=0", {
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
)
|
||||
const parsed = JSON.parse(yield* Effect.promise(async () => res.text()))
|
||||
const res = yield* requestInDirectory("/api/session?limit=0", test.directory)
|
||||
const parsed = JSON.parse(yield* text(res))
|
||||
expect(res.status).toBe(400)
|
||||
expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
|
||||
expect(parsed.message).toEqual(expect.any(String))
|
||||
@@ -132,14 +128,12 @@ describe("schema-rejection wire shape", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const huge = "X".repeat(50_000)
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: huge }),
|
||||
}),
|
||||
)
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(SyncPaths.history, test.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: huge }),
|
||||
})
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
// 1 KB cap + small JSON envelope ≈ <2 KB — never tens of KB.
|
||||
expect(body.length).toBeLessThan(2 * 1024)
|
||||
@@ -156,10 +150,10 @@ describe("schema-rejection wire shape", () => {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedCorruptStepFinishPart
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
const body = yield* text(res)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers.get("content-type") ?? "").toContain("application/json")
|
||||
expect(res.headers["content-type"] ?? "").toContain("application/json")
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Body" } })
|
||||
// Field path in data.message — what made this PR worth shipping.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Deferred, Effect, Layer } from "effect"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
@@ -10,11 +11,9 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
|
||||
import type { Config } from "@/config/config"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { errorMessage } from "../../src/util/error"
|
||||
@@ -24,6 +23,9 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { awaitWithTimeout, testEffect } from "../lib/effect"
|
||||
import { testProviderConfig } from "../lib/test-provider"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { httpApiLayer } from "./httpapi-layer"
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const it = testEffect(
|
||||
@@ -31,6 +33,8 @@ const it = testEffect(
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
|
||||
Database.defaultLayer,
|
||||
httpApiLayer,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -45,55 +49,47 @@ type SdkResult = { response: Response; data?: unknown; error?: unknown }
|
||||
type Captured = { status: number; data?: unknown; error?: unknown }
|
||||
type ProjectFixture = { sdk: Sdk; directory: string }
|
||||
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
|
||||
type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service
|
||||
type TestServices =
|
||||
| AppFileSystem.Service
|
||||
| ChildProcessSpawner.ChildProcessSpawner
|
||||
| InstanceStore.Service
|
||||
| HttpServer.HttpServer
|
||||
type TestScope = Scope.Scope | TestServices
|
||||
|
||||
function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
if (serverPath === "default") return Server.Default().app
|
||||
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpApiApp.routes.pipe(
|
||||
Layer.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_SERVER_PASSWORD: input?.password,
|
||||
OPENCODE_SERVER_USERNAME: input?.username,
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
fetch: (request: Request) => handler(request, HttpApiApp.context),
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return this.fetch(input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function client(
|
||||
serverPath: ServerPath,
|
||||
directory?: string,
|
||||
input?: { password?: string; username?: string; headers?: Record<string, string> },
|
||||
) {
|
||||
return createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
headers: input?.headers,
|
||||
fetch: serverFetch(serverPath, input),
|
||||
})
|
||||
return serverFetch(serverPath, input).pipe(
|
||||
Effect.map((fetch) =>
|
||||
createOpencodeClient({
|
||||
baseUrl: "http://localhost",
|
||||
directory,
|
||||
headers: input?.headers,
|
||||
fetch,
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function serverFetch(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||
const serverApp = app(serverPath, input)
|
||||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) =>
|
||||
await serverApp.fetch(request instanceof Request ? request : new Request(request, init)),
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
return HttpServer.HttpServer.use((server) =>
|
||||
Effect.sync(() => {
|
||||
void serverPath
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
Flag.OPENCODE_SERVER_USERNAME = input?.username
|
||||
const baseUrl = HttpServer.formatAddress(server.address)
|
||||
return Object.assign(
|
||||
async (request: RequestInfo | URL, init?: RequestInit) => {
|
||||
const source = request instanceof Request ? request : new Request(request, init)
|
||||
const url = new URL(source.url)
|
||||
return globalThis.fetch(new Request(new URL(`${url.pathname}${url.search}`, baseUrl), source))
|
||||
},
|
||||
{ preconnect: globalThis.fetch.preconnect },
|
||||
) satisfies typeof globalThis.fetch
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function authorization(username: string, password: string) {
|
||||
@@ -204,22 +200,14 @@ function httpapiInstance<A, E>(
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* options.setup?.(instance.directory) ?? Effect.void
|
||||
return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory })
|
||||
return yield* run({ sdk: yield* client(options.serverPath, instance.directory), directory: instance.directory })
|
||||
}),
|
||||
{ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
|
||||
)
|
||||
}
|
||||
|
||||
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
|
||||
it.live(
|
||||
name,
|
||||
Effect.gen(function* () {
|
||||
const standard = yield* scenario("default")
|
||||
yield* resetState()
|
||||
const raw = yield* scenario("raw")
|
||||
expect(raw).toEqual(standard)
|
||||
}),
|
||||
)
|
||||
it.live(name, scenario("raw"))
|
||||
}
|
||||
|
||||
function withProject<A, E, E2 = never>(
|
||||
@@ -237,7 +225,7 @@ function withProject<A, E, E2 = never>(
|
||||
config: { formatter: false, lsp: false, ...options.config },
|
||||
})
|
||||
yield* options.setup?.(directory) ?? Effect.void
|
||||
return yield* run({ sdk: client(serverPath, directory), directory })
|
||||
return yield* run({ sdk: yield* client(serverPath, directory), directory })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -310,9 +298,9 @@ function seedMessage(directory: string, sessionID: string) {
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
tools: {},
|
||||
} satisfies MessageV2.User)
|
||||
} satisfies SessionLegacy.User)
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: id,
|
||||
@@ -338,7 +326,7 @@ describe("HttpApi SDK", () => {
|
||||
httpapi(
|
||||
"uses the generated SDK for global and control routes",
|
||||
Effect.gen(function* () {
|
||||
const sdk = client("raw")
|
||||
const sdk = yield* client("raw")
|
||||
const health = yield* call(() => sdk.global.health())
|
||||
const log = yield* call(() => sdk.app.log({ service: "httpapi-sdk-test", level: "info", message: "hello" }))
|
||||
|
||||
@@ -380,7 +368,7 @@ describe("HttpApi SDK", () => {
|
||||
|
||||
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
||||
Effect.gen(function* () {
|
||||
const sdk = client(serverPath)
|
||||
const sdk = yield* client(serverPath)
|
||||
const health = yield* capture(() => sdk.global.health())
|
||||
const log = yield* capture(() => sdk.app.log({ service: "sdk-parity", level: "info", message: "hello" }))
|
||||
const invalidAuth = yield* capture(() => sdk.auth.set({ providerID: "test" }))
|
||||
@@ -394,9 +382,11 @@ describe("HttpApi SDK", () => {
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global event stream", (serverPath) =>
|
||||
firstEvent((signal) => client(serverPath).global.event({ signal })).pipe(
|
||||
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
const sdk = yield* client(serverPath)
|
||||
const event = yield* firstEvent((signal) => sdk.global.event({ signal }))
|
||||
return { type: record(record(event).payload).type }
|
||||
}),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
|
||||
@@ -441,12 +431,13 @@ describe("HttpApi SDK", () => {
|
||||
withStandardProject(serverPath, ({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = "ses_206f84f18ffeZ6hhD7pFYAiW5T"
|
||||
const fetch = yield* serverFetch(serverPath)
|
||||
const thrown = yield* captureThrown(() =>
|
||||
validateSession({
|
||||
url: "http://localhost",
|
||||
directory,
|
||||
sessionID,
|
||||
fetch: serverFetch(serverPath),
|
||||
fetch,
|
||||
}),
|
||||
)
|
||||
expect(errorMessage(thrown)).toBe(`Session not found: ${sessionID}`)
|
||||
@@ -460,21 +451,18 @@ describe("HttpApi SDK", () => {
|
||||
{ serverPath: "raw", setup: writeStandardFiles },
|
||||
({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const missing = yield* capture(() =>
|
||||
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const bad = yield* capture(() =>
|
||||
client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "wrong") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const good = yield* capture(() =>
|
||||
client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "secret") },
|
||||
}).file.read({ path: "hello.txt" }),
|
||||
)
|
||||
const missingSdk = yield* client("raw", directory, { password: "secret" })
|
||||
const missing = yield* capture(() => missingSdk.file.read({ path: "hello.txt" }))
|
||||
const badSdk = yield* client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "wrong") },
|
||||
})
|
||||
const bad = yield* capture(() => badSdk.file.read({ path: "hello.txt" }))
|
||||
const goodSdk = yield* client("raw", directory, {
|
||||
password: "secret",
|
||||
headers: { authorization: authorization("opencode", "secret") },
|
||||
})
|
||||
const good = yield* capture(() => goodSdk.file.read({ path: "hello.txt" }))
|
||||
|
||||
return {
|
||||
statuses: statuses({ missing, bad, good }),
|
||||
@@ -640,7 +628,7 @@ describe("HttpApi SDK", () => {
|
||||
),
|
||||
)
|
||||
|
||||
// Regression: SyncEvent must publish on the same ProjectBus the /event handler
|
||||
// Regression: EventV2 must publish on the same ProjectBus the /event handler
|
||||
// subscribes to, AND the /event stream must forward handler ALS/context into the
|
||||
// body-pump fiber. Drives the full SDK → /event → Session.updatePart → sync.run →
|
||||
// bus.publish → SDK subscriber path. Goes red if either the publisher uses a
|
||||
|
||||
@@ -1,27 +1,31 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { NodeHttpServer, NodeServices } from "@effect/platform-node"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { Cause, Config, Effect, Exit, Layer } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse, HttpRouter, HttpServer } from "effect/unstable/http"
|
||||
import { layerWebSocketConstructorGlobal } from "effect/unstable/socket/Socket"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import * as HttpSessionError from "../../src/server/routes/instance/httpapi/handlers/session-errors"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID, SessionID, type SessionID as SessionIDType } from "../../src/session/schema"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionMessageTable, SessionTable } from "@/session/session.sql"
|
||||
import { SessionMessage } from "@opencode-ai/core/session-message"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { ModelV2 } from "@opencode-ai/core/model"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import * as DateTime from "effect/DateTime"
|
||||
@@ -45,11 +49,28 @@ const instanceStoreLayer = InstanceStore.defaultLayer.pipe(
|
||||
Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })),
|
||||
),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
const servedRoutes: Layer.Layer<never, Config.ConfigError, HttpServer.HttpServer> = HttpRouter.serve(
|
||||
HttpApiApp.routes,
|
||||
{
|
||||
disableListenLog: true,
|
||||
disableLogger: true,
|
||||
},
|
||||
)
|
||||
const httpApiLayer = servedRoutes.pipe(
|
||||
Layer.provide(layerWebSocketConstructorGlobal),
|
||||
Layer.provideMerge(NodeHttpServer.layerTest),
|
||||
Layer.provideMerge(NodeServices.layer),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
instanceStoreLayer,
|
||||
Project.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
workspaceLayer,
|
||||
Database.defaultLayer,
|
||||
httpApiLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function pathFor(path: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
||||
@@ -67,7 +88,7 @@ function createTextMessage(sessionID: SessionIDType, text: string) {
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const part = yield* svc.updatePart({
|
||||
@@ -109,7 +130,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
|
||||
)
|
||||
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
||||
Effect.sync(() => {
|
||||
Effect.gen(function* () {
|
||||
const message = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "assistant",
|
||||
@@ -122,90 +143,93 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
|
||||
time: { created: DateTime.makeUnsafe(time) },
|
||||
content: [],
|
||||
})
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type: message.type,
|
||||
time_created: time,
|
||||
data: {
|
||||
time: { created: time },
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
content: message.content,
|
||||
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
])
|
||||
.run(),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type: message.type,
|
||||
time_created: time,
|
||||
data: {
|
||||
time: { created: time },
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
content: message.content,
|
||||
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const insertCorruptV2Message = (sessionID: SessionIDType, time = 1) =>
|
||||
Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
session_id: sessionID,
|
||||
type: "assistant",
|
||||
time_created: time,
|
||||
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
])
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: SessionMessage.ID.create(),
|
||||
session_id: sessionID,
|
||||
type: "assistant",
|
||||
time_created: time,
|
||||
data: {} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const setLegacySummaryDiff = (sessionID: SessionIDType) =>
|
||||
Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
summary_additions: 1,
|
||||
summary_deletions: 0,
|
||||
summary_files: 1,
|
||||
summary_diffs: [{ additions: 1, deletions: 0 }],
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
summary_additions: 1,
|
||||
summary_deletions: 0,
|
||||
summary_files: 1,
|
||||
summary_diffs: [{ additions: 1, deletions: 0 }],
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const getWorkspaceID = (sessionID: SessionIDType) =>
|
||||
Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
return yield* db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const clearSessionPath = (sessionID: SessionIDType) =>
|
||||
Effect.sync(() =>
|
||||
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run()),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run().pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
function request(path: string, init?: RequestInit) {
|
||||
return Effect.promise(async () => app().request(path, init))
|
||||
const url = new URL(path, "http://localhost")
|
||||
return HttpClientRequest.fromWeb(new Request(url, init)).pipe(
|
||||
HttpClientRequest.setUrl(url.pathname),
|
||||
HttpClient.execute,
|
||||
)
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(async () => {
|
||||
if (response.status !== 200) throw new Error(await response.text())
|
||||
return (await response.json()) as T
|
||||
})
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
if (response.status !== 200) return response.text.pipe(Effect.flatMap((text) => Effect.die(new Error(text))))
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
function responseJson(response: Response) {
|
||||
return Effect.promise(() => response.json())
|
||||
function responseJson(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json
|
||||
}
|
||||
|
||||
function requestJson<T>(path: string, init?: RequestInit) {
|
||||
@@ -338,8 +362,8 @@ describe("session HttpApi", () => {
|
||||
const messages = yield* request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, {
|
||||
headers,
|
||||
})
|
||||
const messagePage = yield* json<MessageV2.WithParts[]>(messages)
|
||||
const nextCursor = messages.headers.get("x-next-cursor")
|
||||
const messagePage = yield* json<SessionLegacy.WithParts[]>(messages)
|
||||
const nextCursor = messages.headers["x-next-cursor"]
|
||||
expect(nextCursor).toBeTruthy()
|
||||
expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
|
||||
|
||||
@@ -355,7 +379,7 @@ describe("session HttpApi", () => {
|
||||
).toBe(400)
|
||||
|
||||
expect(
|
||||
yield* requestJson<MessageV2.WithParts>(
|
||||
yield* requestJson<SessionLegacy.WithParts>(
|
||||
pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }),
|
||||
{ headers },
|
||||
),
|
||||
@@ -788,9 +812,9 @@ describe("session HttpApi", () => {
|
||||
|
||||
const response = yield* request(route, { headers })
|
||||
|
||||
expect(response.headers.get("x-next-cursor")).toBeTruthy()
|
||||
expect(response.headers.get("link")).toContain("limit=1")
|
||||
expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor")
|
||||
expect(response.headers["x-next-cursor"]).toBeTruthy()
|
||||
expect(response.headers["link"]).toContain("limit=1")
|
||||
expect(response.headers["access-control-expose-headers"]?.toLowerCase()).toContain("x-next-cursor")
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
@@ -805,7 +829,7 @@ describe("session HttpApi", () => {
|
||||
const first = yield* createTextMessage(session.id, "first")
|
||||
const second = yield* createTextMessage(session.id, "second")
|
||||
|
||||
const updated = yield* requestJson<MessageV2.Part>(
|
||||
const updated = yield* requestJson<SessionLegacy.Part>(
|
||||
pathFor(SessionPaths.updatePart, {
|
||||
sessionID: session.id,
|
||||
messageID: first.info.id,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { afterEach, describe, expect, mock, spyOn } from "bun:test"
|
||||
import { Context, Effect } from "effect"
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -9,16 +8,13 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
@@ -38,23 +34,17 @@ describe("sync HttpApi", () => {
|
||||
const info = spyOn(Log.create({ service: "server.sync" }), "info")
|
||||
const session = yield* Session.use.create({ title: "sync" })
|
||||
|
||||
const started = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
|
||||
)
|
||||
const started = yield* requestInDirectory(SyncPaths.start, tmp.directory, { method: "POST", headers })
|
||||
expect(started.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => started.json())).toBe(true)
|
||||
expect(yield* started.json).toBe(true)
|
||||
|
||||
const history = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const history = yield* requestInDirectory(SyncPaths.history, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (yield* Effect.promise(() => history.json())) as Array<{
|
||||
const rows = (yield* history.json) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
@@ -63,28 +53,24 @@ describe("sync HttpApi", () => {
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const replayed = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.replay, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.directory,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const replayed = yield* requestInDirectory(SyncPaths.replay, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.directory,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
})
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
|
||||
expect(yield* replayed.json).toEqual({ sessionID: session.id })
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
|
||||
}),
|
||||
@@ -123,15 +109,11 @@ describe("sync HttpApi", () => {
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(item.path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* requestInDirectory(item.path, tmp.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -16,10 +16,11 @@ import Http from "node:http"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { WorkspaceTable } from "@opencode-ai/core/control-plane/workspace.sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Session } from "../../src/session/session"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
@@ -30,7 +31,6 @@ import {
|
||||
workspaceRoutingLayer,
|
||||
} from "../../src/server/routes/instance/httpapi/middleware/workspace-routing"
|
||||
import { HEADER as FenceHeader } from "../../src/server/shared/fence"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { workspaceLayerWithRuntimeFlags } from "../fixture/workspace"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
@@ -54,6 +54,7 @@ const it = testEffect(
|
||||
testStateLayer,
|
||||
NodeHttpServer.layerTest,
|
||||
NodeServices.layer,
|
||||
Database.defaultLayer,
|
||||
Project.defaultLayer,
|
||||
workspaceLayer,
|
||||
Socket.layerWebSocketConstructorGlobal,
|
||||
@@ -165,10 +166,11 @@ const insertRemoteWorkspaceWithoutSync = (input: {
|
||||
type: string
|
||||
url: string
|
||||
}) =>
|
||||
Effect.sync(() => {
|
||||
const id = WorkspaceID.ascending()
|
||||
Effect.gen(function* () {
|
||||
const id = WorkspaceV2.ID.ascending()
|
||||
registerAdapter(input.projectID, input.type, remoteAdapter(path.join(input.dir, `.${input.type}`), input.url))
|
||||
Database.use((db) => db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run())
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.insert(WorkspaceTable).values({ id, type: input.type, project_id: input.projectID }).run().pipe(Effect.orDie)
|
||||
return id
|
||||
})
|
||||
|
||||
@@ -327,9 +329,9 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const project = yield* Project.use.fromDirectory(dir)
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
const workspaceID = WorkspaceV2.ID.ascending()
|
||||
const type = "remote-http-fence-target"
|
||||
const waited = yield* Ref.make<{ workspaceID: WorkspaceID; state: Record<string, number> } | undefined>(undefined)
|
||||
const waited = yield* Ref.make<{ workspaceID: WorkspaceV2.ID; state: Record<string, number> } | undefined>(undefined)
|
||||
|
||||
const remoteUrl = yield* startRemoteWorkspaceHttpServer(() =>
|
||||
HttpServerResponse.json(
|
||||
@@ -438,7 +440,7 @@ describe("HttpApi workspace routing middleware", () => {
|
||||
|
||||
it.live("returns a missing workspace response for unknown workspace ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const workspaceID = WorkspaceID.ascending("wrk_missing")
|
||||
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing")
|
||||
// If the middleware resolves the workspace first, this handler is never
|
||||
// reached and the response should be the middleware error response.
|
||||
yield* serveProbe
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { registerAdapter } from "../../src/control-plane/adapters"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { EventPaths } from "../../src/server/routes/instance/httpapi/groups/event"
|
||||
import { Session } from "@/session/session"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -19,8 +19,8 @@ import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/groups/instance"
|
||||
import { WorkspaceRef } from "../../src/effect/instance-ref"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -29,14 +29,29 @@ const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(NodeServices.layer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Project.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
workspaceLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(InstanceBootstrap.defaultLayer)),
|
||||
Database.defaultLayer,
|
||||
httpApiLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
return Effect.promise(() => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Promise.resolve(Server.Default().app.request(path, { ...init, headers }))
|
||||
})
|
||||
return requestInDirectory(path, directory, init)
|
||||
}
|
||||
|
||||
function requestDefault(path: string, directory: string, init: RequestInit = {}) {
|
||||
return requestInDirectory(path, directory, init)
|
||||
}
|
||||
|
||||
function requestServer(path: string, directory: string, init: RequestInit = {}) {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Effect.promise(() => Promise.resolve(Server.Default().app.request(path, { ...init, headers })))
|
||||
}
|
||||
|
||||
function localAdapter(directory: string): WorkspaceAdapter {
|
||||
@@ -180,17 +195,17 @@ describe("workspace HttpApi", () => {
|
||||
])
|
||||
|
||||
expect(adapters.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => adapters.json())).toContainEqual({
|
||||
expect(yield* adapters.json).toContainEqual({
|
||||
type: "worktree",
|
||||
name: "Worktree",
|
||||
description: "Create a git worktree",
|
||||
})
|
||||
|
||||
expect(workspaces.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => workspaces.json())).toEqual([])
|
||||
expect(yield* workspaces.json).toEqual([])
|
||||
|
||||
expect(status.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => status.json())).toEqual([])
|
||||
expect(yield* status.json).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -207,7 +222,7 @@ describe("workspace HttpApi", () => {
|
||||
body: JSON.stringify({ type: "local-test", branch: null }),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
expect(workspace).toMatchObject({ type: "local-test", name: "local-test" })
|
||||
|
||||
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
|
||||
@@ -220,11 +235,11 @@ describe("workspace HttpApi", () => {
|
||||
|
||||
const removed = yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => removed.json())).toMatchObject({ id: workspace.id })
|
||||
expect(yield* removed.json).toMatchObject({ id: workspace.id })
|
||||
|
||||
const listed = yield* request(WorkspacePaths.list, dir)
|
||||
expect(listed.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => listed.json())).toEqual([])
|
||||
expect(yield* listed.json).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -240,7 +255,7 @@ describe("workspace HttpApi", () => {
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
const listed = yield* request(WorkspacePaths.list, dir)
|
||||
expect(yield* Effect.promise(() => listed.json())).toMatchObject([
|
||||
expect(yield* listed.json).toMatchObject([
|
||||
{
|
||||
type,
|
||||
name: "listed-test",
|
||||
@@ -256,7 +271,7 @@ describe("workspace HttpApi", () => {
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const session = yield* Session.use.create({}).pipe(provideInstance(dir))
|
||||
const workspaceID = WorkspaceID.ascending("wrk_missing_warp")
|
||||
const workspaceID = WorkspaceV2.ID.ascending("wrk_missing_warp")
|
||||
|
||||
const response = yield* request(WorkspacePaths.warp, dir, {
|
||||
method: "POST",
|
||||
@@ -265,7 +280,7 @@ describe("workspace HttpApi", () => {
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => response.json())).toEqual({
|
||||
expect(yield* response.json).toEqual({
|
||||
name: "NotFoundError",
|
||||
data: { message: `Workspace not found: ${workspaceID}` },
|
||||
})
|
||||
@@ -286,7 +301,7 @@ describe("workspace HttpApi", () => {
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
expect((yield* Effect.promise(() => created.json())) as Workspace.Info).toMatchObject({
|
||||
expect((yield* created.json) as Workspace.Info).toMatchObject({
|
||||
type: "local-test",
|
||||
name: "local-test",
|
||||
})
|
||||
@@ -298,7 +313,7 @@ describe("workspace HttpApi", () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
const created = yield* requestServer(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "worktree", branch: null }),
|
||||
@@ -323,7 +338,7 @@ describe("workspace HttpApi", () => {
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "local-target", branch: null }),
|
||||
})
|
||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
|
||||
const url = new URL(`http://localhost${InstancePaths.path}`)
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
@@ -331,7 +346,7 @@ describe("workspace HttpApi", () => {
|
||||
const response = yield* request(url.toString(), dir)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toMatchObject({ directory: workspaceDir })
|
||||
expect(yield* response.json).toMatchObject({ directory: workspaceDir })
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
}),
|
||||
)
|
||||
@@ -374,19 +389,19 @@ describe("workspace HttpApi", () => {
|
||||
"x-target-auth": "secret",
|
||||
}),
|
||||
)
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
const created = yield* requestDefault(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "remote-target", branch: null }),
|
||||
})
|
||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
|
||||
const url = new URL("http://localhost/config")
|
||||
url.searchParams.set("workspace", workspace.id)
|
||||
url.searchParams.set("keep", "yes")
|
||||
|
||||
try {
|
||||
const response = yield* request(url.toString(), dir, {
|
||||
const response = yield* requestDefault(url.toString(), dir, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"accept-encoding": "br",
|
||||
@@ -396,10 +411,10 @@ describe("workspace HttpApi", () => {
|
||||
body: JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
|
||||
})
|
||||
|
||||
const responseBody = yield* Effect.promise(() => response.text())
|
||||
const responseBody = yield* response.text
|
||||
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 201 })
|
||||
expect(response.headers.get("content-length")).toBeNull()
|
||||
expect(response.headers.get("x-remote")).toBe("yes")
|
||||
expect(response.headers["content-length"]).toBeUndefined()
|
||||
expect(response.headers["x-remote"]).toBe("yes")
|
||||
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: "/base/config", keep: "yes", workspace: null })
|
||||
const forwarded = proxied.filter((item) => new URL(item.url).pathname === "/base/config")
|
||||
expect(forwarded).toEqual([
|
||||
@@ -420,16 +435,13 @@ describe("workspace HttpApi", () => {
|
||||
eventURL.searchParams.set("workspace", workspace.id)
|
||||
const eventResponse = yield* request(eventURL.toString(), dir)
|
||||
expect(eventResponse.status).toBe(200)
|
||||
expect(eventResponse.headers.get("content-type")).toContain("text/event-stream")
|
||||
if (!eventResponse.body) throw new Error("missing proxied event response body")
|
||||
const eventReader = eventResponse.body.getReader()
|
||||
const event = yield* Effect.promise(() => eventReader.read())
|
||||
yield* Effect.promise(() => eventReader.cancel())
|
||||
expect(new TextDecoder().decode(event.value)).toContain("server.connected")
|
||||
expect(eventResponse.headers["content-type"]).toContain("text/event-stream")
|
||||
const event = Array.from(yield* eventResponse.stream.pipe(Stream.take(1), Stream.runCollect))[0]
|
||||
expect(new TextDecoder().decode(event)).toContain("server.connected")
|
||||
expect(proxied.some((item) => new URL(item.url).pathname === "/base/event")).toBe(true)
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -453,24 +465,29 @@ describe("workspace HttpApi", () => {
|
||||
"remote-session-target",
|
||||
remoteAdapter(path.join(dir, ".remote-session"), `http://127.0.0.1:${remote.port}/base`),
|
||||
)
|
||||
const created = yield* request(WorkspacePaths.list, dir, {
|
||||
const created = yield* requestDefault(WorkspacePaths.list, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ type: "remote-session-target", branch: null }),
|
||||
})
|
||||
const workspace = (yield* Effect.promise(() => created.json())) as Workspace.Info
|
||||
const session = yield* Session.use
|
||||
.create()
|
||||
.pipe(Effect.provideService(WorkspaceRef, workspace.id), provideInstance(dir))
|
||||
const workspace = (yield* created.json) as Workspace.Info
|
||||
const sessionResponse = yield* requestDefault("/session", dir, { method: "POST" })
|
||||
const session = (yield* sessionResponse.json) as Session.Info
|
||||
const warped = yield* requestDefault(WorkspacePaths.warp, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ id: workspace.id, sessionID: session.id }),
|
||||
})
|
||||
expect(warped.status).toBe(204)
|
||||
|
||||
try {
|
||||
const response = yield* request(`http://localhost/session/${session.id}/message`, dir, {
|
||||
const response = yield* requestDefault(`http://localhost/session/${session.id}/message`, dir, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ parts: [{ type: "text", text: "hello" }] }),
|
||||
})
|
||||
|
||||
const responseBody = yield* Effect.promise(() => response.text())
|
||||
const responseBody = yield* response.text
|
||||
expect({ status: response.status, body: responseBody }).toMatchObject({ status: 200 })
|
||||
expect(JSON.parse(responseBody)).toEqual({ proxied: true, path: `/base/session/${session.id}/message` })
|
||||
expect(proxied.filter((item) => new URL(item.url).pathname === `/base/session/${session.id}/message`)).toEqual([
|
||||
@@ -491,7 +508,7 @@ describe("workspace HttpApi", () => {
|
||||
])
|
||||
} finally {
|
||||
void remote.stop(true)
|
||||
yield* request(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
yield* requestDefault(WorkspacePaths.remove.replace(":id", workspace.id), dir, { method: "DELETE" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,20 +6,21 @@
|
||||
// strict `NonNegativeInt` schema then made every load of the message list
|
||||
// fail to encode, killing Desktop boot for every user with such a row.
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Server } from "../../src/server/server"
|
||||
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import * as Database from "@/storage/db"
|
||||
import { PartTable } from "@/session/session.sql"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { PartTable } from "@opencode-ai/core/session/sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Database.defaultLayer, httpApiLayer))
|
||||
|
||||
function seedNegativeTokenSession() {
|
||||
return Effect.gen(function* () {
|
||||
@@ -30,7 +31,7 @@ function seedNegativeTokenSession() {
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
model: { providerID: ProviderV2.ID.make("test"), modelID: ProviderV2.ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
@@ -46,20 +47,20 @@ function seedNegativeTokenSession() {
|
||||
|
||||
// Bypass the schema with a direct SQL update to install the
|
||||
// negative `output` value we want to test loading.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
yield* db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
return info.id
|
||||
})
|
||||
@@ -73,7 +74,7 @@ describe("messages endpoint tolerates legacy negative token counts", () => {
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedNegativeTokenSession()
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const res = yield* requestInDirectory(url, test.directory)
|
||||
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import path from "path"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Server } from "../../src/server/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -23,18 +24,16 @@ afterEach(async () => {
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const testInstanceStore = InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap))
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(AppFileSystem.defaultLayer, Snapshot.defaultLayer, testInstanceStore, httpApiLayer),
|
||||
)
|
||||
|
||||
function request(directory: string, url: string, init: RequestInit = {}) {
|
||||
return Effect.promise(() => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Promise.resolve(Server.Default().app.request(url, { ...init, headers }))
|
||||
})
|
||||
return requestInDirectory(url, directory, init)
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(() => response.json() as Promise<T>)
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((value) => value as T))
|
||||
}
|
||||
|
||||
function collectGlobalEvents() {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(SessionNs.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
@@ -21,73 +21,52 @@ describe("session action routes", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const app = Server.Default().app
|
||||
const headers = { "Content-Type": "application/json", "x-opencode-directory": test.directory }
|
||||
const headers = { "Content-Type": "application/json" }
|
||||
|
||||
const created = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request("/session", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: "meta-session",
|
||||
metadata: { source: "sdk", trace: { id: "abc" } },
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const created = yield* requestInDirectory("/session", test.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: "meta-session",
|
||||
metadata: { source: "sdk", trace: { id: "abc" } },
|
||||
}),
|
||||
})
|
||||
expect(created.status).toBe(200)
|
||||
|
||||
const session = (yield* Effect.promise(() => created.json())) as SessionNs.Info
|
||||
const session = (yield* created.json) as SessionNs.Info
|
||||
expect(session.metadata).toEqual({ source: "sdk", trace: { id: "abc" } })
|
||||
|
||||
const updated = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request(`/session/${session.id}`, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const updated = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: { source: "sdk", trace: { id: "def" }, tags: ["one"] } }),
|
||||
})
|
||||
expect(updated.status).toBe(200)
|
||||
|
||||
const next = (yield* Effect.promise(() => updated.json())) as SessionNs.Info
|
||||
const next = (yield* updated.json) as SessionNs.Info
|
||||
expect(next.metadata).toEqual({ source: "sdk", trace: { id: "def" }, tags: ["one"] })
|
||||
|
||||
const fetched = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request(`/session/${session.id}`, { headers: { "x-opencode-directory": test.directory } }),
|
||||
),
|
||||
)
|
||||
const fetched = yield* requestInDirectory(`/session/${session.id}`, test.directory)
|
||||
expect(fetched.status).toBe(200)
|
||||
expect(((yield* Effect.promise(() => fetched.json())) as SessionNs.Info).metadata).toEqual(next.metadata)
|
||||
expect(((yield* fetched.json) as SessionNs.Info).metadata).toEqual(next.metadata)
|
||||
|
||||
const forked = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request(`/session/${session.id}/fork`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const forked = yield* requestInDirectory(`/session/${session.id}/fork`, test.directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(forked.status).toBe(200)
|
||||
|
||||
const fork = (yield* Effect.promise(() => forked.json())) as SessionNs.Info
|
||||
const fork = (yield* forked.json) as SessionNs.Info
|
||||
expect(fork.metadata).toEqual(next.metadata)
|
||||
|
||||
const reset = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request(`/session/${session.id}`, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: {} }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const reset = yield* requestInDirectory(`/session/${session.id}`, test.directory, {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({ metadata: {} }),
|
||||
})
|
||||
expect(reset.status).toBe(200)
|
||||
expect(((yield* Effect.promise(() => reset.json())) as SessionNs.Info).metadata).toEqual({})
|
||||
expect(((yield* reset.json) as SessionNs.Info).metadata).toEqual({})
|
||||
|
||||
yield* SessionNs.Service.use((svc) => svc.remove(fork.id).pipe(Effect.ignore))
|
||||
yield* SessionNs.Service.use((svc) => svc.remove(session.id).pipe(Effect.ignore))
|
||||
@@ -104,17 +83,10 @@ describe("session action routes", () => {
|
||||
SessionNs.use.remove(created.id).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const res = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
Server.Default().app.request(`/session/${session.id}/abort`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
),
|
||||
)
|
||||
const res = yield* requestInDirectory(`/session/${session.id}/abort`, test.directory, { method: "POST" })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => res.json())).toBe(true)
|
||||
expect(yield* res.json).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Server } from "@/server/server"
|
||||
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { Storage } from "@/storage/storage"
|
||||
@@ -19,10 +18,11 @@ import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer, httpApiLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -51,16 +51,13 @@ describe("session diff with missing patch (#26574)", () => {
|
||||
storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
|
||||
)
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), {
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
),
|
||||
const response = yield* requestInDirectory(
|
||||
pathFor(SessionPaths.diff, { sessionID: session.id }),
|
||||
test.directory,
|
||||
)
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = (yield* Effect.promise(() => response.json())) as Array<{
|
||||
const body = (yield* response.json) as Array<{
|
||||
file: string
|
||||
patch?: string
|
||||
additions: number
|
||||
|
||||
@@ -1,28 +1,33 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
|
||||
import { mkdir } from "fs/promises"
|
||||
import path from "path"
|
||||
import { Database } from "@/storage/db"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Bus } from "@/bus"
|
||||
import { EventV2Bridge } from "@/event-v2-bridge"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
|
||||
void Log.init({ print: false })
|
||||
const it = testEffect(
|
||||
SessionNs.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(SyncEvent.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
Layer.mergeAll(
|
||||
Database.defaultLayer,
|
||||
SessionNs.layer.pipe(
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(EventV2Bridge.defaultLayer),
|
||||
Layer.provide(SessionProjector.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ experimentalWorkspaces: false })),
|
||||
Layer.provide(BackgroundJob.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -148,16 +153,9 @@ describe("session.list", () => {
|
||||
provideInstance(path.join(test.directory, "packages", "app")),
|
||||
)
|
||||
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run(),
|
||||
),
|
||||
)
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run(),
|
||||
),
|
||||
)
|
||||
const { db } = yield* Database.Service
|
||||
yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, current.id)).run().pipe(Effect.orDie)
|
||||
yield* db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sibling.id)).run().pipe(Effect.orDie)
|
||||
|
||||
const pathIDs = (yield* SessionNs.Service.use((session) =>
|
||||
session.list({
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionLegacy } from "@opencode-ai/core/session/legacy"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientResponse } from "effect/unstable/http"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { ProviderV2 } from "@opencode-ai/core/provider"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(SessionNs.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(SessionNs.defaultLayer, httpApiLayer))
|
||||
|
||||
const model = {
|
||||
providerID: ProviderID.make("test"),
|
||||
modelID: ModelID.make("test"),
|
||||
providerID: ProviderV2.ID.make("test"),
|
||||
modelID: ProviderV2.ModelID.make("test"),
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -62,25 +65,25 @@ const fill = Effect.fn("SessionMessagesTest.fill")(function* (
|
||||
agent: "test",
|
||||
model,
|
||||
tools: {},
|
||||
} satisfies MessageV2.User)
|
||||
} satisfies SessionLegacy.User)
|
||||
yield* session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: id,
|
||||
type: "text",
|
||||
text: `m${i}`,
|
||||
} satisfies MessageV2.TextPart)
|
||||
} satisfies SessionLegacy.TextPart)
|
||||
return id
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
function request(path: string) {
|
||||
return Effect.promise(() => Promise.resolve(Server.Default().app.request(path)))
|
||||
return TestInstance.pipe(Effect.flatMap((test) => requestInDirectory(path, test.directory)))
|
||||
}
|
||||
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(() => response.json() as Promise<T>)
|
||||
function json<T>(response: HttpClientResponse.HttpClientResponse) {
|
||||
return response.json.pipe(Effect.map((body) => body as T))
|
||||
}
|
||||
|
||||
describe("session messages endpoint", () => {
|
||||
@@ -93,15 +96,15 @@ describe("session messages endpoint", () => {
|
||||
|
||||
const a = yield* request(`/session/${session.id}/message?limit=2`)
|
||||
expect(a.status).toBe(200)
|
||||
const aBody = yield* json<MessageV2.WithParts[]>(a)
|
||||
const aBody = yield* json<SessionLegacy.WithParts[]>(a)
|
||||
expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
|
||||
const cursor = a.headers.get("x-next-cursor")
|
||||
const cursor = a.headers["x-next-cursor"]
|
||||
expect(cursor).toBeTruthy()
|
||||
expect(a.headers.get("link")).toContain('rel="next"')
|
||||
expect(a.headers["link"]).toContain('rel="next"')
|
||||
|
||||
const b = yield* request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
|
||||
expect(b.status).toBe(200)
|
||||
const bBody = yield* json<MessageV2.WithParts[]>(b)
|
||||
const bBody = yield* json<SessionLegacy.WithParts[]>(b)
|
||||
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
|
||||
}),
|
||||
),
|
||||
@@ -117,7 +120,7 @@ describe("session messages endpoint", () => {
|
||||
|
||||
const res = yield* request(`/session/${session.id}/message`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = yield* json<MessageV2.WithParts[]>(res)
|
||||
const body = yield* json<SessionLegacy.WithParts[]>(res)
|
||||
expect(body.map((item) => item.info.id)).toEqual(ids)
|
||||
}),
|
||||
),
|
||||
@@ -149,7 +152,7 @@ describe("session messages endpoint", () => {
|
||||
|
||||
const res = yield* request(`/session/${session.id}/message?limit=510`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = yield* json<MessageV2.WithParts[]>(res)
|
||||
const body = yield* json<SessionLegacy.WithParts[]>(res)
|
||||
expect(body).toHaveLength(510)
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { httpApiLayer, requestInDirectory } from "./httpapi-layer"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, httpApiLayer))
|
||||
|
||||
describe("tui.selectSession endpoint", () => {
|
||||
it.instance(
|
||||
@@ -18,22 +18,14 @@ describe("tui.selectSession endpoint", () => {
|
||||
const tmp = yield* TestInstance
|
||||
const session = yield* Session.use.create({})
|
||||
|
||||
const app = Server.Default().app
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": tmp.directory,
|
||||
},
|
||||
body: JSON.stringify({ sessionID: session.id }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: session.id }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
const body = yield* Effect.promise(() => response.json())
|
||||
const body = yield* response.json
|
||||
expect(body).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
@@ -46,19 +38,11 @@ describe("tui.selectSession endpoint", () => {
|
||||
const tmp = yield* TestInstance
|
||||
const nonExistentSessionID = "ses_nonexistent123"
|
||||
|
||||
const app = Server.Default().app
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": tmp.directory,
|
||||
},
|
||||
body: JSON.stringify({ sessionID: nonExistentSessionID }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: nonExistentSessionID }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
}),
|
||||
@@ -72,19 +56,11 @@ describe("tui.selectSession endpoint", () => {
|
||||
const tmp = yield* TestInstance
|
||||
const invalidSessionID = "invalid_session_id"
|
||||
|
||||
const app = Server.Default().app
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": tmp.directory,
|
||||
},
|
||||
body: JSON.stringify({ sessionID: invalidSessionID }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* requestInDirectory("/tui/select-session", tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: invalidSessionID }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
}),
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Queue } from "effect"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { WorkspacePaths } from "../../src/server/routes/instance/httpapi/groups/workspace"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
@@ -30,19 +29,16 @@ const stateLayer = Layer.effectDiscard(
|
||||
|
||||
const it = testEffect(stateLayer)
|
||||
const worktreeTest = process.platform === "win32" ? it.instance.skip : it.instance
|
||||
type TestServer = ReturnType<typeof HttpRouter.toWebHandler>
|
||||
type TestServer = ReturnType<typeof Server.Default>["app"]
|
||||
type CreatedWorktree = { directory: string }
|
||||
type ScopedWorktree = { directory: string; body: CreatedWorktree; ready: Effect.Effect<void, Error> }
|
||||
|
||||
function serverScoped() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => HttpRouter.toWebHandler(HttpApiApp.routes, { disableLogger: true })),
|
||||
(server) => Effect.promise(() => server.dispose()).pipe(Effect.ignore),
|
||||
)
|
||||
return Effect.sync(() => Server.Default().app)
|
||||
}
|
||||
|
||||
function request(server: TestServer, input: string, init?: RequestInit) {
|
||||
return Effect.promise(() => server.handler(new Request(new URL(input, "http://localhost"), init), HttpApiApp.context))
|
||||
return Effect.promise(() => Promise.resolve(server.request(input, init)))
|
||||
}
|
||||
|
||||
function withRequestTimeout(effect: Effect.Effect<Response>, label: string, ms = 5_000) {
|
||||
|
||||
Reference in New Issue
Block a user