persist.test.ts 3.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
  2. type PersistTestingType = typeof import("./persist").PersistTesting
  3. class MemoryStorage implements Storage {
  4. private values = new Map<string, string>()
  5. readonly events: string[] = []
  6. readonly calls = { get: 0, set: 0, remove: 0 }
  7. clear() {
  8. this.values.clear()
  9. }
  10. get length() {
  11. return this.values.size
  12. }
  13. key(index: number) {
  14. return Array.from(this.values.keys())[index] ?? null
  15. }
  16. getItem(key: string) {
  17. this.calls.get += 1
  18. this.events.push(`get:${key}`)
  19. if (key.startsWith("opencode.throw")) throw new Error("storage get failed")
  20. return this.values.get(key) ?? null
  21. }
  22. setItem(key: string, value: string) {
  23. this.calls.set += 1
  24. this.events.push(`set:${key}`)
  25. if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError")
  26. if (key.startsWith("opencode.throw")) throw new Error("storage set failed")
  27. this.values.set(key, value)
  28. }
  29. removeItem(key: string) {
  30. this.calls.remove += 1
  31. this.events.push(`remove:${key}`)
  32. if (key.startsWith("opencode.throw")) throw new Error("storage remove failed")
  33. this.values.delete(key)
  34. }
  35. }
  36. const storage = new MemoryStorage()
  37. let persistTesting: PersistTestingType
  38. beforeAll(async () => {
  39. mock.module("@/context/platform", () => ({
  40. usePlatform: () => ({ platform: "web" }),
  41. }))
  42. const mod = await import("./persist")
  43. persistTesting = mod.PersistTesting
  44. })
  45. beforeEach(() => {
  46. storage.clear()
  47. storage.events.length = 0
  48. storage.calls.get = 0
  49. storage.calls.set = 0
  50. storage.calls.remove = 0
  51. Object.defineProperty(globalThis, "localStorage", {
  52. value: storage,
  53. configurable: true,
  54. })
  55. })
  56. describe("persist localStorage resilience", () => {
  57. test("does not cache values as persisted when quota write and eviction fail", () => {
  58. const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope")
  59. storageApi.setItem("value", '{"value":1}')
  60. expect(storage.getItem("opencode.quota.scope:value")).toBeNull()
  61. expect(storageApi.getItem("value")).toBeNull()
  62. })
  63. test("disables only the failing scope when storage throws", () => {
  64. const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope")
  65. bad.setItem("value", '{"value":1}')
  66. const before = storage.calls.set
  67. bad.setItem("value", '{"value":2}')
  68. expect(storage.calls.set).toBe(before)
  69. expect(bad.getItem("value")).toBeNull()
  70. const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope")
  71. healthy.setItem("value", '{"value":3}')
  72. expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}')
  73. })
  74. test("failing fallback scope does not poison direct storage scope", () => {
  75. const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2")
  76. broken.setItem("value", '{"value":1}')
  77. const direct = persistTesting.localStorageDirect()
  78. direct.setItem("direct-value", '{"value":5}')
  79. expect(storage.getItem("direct-value")).toBe('{"value":5}')
  80. })
  81. test("normalizer rejects malformed JSON payloads", () => {
  82. const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
  83. expect(result).toBeUndefined()
  84. })
  85. })