| 1 | import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts"; |
| 2 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | function createLocalStorageStub(): Storage { |
| 5 | const store = new Map<string, string>(); |
| 6 | return { |
| 7 | getItem: (key) => store.get(key) ?? null, |
| 8 | setItem: (key, value) => { |
| 9 | store.set(key, value); |
| 10 | }, |
| 11 | removeItem: (key) => { |
| 12 | store.delete(key); |
| 13 | }, |
| 14 | clear: () => { |
| 15 | store.clear(); |
| 16 | }, |
| 17 | key: (index) => [...store.keys()][index] ?? null, |
| 18 | get length() { |
| 19 | return store.size; |
| 20 | }, |
| 21 | }; |
| 22 | } |
| 23 | |
| 24 | function getTestWindow(): Window & typeof globalThis { |
| 25 | const localStorage = createLocalStorageStub(); |
| 26 | const testWindow = { |
| 27 | localStorage, |
| 28 | } as Window & typeof globalThis; |
| 29 | vi.stubGlobal("window", testWindow); |
| 30 | vi.stubGlobal("localStorage", localStorage); |
| 31 | return testWindow; |
| 32 | } |
| 33 | |
| 34 | afterEach(() => { |
| 35 | vi.resetModules(); |
| 36 | vi.unstubAllGlobals(); |
| 37 | vi.restoreAllMocks(); |
| 38 | }); |
| 39 | |
| 40 | describe("clientPersistenceStorage", () => { |
| 41 | it("persists client settings in browser storage", async () => { |
| 42 | getTestWindow(); |
| 43 | const { readBrowserClientSettings, writeBrowserClientSettings } = |
| 44 | await import("./clientPersistenceStorage"); |
| 45 | const settings = { |
| 46 | ...DEFAULT_CLIENT_SETTINGS, |
| 47 | timestampFormat: "24-hour" as const, |
| 48 | }; |
| 49 | |
| 50 | writeBrowserClientSettings(settings); |
| 51 | |
| 52 | expect(readBrowserClientSettings()).toEqual(settings); |
| 53 | }); |
| 54 | |
| 55 | it.each(["not-json", '{"wordWrap":"invalid"}'])( |
| 56 | "does not treat invalid saved settings as absent: %s", |
| 57 | async (value) => { |
| 58 | const testWindow = getTestWindow(); |
| 59 | testWindow.localStorage.setItem("t3code:client-settings:v1", value); |
| 60 | const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); |
| 61 | |
| 62 | expect(() => readBrowserClientSettings()).toThrow( |
| 63 | expect.objectContaining({ |
| 64 | _tag: "LocalStorageOperationError", |
| 65 | operation: "decode", |
| 66 | storageKey: "t3code:client-settings:v1", |
| 67 | }), |
| 68 | ); |
| 69 | expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); |
| 70 | }, |
| 71 | ); |
| 72 | |
| 73 | it("preserves saved settings across a transient read failure", async () => { |
| 74 | const testWindow = getTestWindow(); |
| 75 | const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; |
| 76 | testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); |
| 77 | const write = vi.spyOn(testWindow.localStorage, "setItem"); |
| 78 | const failure = new Error("storage unavailable"); |
| 79 | vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { |
| 80 | throw failure; |
| 81 | }); |
| 82 | const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); |
| 83 | |
| 84 | expect(() => readBrowserClientSettings()).toThrow( |
| 85 | expect.objectContaining({ |
| 86 | _tag: "LocalStorageOperationError", |
| 87 | operation: "read", |
| 88 | storageKey: "t3code:client-settings:v1", |
| 89 | cause: failure, |
| 90 | }), |
| 91 | ); |
| 92 | expect(readBrowserClientSettings()).toEqual(settings); |
| 93 | expect(write).not.toHaveBeenCalled(); |
| 94 | }); |
| 95 | |
| 96 | it("defaults word wrap on and discards obsolete wrapping preferences", async () => { |
| 97 | const testWindow = getTestWindow(); |
| 98 | testWindow.localStorage.setItem( |
| 99 | "t3code:client-settings:v1", |
| 100 | JSON.stringify({ |
| 101 | chatWordWrap: false, |
| 102 | diffWordWrap: false, |
| 103 | }), |
| 104 | ); |
| 105 | const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); |
| 106 | const settings = readBrowserClientSettings(); |
| 107 | |
| 108 | expect(settings).toEqual( |
| 109 | expect.objectContaining({ |
| 110 | wordWrap: true, |
| 111 | }), |
| 112 | ); |
| 113 | expect(settings).not.toHaveProperty("chatWordWrap"); |
| 114 | expect(settings).not.toHaveProperty("diffWordWrap"); |
| 115 | }); |
| 116 | |
| 117 | it("keeps the default diff file state across reloads and defaults it to collapsed", async () => { |
| 118 | const testWindow = getTestWindow(); |
| 119 | const { readBrowserClientSettings, writeBrowserClientSettings } = |
| 120 | await import("./clientPersistenceStorage"); |
| 121 | |
| 122 | testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); |
| 123 | expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); |
| 124 | |
| 125 | writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: true }); |
| 126 | expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(true); |
| 127 | |
| 128 | writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffFilesCollapsed: false }); |
| 129 | expect(readBrowserClientSettings()?.diffFilesCollapsed).toBe(false); |
| 130 | }); |
| 131 | |
| 132 | it("keeps the diff layout across reloads and defaults it to stacked", async () => { |
| 133 | const testWindow = getTestWindow(); |
| 134 | const { readBrowserClientSettings, writeBrowserClientSettings } = |
| 135 | await import("./clientPersistenceStorage"); |
| 136 | |
| 137 | expect(readBrowserClientSettings()).toBeNull(); |
| 138 | testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify({})); |
| 139 | expect(readBrowserClientSettings()?.diffLayout).toBe("stacked"); |
| 140 | |
| 141 | writeBrowserClientSettings({ ...DEFAULT_CLIENT_SETTINGS, diffLayout: "split" }); |
| 142 | expect(readBrowserClientSettings()?.diffLayout).toBe("split"); |
| 143 | }); |
| 144 | }); |