diff --git a/src/app/service/service_worker/synchronize.test.ts b/src/app/service/service_worker/synchronize.test.ts index 79d2bcd20..30c485909 100644 --- a/src/app/service/service_worker/synchronize.test.ts +++ b/src/app/service/service_worker/synchronize.test.ts @@ -4,7 +4,7 @@ import { initTestEnv } from "@Tests/utils"; import type FileSystem from "@Packages/filesystem/filesystem"; import type { FileInfo } from "@Packages/filesystem/filesystem"; import { FileSystemError } from "@Packages/filesystem/error"; -import type { CloudSyncConfig, SystemConfig } from "@App/pkg/config/config"; +import { SystemConfig, type CloudSyncConfig } from "@App/pkg/config/config"; import type { ScriptDAO } from "@App/app/repo/scripts"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { md5OfText } from "@App/pkg/utils/crypto"; @@ -13,6 +13,8 @@ import { AgentModelRepo } from "@App/app/repo/agent_model"; import ChromeStorage from "@App/pkg/config/chrome_storage"; import { createMockOPFS } from "@App/app/repo/test-helpers"; import { cacheInstance } from "@App/app/cache"; +import { MessageQueue } from "@Packages/message/message_queue"; +import { defaultConfig as eslintDefaultConfig } from "@Packages/eslint/linter-config"; initTestEnv(); @@ -165,6 +167,70 @@ describe("SynchronizeService", () => { expect((bundle.systemConfig as any).sync).toBeUndefined(); }); + it("V1 object envelope 备份恢复后保留 resolved 配置", async () => { + const config = new SystemConfig(new MessageQueue()); + const modified = JSON.parse(eslintDefaultConfig); + modified.rules["no-debugger"] = ["off"]; + config.setEslintConfig(JSON.stringify(modified)); + await vi.waitFor(async () => { + const stored = await chrome.storage.sync.get("system_eslint_config"); + expect(stored.system_eslint_config).toEqual({ + format: "scriptcat-json-overrides", + version: 1, + overrides: { rules: { "no-debugger": ["off"] } }, + }); + }); + + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: {} } as any + ); + const bundle = await service.getConfigBundle(); + expect(bundle.systemConfig.eslint_config).toEqual({ + format: "scriptcat-json-overrides", + version: 1, + overrides: { rules: { "no-debugger": ["off"] } }, + }); + + await chrome.storage.sync.clear(); + await service.restoreConfigBundle(bundle); + await expect(new SystemConfig(new MessageQueue()).getEslintConfig()).resolves.toBe( + JSON.stringify(modified, null, 2) + ); + }); + + it("legacy string 备份恢复后保持 raw representation 并按 legacy 语义解析", async () => { + const legacy = JSON.parse(eslintDefaultConfig); + legacy.rules["no-debugger"] = ["off"]; + const legacyString = JSON.stringify(legacy); + await chrome.storage.sync.set({ system_eslint_config: legacyString }); + + const service = new SynchronizeService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { scriptCodeDAO: {} } as any + ); + const bundle = await service.getConfigBundle(); + expect(bundle.systemConfig.eslint_config).toBe(legacyString); + + await chrome.storage.sync.clear(); + await service.restoreConfigBundle(bundle); + const restored = await chrome.storage.sync.get("system_eslint_config"); + expect(restored.system_eslint_config).toBe(legacyString); + await expect(new SystemConfig(new MessageQueue()).getEslintConfig()).resolves.toBe(JSON.stringify(legacy, null, 2)); + }); + it("restoreConfigBundle 把 systemConfig 键写回 sync storage", async () => { const service = new SynchronizeService( {} as any, diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index a31c26936..8bf51994a 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { SystemConfig } from "./config"; import { MessageQueue } from "@Packages/message/message_queue"; +import { defaultConfig as eslintDefaultConfig } from "@Packages/eslint/linter-config"; +import { defaultConfig as editorDefaultConfig } from "@App/pkg/utils/monaco-editor/config"; describe("SystemConfig 双 storage 与懒迁移", () => { let mq: MessageQueue; @@ -287,6 +289,142 @@ describe("SystemConfig 双 storage 与懒迁移", () => { }); }); + describe("JSON 配置的稀疏存储与默认值合并", () => { + it("未修改时应返回最新默认配置", async () => { + await expect(config.getEslintConfig()).resolves.toBe(eslintDefaultConfig); + await expect(config.getEditorConfig()).resolves.toBe(editorDefaultConfig); + }); + + it("保存时应只存储与默认配置的差异", async () => { + const modified = JSON.parse(eslintDefaultConfig); + modified.rules["no-debugger"] = ["warn"]; + modified.rules["custom/added-rule"] = ["error"]; + config.setEslintConfig(JSON.stringify(modified)); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_eslint_config"); + expect(syncData["system_eslint_config"]).toEqual({ + format: "scriptcat-json-overrides", + version: 1, + overrides: { rules: { "no-debugger": ["warn"], "custom/added-rule": ["error"] } }, + }); + }); + }); + + it("读取时应将存储的差异合并到最新默认配置", async () => { + await chrome.storage.sync.set({ + system_eslint_config: { + format: "scriptcat-json-overrides", + version: 1, + overrides: { rules: { "no-debugger": ["warn"] } }, + }, + }); + + const result = JSON.parse(await new SystemConfig(new MessageQueue()).getEslintConfig()); + const defaults = JSON.parse(eslintDefaultConfig); + expect(result.rules["no-debugger"]).toEqual(["warn"]); + expect(result.rules["no-empty"]).toEqual(defaults.rules["no-empty"]); + expect(result.globals).toEqual(defaults.globals); + }); + + it("读取旧版全量配置时保留已有值并补充当前缺失字段", async () => { + const legacy = JSON.parse(eslintDefaultConfig); + legacy.rules["no-debugger"] = ["off"]; + delete legacy.rules["no-empty"]; + const legacyString = JSON.stringify(legacy); + await chrome.storage.sync.set({ system_eslint_config: legacyString }); + + const result = JSON.parse(await new SystemConfig(new MessageQueue()).getEslintConfig()); + expect(result.rules["no-debugger"]).toEqual(["off"]); + expect(result.rules["no-empty"]).toEqual(JSON.parse(eslintDefaultConfig).rules["no-empty"]); + }); + + it("读取旧版全量配置不会写回 storage", async () => { + const legacy = JSON.parse(eslintDefaultConfig); + legacy.rules["no-debugger"] = ["off"]; + const legacyString = JSON.stringify(legacy); + await chrome.storage.sync.set({ system_eslint_config: legacyString }); + const before = await chrome.storage.sync.get("system_eslint_config"); + + await new SystemConfig(new MessageQueue()).getEslintConfig(); + + const after = await chrome.storage.sync.get("system_eslint_config"); + expect(after).toEqual(before); + }); + + it("用户明确保存旧版全量配置后才转换为 V1 sparse object", async () => { + const legacy = JSON.parse(eslintDefaultConfig); + legacy.rules["no-debugger"] = ["off"]; + const legacyString = JSON.stringify(legacy); + await chrome.storage.sync.set({ system_eslint_config: legacyString }); + + const resolved = await config.getEslintConfig(); + config.setEslintConfig(resolved); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_eslint_config"); + expect(syncData["system_eslint_config"]).toEqual({ + format: "scriptcat-json-overrides", + version: 1, + overrides: { rules: { "no-debugger": ["off"] } }, + }); + }); + }); + + it("旧版全量配置中像 envelope 的字段仍按用户配置保留", async () => { + const legacy = JSON.stringify({ + format: "scriptcat-json-overrides", + version: 1, + overrides: { a: 99 }, + }); + await chrome.storage.sync.set({ system_eslint_config: legacy }); + + const result = JSON.parse(await new SystemConfig(new MessageQueue()).getEslintConfig()); + expect(result).toMatchObject({ format: "scriptcat-json-overrides", version: 1, overrides: { a: 99 } }); + }); + + it("保存与默认配置一致的内容时应清除存储", async () => { + // 使用紧凑格式,验证差异按语义比较而非字符串比较 + config.setEslintConfig(JSON.stringify(JSON.parse(eslintDefaultConfig))); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_eslint_config"); + expect(syncData["system_eslint_config"]).toBeUndefined(); + }); + // 重新读取(新实例,不走缓存)应返回默认配置 + await expect(new SystemConfig(new MessageQueue()).getEslintConfig()).resolves.toBe(eslintDefaultConfig); + }); + + it("保存空字符串应恢复默认配置", async () => { + config.setEslintConfig(JSON.stringify({ rules: { "no-debugger": ["warn"] } })); + config.setEslintConfig(""); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_eslint_config"); + expect(syncData["system_eslint_config"]).toBeUndefined(); + }); + await expect(config.getEslintConfig()).resolves.toBe(eslintDefaultConfig); + }); + + it("editor_config 同样只存储差异并合并读取", async () => { + const modified = JSON.parse(editorDefaultConfig); + modified.strict = false; + config.setEditorConfig(JSON.stringify(modified)); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_editor_config"); + expect(syncData["system_editor_config"]).toEqual({ + format: "scriptcat-json-overrides", + version: 1, + overrides: { strict: false }, + }); + }); + + const result = JSON.parse(await new SystemConfig(new MessageQueue()).getEditorConfig()); + expect(result).toEqual({ ...JSON.parse(editorDefaultConfig), strict: false }); + }); + }); + describe("React 外部存储适配器", () => { it("同一配置键应返回稳定的 store 实例", () => { expect(config.externalStore("favicon_service")).toBe(config.externalStore("favicon_service")); diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index fbd630de8..7998bf7f6 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -10,6 +10,7 @@ import type { ScriptTemplateOverrides } from "@App/pkg/utils/script_template"; import { toCamelCase } from "../utils/utils"; import EventEmitter from "eventemitter3"; import { STORAGE_LOCAL_KEYS } from "./consts"; +import { decodeJsonConfig, encodeJsonConfig } from "./json_overrides"; export const SystemConfigChange = "systemConfigChange"; @@ -196,6 +197,21 @@ export class SystemConfig { // 设备相关的配置项,使用 chrome.storage.local(不跨设备同步) private readonly localStorage = new ChromeStorage("system", false); + private readonly jsonConfigDefaults: Partial> = { + eslint_config: defaultConfig, + editor_config: editorDefaultConfig, + }; + + private decodeStored(key: SystemConfigKey, stored: unknown): unknown { + const currentDefault = this.jsonConfigDefaults[key]; + return currentDefault === undefined ? stored : decodeJsonConfig(currentDefault, stored); + } + + private encodeForStorage(key: SystemConfigKey, value: unknown): unknown { + const currentDefault = this.jsonConfigDefaults[key]; + return currentDefault !== undefined && typeof value === "string" ? encodeJsonConfig(currentDefault, value) : value; + } + private isLocalKey(key: string): boolean { return STORAGE_LOCAL_KEYS.has(key); } @@ -303,9 +319,10 @@ export class SystemConfig { return entry.hasValue && entry.value !== undefined ? (entry.value as T) : this.resolveDefault(defaultValue); } if (val !== undefined) { + const decoded = this.decodeStored(key, val); entry.hasValue = true; - entry.value = val; - return val as T; + entry.value = decoded; + return decoded as T; } // 对 local key,回退读取 sync storage(兼容旧版本数据迁移) if (this.isLocalKey(key)) { @@ -335,14 +352,13 @@ export class SystemConfig { entry.version += 1; const writeVersion = entry.version; const storage = this.getStorage(key); - const persist = () => (value === undefined ? storage.remove(key) : storage.set(key, value)); - if (value === undefined) { - entry.hasValue = true; - entry.value = undefined; - } else { - entry.hasValue = true; - entry.value = value; - } + const persist = () => { + if (value === undefined) return storage.remove(key); + const stored = this.encodeForStorage(key, value); + return stored === undefined ? storage.remove(key) : storage.set(key, stored); + }; + entry.hasValue = true; + entry.value = value; // 同一配置键可能在输入框逐字编辑时被高频写入。chrome.storage 的异步回调 // 不保证多次并发写入按调用顺序完成,旧写入后完成会把新值覆盖掉;按键串行化 // 持久化可确保最终落盘值与内存中的最新快照一致,不影响不同配置键并行保存。 @@ -486,8 +502,7 @@ export class SystemConfig { setEslintConfig(v: string) { if (v === "") { - this._set("eslint_config", defaultConfig); - return; + v = defaultConfig; } JSON.parse(v); return this._set("eslint_config", v); @@ -499,8 +514,7 @@ export class SystemConfig { setEditorConfig(v: string) { if (v === "") { - this._set("editor_config", editorDefaultConfig); - return; + v = editorDefaultConfig; } JSON.parse(v); return this._set("editor_config", v); diff --git a/src/pkg/config/json_overrides.test.ts b/src/pkg/config/json_overrides.test.ts new file mode 100644 index 000000000..41be49c32 --- /dev/null +++ b/src/pkg/config/json_overrides.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import { decodeJsonConfig, deepDiff, deepMerge, encodeJsonConfig, type StoredJsonOverridesV1 } from "./json_overrides"; + +describe("deepMerge", () => { + it("deeply merges nested objects with override values winning", () => { + expect(deepMerge({ a: 1, nested: { x: 1, y: 2 } }, { nested: { y: 3 } })).toEqual({ + a: 1, + nested: { x: 1, y: 3 }, + }); + }); + + it("replaces arrays as whole values", () => { + expect(deepMerge({ rule: ["error", { allow: true }] }, { rule: ["warn"] })).toEqual({ rule: ["warn"] }); + }); + + it("lets overrides replace values when object and scalar types differ", () => { + expect(deepMerge({ a: { x: 1 } }, { a: false })).toEqual({ a: false }); + expect(deepMerge({ a: false }, { a: { x: 1 } })).toEqual({ a: { x: 1 } }); + }); + + it("preserves custom user keys", () => { + expect(deepMerge({ a: 1 }, { custom: "x" })).toEqual({ a: 1, custom: "x" }); + }); + + it("treats __proto__ as an ordinary JSON property", () => { + const overrides = JSON.parse('{"__proto__":{"polluted":true}}'); + const merged = deepMerge({}, overrides) as Record; + + expect(Object.prototype.hasOwnProperty.call(merged, "__proto__")).toBe(true); + expect(merged.__proto__).toEqual({ polluted: true }); + expect(({} as Record).polluted).toBeUndefined(); + }); +}); + +describe("deepDiff", () => { + it("returns undefined for equal values", () => { + const defaults = { a: 1, nested: { x: [1, 2], y: { z: true } } }; + expect(deepDiff(structuredClone(defaults), defaults)).toBeUndefined(); + }); + + it("keeps only sparse nested differences", () => { + expect(deepDiff({ a: 1, nested: { x: 1, y: 3 } }, { a: 1, nested: { x: 1, y: 2 } })).toEqual({ + nested: { y: 3 }, + }); + }); + + it("replaces arrays instead of diffing their indexes", () => { + expect(deepDiff({ rule: ["warn"], other: 1 }, { rule: ["error"], other: 1 })).toEqual({ rule: ["warn"] }); + }); + + it("preserves custom keys and prototype-sensitive keys", () => { + const value = JSON.parse('{"a":1,"custom":"x","__proto__":{"polluted":true}}'); + const diff = deepDiff(value, { a: 1 }) as Record; + + expect(Object.prototype.hasOwnProperty.call(diff, "__proto__")).toBe(true); + expect(diff).toEqual(JSON.parse('{"custom":"x","__proto__":{"polluted":true}}')); + expect(({} as Record).polluted).toBeUndefined(); + }); +}); + +describe("JSON config storage codec", () => { + const currentDefaultStr = JSON.stringify({ rules: { "no-debugger": ["error"], "no-eval": ["warn"] } }); + + it("encodes a modified config as an object V1 envelope", () => { + const value = JSON.stringify({ rules: { "no-debugger": ["off"], "no-eval": ["warn"] } }); + + expect(encodeJsonConfig(currentDefaultStr, value)).toEqual({ + format: "scriptcat-json-overrides", + version: 1, + overrides: { rules: { "no-debugger": ["off"] } }, + }); + }); + + it("returns undefined when the complete config equals current defaults", () => { + expect(encodeJsonConfig(currentDefaultStr, JSON.stringify(JSON.parse(currentDefaultStr)))).toBeUndefined(); + }); + + it("decodes a V1 envelope by merging overrides into current defaults", () => { + const stored: StoredJsonOverridesV1 = { + format: "scriptcat-json-overrides", + version: 1, + overrides: { rules: { "no-debugger": ["off"] } }, + }; + + expect(JSON.parse(decodeJsonConfig(currentDefaultStr, stored))).toEqual({ + rules: { "no-debugger": ["off"], "no-eval": ["warn"] }, + }); + }); + + it("uses a newer current default for fields without an override", () => { + const stored: StoredJsonOverridesV1 = { + format: "scriptcat-json-overrides", + version: 1, + overrides: { a: 9 }, + }; + + expect(JSON.parse(decodeJsonConfig(JSON.stringify({ a: 2, b: 3, newField: true }), stored))).toEqual({ + a: 9, + b: 3, + newField: true, + }); + }); + + it("rejects an unsupported ScriptCat storage version", () => { + expect(() => + decodeJsonConfig(currentDefaultStr, { + format: "scriptcat-json-overrides", + version: 2, + overrides: {}, + }) + ).toThrow("Unsupported JSON config storage version: 2"); + }); + + it("rejects malformed object storage representations", () => { + expect(() => decodeJsonConfig(currentDefaultStr, { rules: {} })).toThrow("Invalid JSON config storage envelope"); + expect(() => decodeJsonConfig(currentDefaultStr, { format: "scriptcat-json-overrides", version: 1 })).toThrow( + "Invalid JSON config storage envelope" + ); + }); + + it("preserves every field in a legacy full JSON string", () => { + const current = JSON.stringify({ a: 2, b: 1, newField: true }); + const legacy = JSON.stringify({ a: 1, b: 2 }); + + expect(JSON.parse(decodeJsonConfig(current, legacy))).toEqual({ a: 1, b: 2, newField: true }); + }); + + it("does not infer historical defaults from a legacy full JSON string", () => { + const current = JSON.stringify({ a: 2, b: 1 }); + const legacy = JSON.stringify({ a: 1, b: 2 }); + + expect(JSON.parse(decodeJsonConfig(current, legacy))).not.toEqual({ a: 2, b: 2 }); + }); + + it("adds current-only fields to a legacy full JSON string", () => { + expect(JSON.parse(decodeJsonConfig(JSON.stringify({ a: 2, newField: true }), JSON.stringify({ a: 1 })))).toEqual({ + a: 1, + newField: true, + }); + }); + + it("preserves custom legacy keys", () => { + expect(JSON.parse(decodeJsonConfig(JSON.stringify({ a: 2 }), JSON.stringify({ a: 1, custom: "x" })))).toEqual({ + a: 1, + custom: "x", + }); + }); + + it("treats format-like fields inside legacy strings as user config", () => { + const legacy = JSON.stringify({ + a: 1, + format: "scriptcat-json-overrides", + version: 1, + overrides: { a: 99 }, + }); + + expect(JSON.parse(decodeJsonConfig(JSON.stringify({ a: 2 }), legacy))).toEqual({ + a: 1, + format: "scriptcat-json-overrides", + version: 1, + overrides: { a: 99 }, + }); + }); + + it("preserves legacy arrays and scalar/object replacements", () => { + const current = JSON.stringify({ a: { x: 1 }, b: [1], c: 1 }); + const legacy = JSON.stringify({ a: false, b: [2], c: { x: 2 } }); + + expect(JSON.parse(decodeJsonConfig(current, legacy))).toEqual({ a: false, b: [2], c: { x: 2 } }); + }); +}); diff --git a/src/pkg/config/json_overrides.ts b/src/pkg/config/json_overrides.ts new file mode 100644 index 000000000..17a799662 --- /dev/null +++ b/src/pkg/config/json_overrides.ts @@ -0,0 +1,92 @@ +const JSON_CONFIG_STORAGE_FORMAT = "scriptcat-json-overrides"; +const JSON_CONFIG_STORAGE_VERSION = 1; + +export type StoredJsonOverridesV1 = { + format: typeof JSON_CONFIG_STORAGE_FORMAT; + version: typeof JSON_CONFIG_STORAGE_VERSION; + overrides: unknown; +}; + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value: Record, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function setOwn(value: Record, key: string, next: unknown): void { + Object.defineProperty(value, key, { + configurable: true, + enumerable: true, + value: next, + writable: true, + }); +} + +// 深度合并:defaults 打底,overrides 覆盖;仅递归普通对象,数组与标量整体替换 +export function deepMerge(defaults: unknown, overrides: unknown): unknown { + if (!isPlainObject(defaults) || !isPlainObject(overrides)) return overrides; + const result: Record = { ...defaults }; + for (const [key, value] of Object.entries(overrides)) { + setOwn(result, key, hasOwn(defaults, key) ? deepMerge(defaults[key], value) : value); + } + return result; +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => deepEqual(value, b[index])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const keysA = Object.keys(a); + return keysA.length === Object.keys(b).length && keysA.every((key) => hasOwn(b, key) && deepEqual(a[key], b[key])); + } + return false; +} + +// 计算稀疏差异:仅保留与 defaults 不同的部分,完全一致时返回 undefined +export function deepDiff(value: unknown, defaults: unknown): unknown { + if (deepEqual(value, defaults)) return undefined; + if (!isPlainObject(value) || !isPlainObject(defaults)) return value; + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (hasOwn(defaults, key)) { + const diff = deepDiff(val, defaults[key]); + if (diff !== undefined) setOwn(result, key, diff); + } else { + setOwn(result, key, val); + } + } + return Object.keys(result).length === 0 ? undefined : result; +} + +function decodeStoredOverrides(stored: unknown): unknown { + if (typeof stored === "string") return JSON.parse(stored); + if (!isPlainObject(stored) || !hasOwn(stored, "format") || stored.format !== JSON_CONFIG_STORAGE_FORMAT) { + throw new Error("Invalid JSON config storage envelope"); + } + if (!hasOwn(stored, "version")) throw new Error("Invalid JSON config storage envelope"); + if (stored.version !== JSON_CONFIG_STORAGE_VERSION) { + throw new Error(`Unsupported JSON config storage version: ${String(stored.version)}`); + } + if (!hasOwn(stored, "overrides")) throw new Error("Invalid JSON config storage envelope"); + return stored.overrides; +} + +export function decodeJsonConfig(currentDefaultStr: string, stored: unknown): string { + const currentDefault = JSON.parse(currentDefaultStr); + const overrides = decodeStoredOverrides(stored); + return JSON.stringify(deepMerge(currentDefault, overrides), null, 2); +} + +export function encodeJsonConfig(currentDefaultStr: string, valueStr: string): StoredJsonOverridesV1 | undefined { + const overrides = deepDiff(JSON.parse(valueStr), JSON.parse(currentDefaultStr)); + if (overrides === undefined) return undefined; + return { + format: JSON_CONFIG_STORAGE_FORMAT, + version: JSON_CONFIG_STORAGE_VERSION, + overrides, + }; +}