Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions src/pkg/config/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it, beforeEach, 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;
Expand Down Expand Up @@ -185,6 +187,89 @@ 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(JSON.parse(syncData["system_eslint_config"] as string)).toEqual({
rules: { "no-debugger": ["warn"], "custom/added-rule": ["error"] },
});
});
});

it("读取时应将存储的差异合并到最新默认配置", async () => {
await chrome.storage.sync.set({
system_eslint_config: JSON.stringify({ 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 () => {
// 模拟旧版本存储的全量 JSON:缺少后续新增的默认规则,且用户改过其中一条
const legacy = JSON.parse(eslintDefaultConfig);
legacy.rules["no-debugger"] = ["off"];
delete legacy.rules["no-empty"];
await chrome.storage.sync.set({ system_eslint_config: JSON.stringify(legacy) });

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("保存与默认配置一致的内容时应清除存储", 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(JSON.parse(syncData["system_editor_config"] as string)).toEqual({ 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"));
Expand Down
40 changes: 33 additions & 7 deletions src/pkg/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import defaultTypeDefinition from "@App/template/scriptcat.d.tpl";
import { toCamelCase } from "../utils/utils";
import EventEmitter from "eventemitter3";
import { STORAGE_LOCAL_KEYS } from "./consts";
import { mergeJsonConfig, diffJsonConfig } from "./json_overrides";

export const SystemConfigChange = "systemConfigChange";

Expand Down Expand Up @@ -164,6 +165,31 @@ export class SystemConfig {
// 设备相关的配置项,使用 chrome.storage.local(不跨设备同步)
private readonly localStorage = new ChromeStorage("system", false);

// JSON 配置项的默认配置:storage 只保存与默认配置的稀疏差异,
// 缓存与消息广播中始终是合并后的完整配置(#1517)
private readonly jsonConfigDefaults: Partial<Record<SystemConfigKey, string>> = {
eslint_config: defaultConfig,
editor_config: editorDefaultConfig,
};

// 读取解码:将存储的用户差异合并到最新默认配置
private decodeStored(key: SystemConfigKey, stored: unknown): unknown {
const defaultStr = this.jsonConfigDefaults[key];
if (defaultStr !== undefined && typeof stored === "string") {
return mergeJsonConfig(defaultStr, stored);
}
return stored;
}

// 写入编码:只保存与默认配置的差异,完全一致时返回 undefined(清除存储)
private encodeForStorage(key: SystemConfigKey, value: unknown): unknown {
const defaultStr = this.jsonConfigDefaults[key];
if (defaultStr !== undefined && typeof value === "string") {
return diffJsonConfig(defaultStr, value);
}
return value;
}

private isLocalKey(key: string): boolean {
return STORAGE_LOCAL_KEYS.has(key);
}
Expand Down Expand Up @@ -271,9 +297,10 @@ export class SystemConfig {
return entry.hasValue && entry.value !== undefined ? (entry.value as T) : this.resolveDefault<T>(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)) {
Expand Down Expand Up @@ -310,7 +337,8 @@ export class SystemConfig {
} else {
entry.hasValue = true;
entry.value = value;
asyncOp = storage.set(key, value);
const stored = this.encodeForStorage(key, value);
asyncOp = stored === undefined ? storage.remove(key) : storage.set(key, stored);
}
asyncOp.then(() => {
// 发送消息通知更新
Expand Down Expand Up @@ -439,8 +467,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);
Expand All @@ -452,8 +479,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);
Expand Down
70 changes: 70 additions & 0 deletions src/pkg/config/json_overrides.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { deepMerge, deepDiff, mergeJsonConfig, diffJsonConfig } from "./json_overrides";

describe("deepMerge 深度合并", () => {
it("应以覆盖值优先合并嵌套对象", () => {
const defaults = { a: 1, nested: { x: 1, y: 2 } };
const overrides = { nested: { y: 3 } };
expect(deepMerge(defaults, overrides)).toEqual({ a: 1, nested: { x: 1, y: 3 } });
});

it("数组应整体替换而非合并", () => {
const defaults = { rule: ["error", { allow: true }] };
const overrides = { rule: ["warn"] };
expect(deepMerge(defaults, overrides)).toEqual({ rule: ["warn"] });
});

it("覆盖配置独有的键应保留", () => {
expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 });
});

it("类型不同时应使用覆盖值", () => {
expect(deepMerge({ a: { x: 1 } }, { a: false })).toEqual({ a: false });
});
});

describe("deepDiff 稀疏差异", () => {
it("与默认值一致时应返回 undefined", () => {
const defaults = { a: 1, nested: { x: [1, 2], y: { z: true } } };
expect(deepDiff(structuredClone(defaults), defaults)).toBeUndefined();
});

it("应只保留与默认值不同的部分", () => {
const defaults = { a: 1, nested: { x: 1, y: 2 } };
const value = { a: 1, nested: { x: 1, y: 3 } };
expect(deepDiff(value, defaults)).toEqual({ nested: { y: 3 } });
});

it("深度相等的数组应被去除", () => {
const defaults = { rule: ["error", { allow: true }], other: 1 };
const value = { rule: ["error", { allow: true }], other: 2 };
expect(deepDiff(value, defaults)).toEqual({ other: 2 });
});

it("用户新增的键应保留", () => {
expect(deepDiff({ a: 1, custom: "x" }, { a: 1 })).toEqual({ custom: "x" });
});
});

describe("JSON 配置字符串编解码", () => {
const defaultStr = JSON.stringify({ rules: { "no-debugger": ["error"], "no-eval": ["warn"] } });

it("diffJsonConfig 应只保留差异,无差异时返回 undefined", () => {
expect(diffJsonConfig(defaultStr, defaultStr)).toBeUndefined();
const user = JSON.stringify({ rules: { "no-debugger": ["warn"], "no-eval": ["warn"] } });
expect(JSON.parse(diffJsonConfig(defaultStr, user)!)).toEqual({ rules: { "no-debugger": ["warn"] } });
});

it("mergeJsonConfig 应将用户差异合并到最新默认配置", () => {
const stored = JSON.stringify({ rules: { "no-debugger": ["warn"] } });
expect(JSON.parse(mergeJsonConfig(defaultStr, stored))).toEqual({
rules: { "no-debugger": ["warn"], "no-eval": ["warn"] },
});
});

it("合并 diff 结果应还原用户配置", () => {
const user = JSON.stringify({ rules: { "no-debugger": ["off"], "custom/rule": ["error"], "no-eval": ["warn"] } });
const diff = diffJsonConfig(defaultStr, user)!;
expect(JSON.parse(mergeJsonConfig(defaultStr, diff))).toEqual(JSON.parse(user));
});
});
57 changes: 57 additions & 0 deletions src/pkg/config/json_overrides.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// JSON 配置项的「默认配置 + 用户差异」存储机制(#1517)
// storage 中只保存用户与默认配置不同的部分,读取时与最新默认配置合并,
// 这样升级扩展带来的默认配置变化能自动生效,同时保留用户改动(用户值优先)。

function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

// 深度合并:defaults 打底,overrides 覆盖;仅递归普通对象,数组与标量整体替换
export function deepMerge(defaults: unknown, overrides: unknown): unknown {
if (!isPlainObject(defaults) || !isPlainObject(overrides)) return overrides;
const result: Record<string, unknown> = { ...defaults };
for (const [key, value] of Object.entries(overrides)) {
result[key] = key in defaults ? 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((v, i) => deepEqual(v, b[i]));
}
if (isPlainObject(a) && isPlainObject(b)) {
const keysA = Object.keys(a);
return keysA.length === Object.keys(b).length && keysA.every((k) => k in b && deepEqual(a[k], b[k]));
}
return false;
}

// 计算稀疏差异:仅保留与 defaults 不同的部分,完全一致时返回 undefined
// 注意:value 中缺失的默认键不会被记录,语义为「恢复默认值」
export function deepDiff(value: unknown, defaults: unknown): unknown {
if (deepEqual(value, defaults)) return undefined;
if (!isPlainObject(value) || !isPlainObject(defaults)) return value;
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
if (key in defaults) {
const diff = deepDiff(val, defaults[key]);
if (diff !== undefined) result[key] = diff;
} else {
result[key] = val;
}
}
return Object.keys(result).length === 0 ? undefined : result;
}

// 读取解码:将存储的用户差异合并到最新默认配置,返回完整配置字符串
export function mergeJsonConfig(defaultStr: string, storedStr: string): string {
return JSON.stringify(deepMerge(JSON.parse(defaultStr), JSON.parse(storedStr)), null, 2);
}

// 写入编码:只保留与默认配置的差异;与默认配置完全一致时返回 undefined(清除存储)
export function diffJsonConfig(defaultStr: string, valueStr: string): string | undefined {
const diff = deepDiff(JSON.parse(valueStr), JSON.parse(defaultStr));
return diff === undefined ? undefined : JSON.stringify(diff);
}
Loading