| import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" |
| import { ServerScope } from "./server-scope" |
|
|
| type PersistTestingType = typeof import("./persist").PersistTesting |
| type PersistType = typeof import("./persist").Persist |
| type RemovePersistedType = typeof import("./persist").removePersisted |
|
|
| class MemoryStorage implements Storage { |
| private values = new Map<string, string>() |
| readonly events: string[] = [] |
| readonly calls = { get: 0, set: 0, remove: 0 } |
|
|
| clear() { |
| this.values.clear() |
| } |
|
|
| get length() { |
| return this.values.size |
| } |
|
|
| key(index: number) { |
| return Array.from(this.values.keys())[index] ?? null |
| } |
|
|
| getItem(key: string) { |
| this.calls.get += 1 |
| this.events.push(`get:${key}`) |
| if (key.startsWith("opencode.throw")) throw new Error("storage get failed") |
| return this.values.get(key) ?? null |
| } |
|
|
| setItem(key: string, value: string) { |
| this.calls.set += 1 |
| this.events.push(`set:${key}`) |
| if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") |
| if (key.startsWith("opencode.throw")) throw new Error("storage set failed") |
| this.values.set(key, value) |
| } |
|
|
| removeItem(key: string) { |
| this.calls.remove += 1 |
| this.events.push(`remove:${key}`) |
| if (key.startsWith("opencode.throw")) throw new Error("storage remove failed") |
| this.values.delete(key) |
| } |
| } |
|
|
| const storage = new MemoryStorage() |
|
|
| let persistTesting: PersistTestingType |
| let Persist: PersistType |
| let removePersisted: RemovePersistedType |
|
|
| beforeAll(async () => { |
| mock.module("@/context/platform", () => ({ |
| usePlatform: () => ({ platform: "web" }), |
| })) |
|
|
| const mod = await import("./persist") |
| persistTesting = mod.PersistTesting |
| Persist = mod.Persist |
| removePersisted = mod.removePersisted |
| }) |
|
|
| beforeEach(() => { |
| storage.clear() |
| storage.events.length = 0 |
| storage.calls.get = 0 |
| storage.calls.set = 0 |
| storage.calls.remove = 0 |
| Object.defineProperty(globalThis, "localStorage", { |
| value: storage, |
| configurable: true, |
| }) |
| }) |
|
|
| describe("persist localStorage resilience", () => { |
| test("does not cache values as persisted when quota write and eviction fail", () => { |
| const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope") |
| storageApi.setItem("value", '{"value":1}') |
|
|
| expect(storage.getItem("opencode.quota.scope:value")).toBeNull() |
| expect(storageApi.getItem("value")).toBeNull() |
| }) |
|
|
| test("disables only the failing scope when storage throws", () => { |
| const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope") |
| bad.setItem("value", '{"value":1}') |
|
|
| const before = storage.calls.set |
| bad.setItem("value", '{"value":2}') |
| expect(storage.calls.set).toBe(before) |
| expect(bad.getItem("value")).toBeNull() |
|
|
| const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope") |
| healthy.setItem("value", '{"value":3}') |
| expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}') |
| }) |
|
|
| test("failing fallback scope does not poison direct storage scope", () => { |
| const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2") |
| broken.setItem("value", '{"value":1}') |
|
|
| const direct = persistTesting.localStorageDirect() |
| direct.setItem("direct-value", '{"value":5}') |
|
|
| expect(storage.getItem("direct-value")).toBe('{"value":5}') |
| }) |
|
|
| test("normalizer rejects malformed JSON payloads", () => { |
| const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}') |
| expect(result).toBeUndefined() |
| }) |
|
|
| test("workspace storage sanitizes Windows filename characters", () => { |
| const result = persistTesting.workspaceStorage("C:\\Users\\foo") |
|
|
| expect(result).toStartWith("opencode.workspace.") |
| expect(result.endsWith(".dat")).toBeTrue() |
| expect(/[:\\/]/.test(result)).toBeFalse() |
| }) |
|
|
| test("workspace target keeps raw path storage as legacy fallback", () => { |
| const target = Persist.workspace("C:\\Users\\foo", "vcs") |
|
|
| expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) |
| expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) |
| }) |
|
|
| test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => { |
| const target = Persist.workspace("C:/Users/foo", "vcs") |
|
|
| expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) |
| expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) |
| }) |
|
|
| test("migrates direct legacy keys into scoped storage", () => { |
| storage.setItem("legacy.workspace", '{"value":2}') |
| const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"]) |
| const current = persistTesting.localStorageWithPrefix(target.storage!) |
| const legacyStore = persistTesting.localStorageDirect() |
|
|
| const result = persistTesting.migrateLegacy({ |
| current, |
| legacyStore, |
| stores: [], |
| keys: target.legacy!, |
| key: target.key, |
| defaults: { value: 1 }, |
| }) |
|
|
| expect(result).toBe('{"value":2}') |
| expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}') |
| expect(legacyStore.getItem("legacy.workspace")).toBeNull() |
| expect(storage.getItem("legacy.workspace")).toBeNull() |
| }) |
|
|
| test("removes legacy workspace storage when removing persisted target", () => { |
| const target = Persist.workspace("C:\\Users\\foo", "terminal") |
| storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') |
| storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}') |
|
|
| removePersisted(target) |
|
|
| expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() |
| expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() |
| }) |
|
|
| test("draft target isolates storage per draft and namespaces keys", () => { |
| const a = Persist.draft("draft-a", "prompt") |
| const b = Persist.draft("draft-b", "prompt") |
|
|
| expect(a.key).toBe("draft:prompt") |
| expect(a.storage).not.toBe(b.storage) |
| expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage) |
| }) |
|
|
| test("removes draft storage when removing persisted target", () => { |
| const target = Persist.draft("draft-a", "prompt") |
| storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') |
|
|
| removePersisted(target) |
|
|
| expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() |
| }) |
|
|
| test("server workspace target preserves local storage and isolates remote storage", () => { |
| const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt") |
| const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt") |
| const debian = Persist.serverWorkspace("https://debian.example" as ServerScope, "/home/luke/repo", "prompt") |
|
|
| expect(local).toEqual(Persist.workspace("/home/luke/repo", "prompt")) |
| expect(windows.storage).not.toBe(local.storage) |
| expect(debian.storage).not.toBe(local.storage) |
| expect(debian.storage).not.toBe(windows.storage) |
| expect(windows.legacyStorageNames).toBeUndefined() |
| expect(debian.legacyStorageNames).toBeUndefined() |
| }) |
|
|
| test("server global target preserves local key and isolates remote keys", () => { |
| expect(Persist.serverGlobal(ServerScope.local, "notification")).toEqual(Persist.global("notification")) |
| expect(Persist.serverGlobal("https://debian.example" as ServerScope, "notification")).toEqual({ |
| storage: "opencode.global.dat", |
| key: "https://debian.example\0notification", |
| }) |
| }) |
|
|
| test("server global target cannot collide when scope and key contain colons", () => { |
| expect(Persist.serverGlobal("a:b" as ServerScope, "c")).not.toEqual(Persist.serverGlobal("a" as ServerScope, "b:c")) |
| }) |
| }) |
|
|