Merge branch 'dev' into nxl/improve-compaction-strategy
This commit is contained in:
@@ -18,14 +18,14 @@ const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||
|
||||
it.live("list returns empty when no accounts exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||
expect(accounts).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("active returns none when no accounts exist", () =>
|
||||
Effect.gen(function* () {
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.isNone(active)).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -33,7 +33,7 @@ it.live("active returns none when no accounts exist", () =>
|
||||
it.live("persistAccount inserts and getRow retrieves", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -45,13 +45,13 @@ it.live("persistAccount inserts and getRow retrieves", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
expect(Option.isSome(row)).toBe(true)
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.id).toBe(AccountID.make("user-1"))
|
||||
expect(value.email).toBe("test@example.com")
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-1"))
|
||||
}),
|
||||
)
|
||||
@@ -60,7 +60,7 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -72,9 +72,9 @@ it.live("persistAccount normalizes trailing slashes in stored server URLs", () =
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const list = yield* AccountRepo.use((r) => r.list())
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
const list = yield* AccountRepo.Service.use((r) => r.list())
|
||||
|
||||
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
|
||||
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
|
||||
@@ -87,7 +87,7 @@ it.live("persistAccount sets the active account and org", () =>
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "first@example.com",
|
||||
@@ -99,7 +99,7 @@ it.live("persistAccount sets the active account and org", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "second@example.com",
|
||||
@@ -112,7 +112,7 @@ it.live("persistAccount sets the active account and org", () =>
|
||||
)
|
||||
|
||||
// Last persisted account is active with its org
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.isSome(active)).toBe(true)
|
||||
expect(Option.getOrThrow(active).id).toBe(AccountID.make("user-2"))
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||
@@ -124,7 +124,7 @@ it.live("list returns all accounts", () =>
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "a@example.com",
|
||||
@@ -136,7 +136,7 @@ it.live("list returns all accounts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "b@example.com",
|
||||
@@ -148,7 +148,7 @@ it.live("list returns all accounts", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||
expect(accounts.length).toBe(2)
|
||||
expect(accounts.map((a) => a.email).sort()).toEqual(["a@example.com", "b@example.com"])
|
||||
}),
|
||||
@@ -158,7 +158,7 @@ it.live("remove deletes an account", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -170,9 +170,9 @@ it.live("remove deletes an account", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.remove(id))
|
||||
yield* AccountRepo.Service.use((r) => r.remove(id))
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
expect(Option.isNone(row)).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -182,7 +182,7 @@ it.live("use stores the selected org and marks the account active", () =>
|
||||
const id1 = AccountID.make("user-1")
|
||||
const id2 = AccountID.make("user-2")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id1,
|
||||
email: "first@example.com",
|
||||
@@ -194,7 +194,7 @@ it.live("use stores the selected org and marks the account active", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: id2,
|
||||
email: "second@example.com",
|
||||
@@ -206,13 +206,13 @@ it.live("use stores the selected org and marks the account active", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
|
||||
const active1 = yield* AccountRepo.use((r) => r.active())
|
||||
yield* AccountRepo.Service.use((r) => r.use(id1, Option.some(OrgID.make("org-99"))))
|
||||
const active1 = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active1).id).toBe(id1)
|
||||
expect(Option.getOrThrow(active1).active_org_id).toBe(OrgID.make("org-99"))
|
||||
|
||||
yield* AccountRepo.use((r) => r.use(id1, Option.none()))
|
||||
const active2 = yield* AccountRepo.use((r) => r.active())
|
||||
yield* AccountRepo.Service.use((r) => r.use(id1, Option.none()))
|
||||
const active2 = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active2).active_org_id).toBeNull()
|
||||
}),
|
||||
)
|
||||
@@ -221,7 +221,7 @@ it.live("persistToken updates token fields", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -234,7 +234,7 @@ it.live("persistToken updates token fields", () =>
|
||||
)
|
||||
|
||||
const expiry = Date.now() + 7200_000
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistToken({
|
||||
accountID: id,
|
||||
accessToken: AccessToken.make("new_token"),
|
||||
@@ -243,7 +243,7 @@ it.live("persistToken updates token fields", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("new_token"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("new_refresh"))
|
||||
@@ -255,7 +255,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -267,7 +267,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistToken({
|
||||
accountID: id,
|
||||
accessToken: AccessToken.make("new_token"),
|
||||
@@ -276,7 +276,7 @@ it.live("persistToken with no expiry sets token_expiry to null", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
expect(Option.getOrThrow(row).token_expiry).toBeNull()
|
||||
}),
|
||||
)
|
||||
@@ -285,7 +285,7 @@ it.live("persistAccount upserts on conflict", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -297,7 +297,7 @@ it.live("persistAccount upserts on conflict", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -309,14 +309,14 @@ it.live("persistAccount upserts on conflict", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||
const accounts = yield* AccountRepo.Service.use((r) => r.list())
|
||||
expect(accounts.length).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_v2"))
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active).active_org_id).toBe(OrgID.make("org-2"))
|
||||
}),
|
||||
)
|
||||
@@ -325,7 +325,7 @@ it.live("remove clears active state when deleting the active account", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "test@example.com",
|
||||
@@ -337,16 +337,16 @@ it.live("remove clears active state when deleting the active account", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) => r.remove(id))
|
||||
yield* AccountRepo.Service.use((r) => r.remove(id))
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.isNone(active)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("getRow returns none for nonexistent account", () =>
|
||||
Effect.gen(function* () {
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(AccountID.make("nope")))
|
||||
expect(Option.isNone(row)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Duration, Effect, Layer, Option, Schema } from "effect"
|
||||
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import { Account } from "../../src/account"
|
||||
import { Account } from "../../src/account/account"
|
||||
import {
|
||||
AccessToken,
|
||||
AccountID,
|
||||
@@ -122,7 +122,7 @@ it.live("login maps transport failures to account transport errors", () =>
|
||||
|
||||
it.live("orgsByAccount groups orgs per account", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: AccountID.make("user-1"),
|
||||
email: "one@example.com",
|
||||
@@ -134,7 +134,7 @@ it.live("orgsByAccount groups orgs per account", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id: AccountID.make("user-2"),
|
||||
email: "two@example.com",
|
||||
@@ -177,7 +177,7 @@ it.live("token refresh persists the new token", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -206,7 +206,7 @@ it.live("token refresh persists the new token", () =>
|
||||
expect(Option.getOrThrow(token)).toBeDefined()
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
@@ -218,7 +218,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -251,7 +251,7 @@ it.live("token refreshes before expiry when inside the eager refresh window", ()
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
expect(refreshCalls).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
@@ -262,7 +262,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -315,7 +315,7 @@ it.live("concurrent config and token requests coalesce token refresh", () =>
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
expect(refreshCalls).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const row = yield* AccountRepo.Service.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
@@ -326,7 +326,7 @@ it.live("config sends the selected org header", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
yield* AccountRepo.Service.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
@@ -388,7 +388,7 @@ it.live("poll stores the account and first org on success", () =>
|
||||
expect(res.email).toBe("user@example.com")
|
||||
}
|
||||
|
||||
const active = yield* AccountRepo.use((r) => r.active())
|
||||
const active = yield* AccountRepo.Service.use((r) => r.active())
|
||||
expect(Option.getOrThrow(active)).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "user-1",
|
||||
|
||||
@@ -331,7 +331,7 @@ export default {
|
||||
const localOpts = {
|
||||
fn_marker: tmp.extra.fnMarker,
|
||||
marker: tmp.extra.localMarker,
|
||||
source: tmp.extra.localDest.replace(".opencode/themes/", ""),
|
||||
source: path.join(tmp.path, tmp.extra.localThemeFile),
|
||||
dest: tmp.extra.localDest,
|
||||
theme_path: `./${tmp.extra.localThemeFile}`,
|
||||
theme_name: tmp.extra.localThemeName,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { getRevertDiffFiles } from "../../../src/cli/cmd/tui/util/revert-diff"
|
||||
|
||||
describe("revert diff", () => {
|
||||
test("prefers the actual file path over /dev/null for added and deleted files", () => {
|
||||
const files = getRevertDiffFiles(`diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
index 0000000..3b18e51
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1 @@
|
||||
+new content
|
||||
diff --git a/old.txt b/old.txt
|
||||
deleted file mode 100644
|
||||
index 3b18e51..0000000
|
||||
--- a/old.txt
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-old content
|
||||
`)
|
||||
|
||||
expect(files).toEqual([
|
||||
{
|
||||
filename: "new.txt",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
},
|
||||
{
|
||||
filename: "old.txt",
|
||||
additions: 0,
|
||||
deletions: 1,
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -264,27 +264,15 @@ describe("SyncProvider", () => {
|
||||
|
||||
log.length = 0
|
||||
await sync.session.sync("ses_1")
|
||||
expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
|
||||
|
||||
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_a")).toHaveLength(1)
|
||||
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_a")
|
||||
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
|
||||
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_a" })
|
||||
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_a.ts")
|
||||
|
||||
log.length = 0
|
||||
project.workspace.set("ws_b")
|
||||
await waitBoot(log, "ws_b")
|
||||
expect(project.workspace.current()).toBe("ws_b")
|
||||
|
||||
log.length = 0
|
||||
await sync.session.sync("ses_1")
|
||||
await wait(() => log.some((item) => item.path === "/session/ses_1" && item.workspace === "ws_b"))
|
||||
|
||||
expect(log.filter((item) => item.path === "/session/ses_1" && item.workspace === "ws_b")).toHaveLength(1)
|
||||
expect(sync.data.todo.ses_1[0]?.content).toBe("todo-ws_b")
|
||||
expect(sync.data.message.ses_1[0]?.id).toBe("msg_1")
|
||||
expect(sync.data.part.msg_1[0]).toMatchObject({ type: "text", text: "part-ws_b" })
|
||||
expect(sync.data.session_diff.ses_1[0]?.file).toBe("ws_b.ts")
|
||||
expect(log.filter((item) => item.path === "/session/ses_1")).toHaveLength(1)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { test, expect, describe, mock, afterEach, beforeEach, spyOn } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer, Option } from "effect"
|
||||
import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "../../src/config"
|
||||
import { Config, ConfigManaged } from "../../src/config"
|
||||
import { ConfigParse } from "../../src/config/parse"
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir, tmpdirScoped } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
@@ -24,7 +26,6 @@ import { pathToFileURL } from "url"
|
||||
import { Global } from "../../src/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import * as Network from "../../src/util/network"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { Npm } from "@opencode-ai/shared/npm"
|
||||
|
||||
@@ -141,6 +142,42 @@ test("loads JSON config file", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("loads formatter boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
formatter: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.formatter).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads lsp boolean config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await writeConfig(dir, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
lsp: true,
|
||||
})
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(config.lsp).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads project config from Git Bash and MSYS2 paths on Windows", async () => {
|
||||
// Git Bash and MSYS2 both use /<drive>/... paths on Windows.
|
||||
await check((dir) => {
|
||||
@@ -757,7 +794,7 @@ test("updates config and writes to file", async () => {
|
||||
const newConfig = { model: "updated/model" }
|
||||
await save(newConfig as any)
|
||||
|
||||
const writtenConfig = await Filesystem.readJson(path.join(tmp.path, "config.json"))
|
||||
const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json"))
|
||||
expect(writtenConfig.model).toBe("updated/model")
|
||||
},
|
||||
})
|
||||
@@ -846,6 +883,9 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||
},
|
||||
})
|
||||
|
||||
// TODO: this is a hack to wait for backgruounded gitignore
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
|
||||
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
||||
} finally {
|
||||
@@ -1860,14 +1900,14 @@ describe("resolvePluginSpec", () => {
|
||||
})
|
||||
|
||||
const file = path.join(tmp.path, "opencode.json")
|
||||
const hit = await Config.resolvePluginSpec("./plugin", file)
|
||||
expect(Config.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
||||
const hit = await ConfigPlugin.resolvePluginSpec("./plugin", file)
|
||||
expect(ConfigPlugin.pluginSpecifier(hit)).toBe(pathToFileURL(path.join(tmp.path, "plugin", "index.ts")).href)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deduplicatePluginOrigins", () => {
|
||||
const dedupe = (plugins: Config.PluginSpec[]) =>
|
||||
Config.deduplicatePluginOrigins(
|
||||
const dedupe = (plugins: ConfigPlugin.Spec[]) =>
|
||||
ConfigPlugin.deduplicatePluginOrigins(
|
||||
plugins.map((spec) => ({
|
||||
spec,
|
||||
source: "",
|
||||
@@ -1937,8 +1977,8 @@ describe("deduplicatePluginOrigins", () => {
|
||||
const config = await load()
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
expect(plugins.some((p) => Config.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
|
||||
expect(plugins.some((p) => Config.pluginSpecifier(p).startsWith("file://"))).toBe(true)
|
||||
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true)
|
||||
expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -2209,17 +2249,23 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
||||
// parseManagedPlist unit tests — pure function, no OS interaction
|
||||
|
||||
test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||
const config = await Config.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
PayloadDisplayName: "OpenCode Managed",
|
||||
PayloadIdentifier: "ai.opencode.managed.test",
|
||||
PayloadType: "ai.opencode.managed",
|
||||
PayloadUUID: "AAAA-BBBB-CCCC",
|
||||
PayloadVersion: 1,
|
||||
_manualProfile: true,
|
||||
share: "disabled",
|
||||
model: "mdm/model",
|
||||
}),
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
PayloadDisplayName: "OpenCode Managed",
|
||||
PayloadIdentifier: "ai.opencode.managed.test",
|
||||
PayloadType: "ai.opencode.managed",
|
||||
PayloadUUID: "AAAA-BBBB-CCCC",
|
||||
PayloadVersion: 1,
|
||||
_manualProfile: true,
|
||||
share: "disabled",
|
||||
model: "mdm/model",
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.share).toBe("disabled")
|
||||
@@ -2231,12 +2277,18 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||
})
|
||||
|
||||
test("parseManagedPlist parses server settings", async () => {
|
||||
const config = await Config.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
server: { hostname: "127.0.0.1", mdns: false },
|
||||
autoupdate: true,
|
||||
}),
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
server: { hostname: "127.0.0.1", mdns: false },
|
||||
autoupdate: true,
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.server?.hostname).toBe("127.0.0.1")
|
||||
@@ -2245,18 +2297,24 @@ test("parseManagedPlist parses server settings", async () => {
|
||||
})
|
||||
|
||||
test("parseManagedPlist parses permission rules", async () => {
|
||||
const config = await Config.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
"*": "ask",
|
||||
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
webfetch: "ask",
|
||||
"~/.ssh/*": "deny",
|
||||
},
|
||||
}),
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
"*": "ask",
|
||||
bash: { "*": "ask", "rm -rf *": "deny", "curl *": "deny" },
|
||||
grep: "allow",
|
||||
glob: "allow",
|
||||
webfetch: "ask",
|
||||
"~/.ssh/*": "deny",
|
||||
},
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.permission?.["*"]).toBe("ask")
|
||||
@@ -2269,19 +2327,29 @@ test("parseManagedPlist parses permission rules", async () => {
|
||||
})
|
||||
|
||||
test("parseManagedPlist parses enabled_providers", async () => {
|
||||
const config = await Config.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["anthropic", "google"],
|
||||
}),
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
enabled_providers: ["anthropic", "google"],
|
||||
}),
|
||||
),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.enabled_providers).toEqual(["anthropic", "google"])
|
||||
})
|
||||
|
||||
test("parseManagedPlist handles empty config", async () => {
|
||||
const config = await Config.parseManagedPlist(
|
||||
JSON.stringify({ $schema: "https://opencode.ai/config.json" }),
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
||||
"test:mobileconfig",
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.$schema).toBe("https://opencode.ai/config.json")
|
||||
|
||||
@@ -10,37 +10,55 @@ import * as Formatter from "../../src/format/formatter"
|
||||
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||
|
||||
describe("Format", () => {
|
||||
it.live("status() returns built-in formatters when no config overrides", () =>
|
||||
it.live("status() returns empty list when no formatters are configured", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
expect(Array.isArray(statuses)).toBe(true)
|
||||
expect(statuses.length).toBeGreaterThan(0)
|
||||
|
||||
for (const item of statuses) {
|
||||
expect(typeof item.name).toBe("string")
|
||||
expect(Array.isArray(item.extensions)).toBe(true)
|
||||
expect(typeof item.enabled).toBe("boolean")
|
||||
}
|
||||
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
expect(yield* fmt.status()).toEqual([])
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() returns empty list when formatter is disabled", () =>
|
||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* fmt.status()).toEqual([])
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
}),
|
||||
),
|
||||
{ config: { formatter: false } },
|
||||
{
|
||||
config: {
|
||||
formatter: true,
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt!.extensions).toContain(".go")
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -51,7 +69,9 @@ describe("Format", () => {
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* fmt.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
const mix = statuses.find((item) => item.name === "mix")
|
||||
expect(gofmt).toBeUndefined()
|
||||
expect(mix).toBeDefined()
|
||||
}),
|
||||
),
|
||||
{
|
||||
@@ -111,68 +131,81 @@ describe("Format", () => {
|
||||
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
||||
config: { formatter: false },
|
||||
})
|
||||
const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()))
|
||||
const b = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
||||
config: {
|
||||
formatter: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(a).toEqual([])
|
||||
expect(b.length).toBeGreaterThan(0)
|
||||
expect(b.find((item) => item.name === "gofmt")).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("runs enabled checks for matching formatters in parallel", () =>
|
||||
provideTmpdirInstance((path) =>
|
||||
Effect.gen(function* () {
|
||||
const file = `${path}/test.parallel`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
provideTmpdirInstance(
|
||||
(path) =>
|
||||
Effect.gen(function* () {
|
||||
const file = `${path}/test.parallel`
|
||||
yield* Effect.promise(() => Bun.write(file, "x"))
|
||||
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
}
|
||||
const one = {
|
||||
extensions: Formatter.gofmt.extensions,
|
||||
enabled: Formatter.gofmt.enabled,
|
||||
}
|
||||
const two = {
|
||||
extensions: Formatter.mix.extensions,
|
||||
enabled: Formatter.mix.enabled,
|
||||
}
|
||||
|
||||
let active = 0
|
||||
let max = 0
|
||||
let active = 0
|
||||
let max = 0
|
||||
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
}),
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
yield* fmt.file(file)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
yield* Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
Formatter.gofmt.extensions = [".parallel"]
|
||||
Formatter.mix.extensions = [".parallel"]
|
||||
Formatter.gofmt.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
Formatter.mix.enabled = async () => {
|
||||
active++
|
||||
max = Math.max(max, active)
|
||||
await Bun.sleep(20)
|
||||
active--
|
||||
return ["sh", "-c", "true"]
|
||||
}
|
||||
}),
|
||||
)
|
||||
() =>
|
||||
Format.Service.use((fmt) =>
|
||||
Effect.gen(function* () {
|
||||
yield* fmt.init()
|
||||
yield* fmt.file(file)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
Effect.sync(() => {
|
||||
Formatter.gofmt.extensions = one.extensions
|
||||
Formatter.gofmt.enabled = one.enabled
|
||||
Formatter.mix.extensions = two.extensions
|
||||
Formatter.mix.enabled = two.enabled
|
||||
}),
|
||||
)
|
||||
|
||||
expect(max).toBe(2)
|
||||
}),
|
||||
expect(max).toBe(2)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: {
|
||||
gofmt: {},
|
||||
mix: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -11,15 +11,38 @@ const it = testEffect(Layer.mergeAll(LSP.defaultLayer, CrossSpawnSpawner.default
|
||||
|
||||
describe("lsp.spawn", () => {
|
||||
it.live("does not spawn builtin LSP for files outside instance", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "..", "hover.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(0)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not spawn builtin LSP for files inside instance when LSP is unset", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.touchFile(path.join(dir, "..", "outside.ts"))
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "..", "hover.ts"),
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
@@ -32,24 +55,55 @@ describe("lsp.spawn", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("would spawn builtin LSP for files inside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
it.live("would spawn builtin LSP for files inside instance when lsp is true", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("would spawn builtin LSP for files inside instance when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined)
|
||||
|
||||
try {
|
||||
yield* lsp.hover({
|
||||
file: path.join(dir, "src", "inside.ts"),
|
||||
line: 0,
|
||||
character: 0,
|
||||
})
|
||||
expect(spy).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -46,17 +46,49 @@ describe("LSP service lifecycle", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns true for .ts files in instance", () =>
|
||||
it.live("hasClients() returns false for .ts files in instance when LSP is unset", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
expect(result).toBe(false)
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns true for .ts files in instance when lsp is true", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ config: { lsp: true } },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() keeps built-in LSPs when config object is provided", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* lsp.hasClients(path.join(dir, "test.ts"))
|
||||
expect(result).toBe(true)
|
||||
}),
|
||||
),
|
||||
{
|
||||
config: {
|
||||
lsp: {
|
||||
eslint: { disabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
it.live("hasClients() returns false for files outside instance", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
LSP.Service.use((lsp) =>
|
||||
|
||||
@@ -63,7 +63,7 @@ describe("plugin.auth-override", () => {
|
||||
}, 30000) // Increased timeout for plugin installation
|
||||
})
|
||||
|
||||
const file = path.join(import.meta.dir, "../../src/plugin/plugin.ts")
|
||||
const file = path.join(import.meta.dir, "../../src/plugin/index.ts")
|
||||
|
||||
describe("plugin.config-hook-error-isolation", () => {
|
||||
test("config hooks are individually error-isolated in the layer factory", async () => {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CloudflareAIGatewayAuthPlugin } from "@/plugin/cloudflare"
|
||||
|
||||
const pluginInput = {
|
||||
client: {} as never,
|
||||
project: {} as never,
|
||||
directory: "",
|
||||
worktree: "",
|
||||
experimental_workspace: {
|
||||
register() {},
|
||||
},
|
||||
serverUrl: new URL("https://example.com"),
|
||||
$: {} as never,
|
||||
}
|
||||
|
||||
function makeHookInput(overrides: { providerID?: string; apiId?: string; reasoning?: boolean }) {
|
||||
return {
|
||||
sessionID: "s",
|
||||
agent: "a",
|
||||
provider: {} as never,
|
||||
message: {} as never,
|
||||
model: {
|
||||
providerID: overrides.providerID ?? "cloudflare-ai-gateway",
|
||||
api: { id: overrides.apiId ?? "openai/gpt-5.2-codex", url: "", npm: "ai-gateway-provider" },
|
||||
capabilities: {
|
||||
reasoning: overrides.reasoning ?? true,
|
||||
temperature: false,
|
||||
attachment: true,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
} as never,
|
||||
}
|
||||
}
|
||||
|
||||
function makeHookOutput() {
|
||||
return { temperature: 0, topP: 1, topK: 0, maxOutputTokens: 32_000 as number | undefined, options: {} }
|
||||
}
|
||||
|
||||
test("omits maxOutputTokens for openai reasoning models on cloudflare-ai-gateway", async () => {
|
||||
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
|
||||
const out = makeHookOutput()
|
||||
await hooks["chat.params"]!(makeHookInput({ apiId: "openai/gpt-5.2-codex", reasoning: true }), out)
|
||||
expect(out.maxOutputTokens).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps maxOutputTokens for openai non-reasoning models", async () => {
|
||||
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
|
||||
const out = makeHookOutput()
|
||||
await hooks["chat.params"]!(makeHookInput({ apiId: "openai/gpt-4-turbo", reasoning: false }), out)
|
||||
expect(out.maxOutputTokens).toBe(32_000)
|
||||
})
|
||||
|
||||
test("keeps maxOutputTokens for non-openai reasoning models on cloudflare-ai-gateway", async () => {
|
||||
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
|
||||
const out = makeHookOutput()
|
||||
await hooks["chat.params"]!(makeHookInput({ apiId: "anthropic/claude-sonnet-4-5", reasoning: true }), out)
|
||||
expect(out.maxOutputTokens).toBe(32_000)
|
||||
})
|
||||
|
||||
test("ignores non-cloudflare-ai-gateway providers", async () => {
|
||||
const hooks = await CloudflareAIGatewayAuthPlugin(pluginInput)
|
||||
const out = makeHookOutput()
|
||||
await hooks["chat.params"]!(makeHookInput({ providerID: "openai", apiId: "gpt-5.2-codex", reasoning: true }), out)
|
||||
expect(out.maxOutputTokens).toBe(32_000)
|
||||
})
|
||||
@@ -14,7 +14,6 @@ const { Instance } = await import("../../src/project/instance")
|
||||
|
||||
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
// @ts-expect-error tests override the flag directly
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -28,7 +27,6 @@ afterAll(() => {
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||
}
|
||||
|
||||
// @ts-expect-error restore original test flag value
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental
|
||||
})
|
||||
|
||||
|
||||
@@ -100,6 +100,24 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
||||
})
|
||||
expect(result.store).toBe(false)
|
||||
})
|
||||
|
||||
test("should set store=true for azure provider by default", () => {
|
||||
const azureModel = {
|
||||
...mockModel,
|
||||
providerID: "azure",
|
||||
api: {
|
||||
id: "gpt-4",
|
||||
url: "https://azure.com",
|
||||
npm: "@ai-sdk/azure",
|
||||
},
|
||||
}
|
||||
const result = ProviderTransform.options({
|
||||
model: azureModel,
|
||||
sessionID,
|
||||
providerOptions: {},
|
||||
})
|
||||
expect(result.store).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ProviderTransform.options - zai/zhipuai thinking", () => {
|
||||
@@ -2246,6 +2264,46 @@ describe("ProviderTransform.variants", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("anthropic opus 4.7 models return adaptive thinking options with xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-opus-4-7",
|
||||
providerID: "gateway",
|
||||
api: {
|
||||
id: "anthropic/claude-opus-4-7",
|
||||
url: "https://gateway.ai",
|
||||
npm: "@ai-sdk/gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
expect(result.xhigh).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
effort: "xhigh",
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
},
|
||||
effort: "max",
|
||||
})
|
||||
})
|
||||
|
||||
test("anthropic opus 4.7 dot-format models return adaptive thinking options with xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-opus-4-7",
|
||||
providerID: "gateway",
|
||||
api: {
|
||||
id: "anthropic/claude-opus-4.7",
|
||||
url: "https://gateway.ai",
|
||||
npm: "@ai-sdk/gateway",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
})
|
||||
|
||||
test("anthropic models return anthropic thinking options", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-sonnet-4",
|
||||
@@ -2654,6 +2712,34 @@ describe("ProviderTransform.variants", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("opus 4.7 returns adaptive thinking options with xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-opus-4-7",
|
||||
providerID: "anthropic",
|
||||
api: {
|
||||
id: "claude-opus-4-7",
|
||||
url: "https://api.anthropic.com",
|
||||
npm: "@ai-sdk/anthropic",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
expect(result.xhigh).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
display: "summarized",
|
||||
},
|
||||
effort: "xhigh",
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
thinking: {
|
||||
type: "adaptive",
|
||||
display: "summarized",
|
||||
},
|
||||
effort: "max",
|
||||
})
|
||||
})
|
||||
|
||||
test("returns high and max with thinking config", () => {
|
||||
const model = createMockModel({
|
||||
id: "anthropic/claude-4",
|
||||
@@ -2702,6 +2788,32 @@ describe("ProviderTransform.variants", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("anthropic opus 4.7 returns adaptive reasoning options with xhigh", () => {
|
||||
const model = createMockModel({
|
||||
id: "bedrock/anthropic-claude-opus-4-7",
|
||||
providerID: "bedrock",
|
||||
api: {
|
||||
id: "anthropic.claude-opus-4-7",
|
||||
url: "https://bedrock.amazonaws.com",
|
||||
npm: "@ai-sdk/amazon-bedrock",
|
||||
},
|
||||
})
|
||||
const result = ProviderTransform.variants(model)
|
||||
expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"])
|
||||
expect(result.xhigh).toEqual({
|
||||
reasoningConfig: {
|
||||
type: "adaptive",
|
||||
maxReasoningEffort: "xhigh",
|
||||
},
|
||||
})
|
||||
expect(result.max).toEqual({
|
||||
reasoningConfig: {
|
||||
type: "adaptive",
|
||||
maxReasoningEffort: "max",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
|
||||
const model = createMockModel({
|
||||
id: "bedrock/llama-4",
|
||||
|
||||
@@ -157,16 +157,6 @@ describe("structured-output.AssistantMessage", () => {
|
||||
})
|
||||
|
||||
describe("structured-output.createStructuredOutputTool", () => {
|
||||
test("creates tool with correct id", () => {
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: { type: "object", properties: { name: { type: "string" } } },
|
||||
onSuccess: () => {},
|
||||
})
|
||||
|
||||
// AI SDK tool type doesn't expose id, but we set it internally
|
||||
expect((tool as any).id).toBe("StructuredOutput")
|
||||
})
|
||||
|
||||
test("creates tool with description", () => {
|
||||
const tool = SessionPrompt.createStructuredOutputTool({
|
||||
schema: { type: "object" },
|
||||
|
||||
@@ -3,8 +3,8 @@ import { beforeEach, describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Layer, Option } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account"
|
||||
import { Account } from "../../src/account"
|
||||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { Bus } from "../../src/bus"
|
||||
@@ -72,7 +72,7 @@ const share = (id: SessionID) =>
|
||||
Database.use((db) => db.select().from(SessionShareTable).where(eq(SessionShareTable.session_id, id)).get())
|
||||
|
||||
const seed = (url: string, org?: string) =>
|
||||
AccountRepo.use((repo) =>
|
||||
AccountRepo.Service.use((repo) =>
|
||||
repo.persistAccount({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
|
||||
@@ -15,12 +15,10 @@ const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
beforeEach(() => {
|
||||
Database.close()
|
||||
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
|
||||
})
|
||||
|
||||
@@ -187,5 +185,53 @@ describe("SyncEvent", () => {
|
||||
).toThrow(/Unknown event type/)
|
||||
}),
|
||||
)
|
||||
|
||||
test(
|
||||
"replayAll accepts later chunks after the first batch",
|
||||
withInstance(() => {
|
||||
const { Created } = setup()
|
||||
const id = Identifier.descending("message")
|
||||
|
||||
const one = SyncEvent.replayAll([
|
||||
{
|
||||
id: "evt_1",
|
||||
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||
seq: 0,
|
||||
aggregateID: id,
|
||||
data: { id, name: "first" },
|
||||
},
|
||||
{
|
||||
id: "evt_2",
|
||||
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||
seq: 1,
|
||||
aggregateID: id,
|
||||
data: { id, name: "second" },
|
||||
},
|
||||
])
|
||||
|
||||
const two = SyncEvent.replayAll([
|
||||
{
|
||||
id: "evt_3",
|
||||
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||
seq: 2,
|
||||
aggregateID: id,
|
||||
data: { id, name: "third" },
|
||||
},
|
||||
{
|
||||
id: "evt_4",
|
||||
type: SyncEvent.versionedType(Created.type, Created.version),
|
||||
seq: 3,
|
||||
aggregateID: id,
|
||||
data: { id, name: "fourth" },
|
||||
},
|
||||
])
|
||||
|
||||
expect(one).toBe(id)
|
||||
expect(two).toBe(id)
|
||||
|
||||
const rows = Database.use((db) => db.select().from(EventTable).all())
|
||||
expect(rows.map((row) => row.seq)).toEqual([0, 1, 2, 3])
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { registerAdaptor } from "../../src/control-plane/adaptors"
|
||||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
||||
import { Database, asc, eq } from "../../src/storage"
|
||||
import { SyncEvent } from "../../src/sync"
|
||||
import { EventTable } from "../../src/sync/event.sql"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
beforeEach(() => {
|
||||
Database.close()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await Instance.disposeAll()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
function create(input?: SessionNs.CreateInput) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
}
|
||||
|
||||
function get(id: SessionID) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.get(id)))
|
||||
}
|
||||
|
||||
function updateMessage<T extends MessageV2.Info>(msg: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updateMessage(msg)))
|
||||
}
|
||||
|
||||
function updatePart<T extends MessageV2.Part>(part: T) {
|
||||
return AppRuntime.runPromise(SessionNs.Service.use((svc) => svc.updatePart(part)))
|
||||
}
|
||||
|
||||
async function user(sessionID: SessionID, text: string) {
|
||||
const msg = await updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
await updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: msg.id,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
}
|
||||
|
||||
function remote(dir: string, url: string): WorkspaceAdaptor {
|
||||
return {
|
||||
name: "remote",
|
||||
description: "remote",
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
directory: dir,
|
||||
}
|
||||
},
|
||||
async create() {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "remote" as const,
|
||||
url,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function local(dir: string): WorkspaceAdaptor {
|
||||
return {
|
||||
name: "local",
|
||||
description: "local",
|
||||
configure(info) {
|
||||
return {
|
||||
...info,
|
||||
directory: dir,
|
||||
}
|
||||
},
|
||||
async create() {
|
||||
await fs.mkdir(dir, { recursive: true })
|
||||
},
|
||||
async remove() {},
|
||||
target() {
|
||||
return {
|
||||
type: "local" as const,
|
||||
directory: dir,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function eventStreamResponse() {
|
||||
return new Response(new ReadableStream({ start() {} }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "text/event-stream",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("Workspace.sessionRestore", () => {
|
||||
test("replays session events in batches of 10 and emits progress", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const dir = path.join(tmp.path, ".restore")
|
||||
const seen: any[] = []
|
||||
const posts: Array<{
|
||||
path: string
|
||||
body: { directory: string; events: Array<{ seq: number; aggregateID: string }> }
|
||||
}> = []
|
||||
const on = (evt: any) => seen.push(evt)
|
||||
GlobalBus.on("event", on)
|
||||
|
||||
const raw = globalThis.fetch
|
||||
spyOn(globalThis, "fetch").mockImplementation(
|
||||
Object.assign(
|
||||
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
|
||||
const url = new URL(typeof input === "string" || input instanceof URL ? input : input.url)
|
||||
if (url.pathname !== "/base/sync/replay") {
|
||||
return eventStreamResponse()
|
||||
}
|
||||
const body = JSON.parse(String(init?.body))
|
||||
posts.push({
|
||||
path: url.pathname,
|
||||
body,
|
||||
})
|
||||
return Response.json({ sessionID: body.events[0].aggregateID })
|
||||
},
|
||||
{
|
||||
preconnect: raw.preconnect?.bind(raw),
|
||||
},
|
||||
) as typeof globalThis.fetch,
|
||||
)
|
||||
|
||||
try {
|
||||
const setup = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
registerAdaptor(Instance.project.id, "worktree", remote(dir, "https://workspace.test/base"))
|
||||
const space = await Workspace.create({
|
||||
type: "worktree",
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
const session = await create({})
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await user(session.id, `msg ${i}`)
|
||||
}
|
||||
const rows = Database.use((db) =>
|
||||
db
|
||||
.select({ seq: EventTable.seq })
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, session.id))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all(),
|
||||
)
|
||||
const result = await Workspace.sessionRestore({
|
||||
workspaceID: space.id,
|
||||
sessionID: session.id,
|
||||
})
|
||||
return { space, session, rows, result }
|
||||
},
|
||||
})
|
||||
|
||||
expect(setup.rows).toHaveLength(13)
|
||||
expect(setup.result).toEqual({ total: 2 })
|
||||
expect(posts).toHaveLength(2)
|
||||
expect(posts[0]?.path).toBe("/base/sync/replay")
|
||||
expect(posts[1]?.path).toBe("/base/sync/replay")
|
||||
expect(posts[0]?.body.directory).toBe(dir)
|
||||
expect(posts[1]?.body.directory).toBe(dir)
|
||||
expect(posts[0]?.body.events).toHaveLength(10)
|
||||
expect(posts[1]?.body.events).toHaveLength(4)
|
||||
expect(posts.flatMap((item) => item.body.events.map((event) => event.seq))).toEqual([
|
||||
...setup.rows.map((row) => row.seq),
|
||||
setup.rows.at(-1)!.seq + 1,
|
||||
])
|
||||
expect(posts[1]?.body.events.at(-1)).toMatchObject({
|
||||
aggregateID: setup.session.id,
|
||||
seq: setup.rows.at(-1)!.seq + 1,
|
||||
type: SyncEvent.versionedType(SessionNs.Event.Updated.type, SessionNs.Event.Updated.version),
|
||||
data: {
|
||||
sessionID: setup.session.id,
|
||||
info: {
|
||||
workspaceID: setup.space.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const restore = seen.filter(
|
||||
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
|
||||
)
|
||||
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
|
||||
expect(restore.map((evt) => evt.payload.properties.total)).toEqual([2, 2, 2])
|
||||
expect(restore.map((evt) => evt.payload.properties.sessionID)).toEqual([
|
||||
setup.session.id,
|
||||
setup.session.id,
|
||||
setup.session.id,
|
||||
])
|
||||
} finally {
|
||||
GlobalBus.off("event", on)
|
||||
}
|
||||
})
|
||||
|
||||
test("replays locally without posting to a server", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const dir = path.join(tmp.path, ".restore-local")
|
||||
const seen: any[] = []
|
||||
const on = (evt: any) => seen.push(evt)
|
||||
GlobalBus.on("event", on)
|
||||
|
||||
const fetch = spyOn(globalThis, "fetch")
|
||||
const replayAll = spyOn(SyncEvent, "replayAll")
|
||||
|
||||
try {
|
||||
const setup = await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
registerAdaptor(Instance.project.id, "local-restore", local(dir))
|
||||
const space = await Workspace.create({
|
||||
type: "local-restore",
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: Instance.project.id,
|
||||
})
|
||||
const session = await create({})
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await user(session.id, `msg ${i}`)
|
||||
}
|
||||
const result = await Workspace.sessionRestore({
|
||||
workspaceID: space.id,
|
||||
sessionID: session.id,
|
||||
})
|
||||
const updated = await get(session.id)
|
||||
return { space, session, result, updated }
|
||||
},
|
||||
})
|
||||
|
||||
expect(setup.result).toEqual({ total: 2 })
|
||||
expect(fetch).not.toHaveBeenCalled()
|
||||
expect(replayAll).toHaveBeenCalledTimes(2)
|
||||
expect(setup.updated.workspaceID).toBe(setup.space.id)
|
||||
|
||||
const restore = seen.filter(
|
||||
(evt) => evt.workspace === setup.space.id && evt.payload.type === Workspace.Event.Restore.type,
|
||||
)
|
||||
expect(restore.map((evt) => evt.payload.properties.step)).toEqual([0, 1, 2])
|
||||
} finally {
|
||||
GlobalBus.off("event", on)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user