Skip to content
Open
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
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 @@ -234,6 +236,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 @@ -157,6 +159,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 @@ -182,6 +189,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 @@ -273,6 +305,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 @@ -284,14 +327,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 @@ -321,14 +373,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 @@ -464,8 +515,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 @@ -477,8 +527,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