Skip to content
102 changes: 102 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, 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;
Expand Down Expand Up @@ -296,6 +298,106 @@ 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({
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: JSON.stringify({
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 () => {
// 模拟旧版本存储的全量 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"]);

const stored = await chrome.storage.sync.get("system_eslint_config");
expect(JSON.parse(stored["system_eslint_config"] as string)).toEqual({
format: "scriptcat-json-overrides",
version: 1,
overrides: { rules: { "no-debugger": ["off"] } },
});
});

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({
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"));
Expand Down
79 changes: 64 additions & 15 deletions src/pkg/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import defaultTypeDefinition from "@App/template/scriptcat.d.tpl";
import { toCamelCase } from "../utils/utils";
import EventEmitter from "eventemitter3";
import { STORAGE_LOCAL_KEYS } from "./consts";
import { decodeJsonConfig, diffJsonConfig, type DecodedJsonConfig } from "./json_overrides";
import { legacyJsonConfigDefaults } from "./json_config_legacy_defaults";

export const SystemConfigChange = "systemConfigChange";

Expand Down Expand Up @@ -170,6 +172,11 @@ interface SystemConfigEntry {
store?: unknown;
}

interface JsonConfigDefault {
current: string;
legacy: string;
}

type GetterFn<T extends SystemConfigKey> = (
...args: any[]
) => Promise<SystemConfigValueType<T>> | SystemConfigValueType<T>;
Expand All @@ -195,6 +202,31 @@ export class SystemConfig {
// 设备相关的配置项,使用 chrome.storage.local(不跨设备同步)
private readonly localStorage = new ChromeStorage("system", false);

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

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

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

private isLocalKey(key: string): boolean {
return STORAGE_LOCAL_KEYS.has(key);
}
Expand Down Expand Up @@ -286,6 +318,17 @@ export class SystemConfig {
return syncVal as T;
}

private migrateStored(key: SystemConfigKey, value: string | undefined): Promise<void> {
const entry = this.cacheEntry(key);
const storage = this.getStorage(key);
const persist = () => (value === undefined ? storage.remove(key) : storage.set(key, value));
const asyncOp = entry.pendingWrite ? entry.pendingWrite.then(persist, persist) : persist();
entry.pendingWrite = asyncOp;
return asyncOp.then(() => {
if (entry.pendingWrite === asyncOp) entry.pendingWrite = undefined;
});
}

private _get<T extends string | number | boolean | object>(
key: SystemConfigKey,
defaultValue: WithAsyncValue<Exclude<T, undefined>>
Expand All @@ -297,14 +340,23 @@ export class SystemConfig {
}
const version = entry.version;
const storage = this.getStorage(key);
return storage.get(key).then((val) => {
return storage.get(key).then(async (val) => {
if (version !== entry.version) {
return entry.hasValue && entry.value !== undefined ? (entry.value as T) : this.resolveDefault<T>(defaultValue);
}
if (val !== undefined) {
const decoded = this.decodeStored(key, val);
if ("migration" in decoded && decoded.migration) {
await this.migrateStored(key, decoded.migration.value);
if (version !== entry.version) {
return entry.hasValue && entry.value !== undefined
? (entry.value as T)
: this.resolveDefault<T>(defaultValue);
}
}
entry.hasValue = true;
entry.value = val;
return val as T;
entry.value = decoded.value;
return decoded.value as T;
}
// 对 local key,回退读取 sync storage(兼容旧版本数据迁移)
if (this.isLocalKey(key)) {
Expand Down Expand Up @@ -334,14 +386,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 的异步回调
// 不保证多次并发写入按调用顺序完成,旧写入后完成会把新值覆盖掉;按键串行化
// 持久化可确保最终落盘值与内存中的最新快照一致,不影响不同配置键并行保存。
Expand Down Expand Up @@ -485,8 +536,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 @@ -498,8 +548,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
Loading
Loading