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
68 changes: 67 additions & 1 deletion src/app/service/service_worker/synchronize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();

Expand Down Expand Up @@ -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,
Expand Down
138 changes: 138 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 @@ -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"));
Expand Down
42 changes: 28 additions & 14 deletions src/pkg/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

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

private readonly jsonConfigDefaults: Partial<Record<SystemConfigKey, string>> = {
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);
}
Expand Down Expand Up @@ -303,9 +319,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 @@ -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 的异步回调
// 不保证多次并发写入按调用顺序完成,旧写入后完成会把新值覆盖掉;按键串行化
// 持久化可确保最终落盘值与内存中的最新快照一致,不影响不同配置键并行保存。
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading