pi-shepherd 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +136 -0
- package/README.md +136 -0
- package/index.ts +229 -0
- package/node_modules/@pi-atelier/shared-utils/README.en.md +182 -0
- package/node_modules/@pi-atelier/shared-utils/README.md +182 -0
- package/node_modules/@pi-atelier/shared-utils/package.json +51 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/agents.test.ts +120 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/ephemeral.test.ts +100 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/file-lock.test.ts +152 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/filter-match.test.ts +187 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/memory-parser.test.ts +170 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/paths.test.ts +126 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config-edge.test.ts +138 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-config.test.ts +257 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools-mcp.test.ts +189 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/project-tools.test.ts +204 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-advanced.test.ts +269 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup-array.test.ts +267 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-backup.test.ts +520 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-read.test.ts +116 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/settings-write.test.ts +119 -0
- package/node_modules/@pi-atelier/shared-utils/src/__tests__/tool-output.test.ts +145 -0
- package/node_modules/@pi-atelier/shared-utils/src/agents.ts +39 -0
- package/node_modules/@pi-atelier/shared-utils/src/ephemeral.ts +42 -0
- package/node_modules/@pi-atelier/shared-utils/src/file-lock.ts +62 -0
- package/node_modules/@pi-atelier/shared-utils/src/filter-match.ts +100 -0
- package/node_modules/@pi-atelier/shared-utils/src/index.ts +71 -0
- package/node_modules/@pi-atelier/shared-utils/src/memory-parser.ts +96 -0
- package/node_modules/@pi-atelier/shared-utils/src/paths.ts +23 -0
- package/node_modules/@pi-atelier/shared-utils/src/project-config.ts +241 -0
- package/node_modules/@pi-atelier/shared-utils/src/project-tools.ts +191 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-array.ts +73 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-rollback.ts +104 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup-utils.ts +75 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings-backup.ts +172 -0
- package/node_modules/@pi-atelier/shared-utils/src/settings.ts +104 -0
- package/node_modules/@pi-atelier/shared-utils/src/tool-output.ts +149 -0
- package/node_modules/@pi-atelier/shared-utils/tsconfig.json +9 -0
- package/node_modules/@pi-atelier/shared-utils/vitest.config.ts +24 -0
- package/package.json +49 -0
- package/rules.json +516 -0
- package/shepherd/ephemeral-shared.ts +14 -0
- package/shepherd/ephemeral.ts +52 -0
- package/shepherd/index.ts +39 -0
- package/shepherd/line-count.ts +86 -0
- package/shepherd/rules-editor.ts +135 -0
- package/shepherd/rules-tool.ts +99 -0
- package/shepherd/rules-validate.ts +44 -0
- package/shepherd/rules.ts +283 -0
- package/shepherd/state-tracker.ts +119 -0
- package/shepherd/tool-event-types.ts +31 -0
- package/shepherd/tool-hooks.ts +176 -0
- package/shepherd/worktree-check.ts +130 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +13 -0
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
// 测试前先 mock paths 模块的 SETTINGS_PATH
|
|
7
|
+
const TEST_DIR = join(tmpdir(), `pi-settings-backup-test-${process.pid}`);
|
|
8
|
+
const TEST_SETTINGS = join(TEST_DIR, "settings.json");
|
|
9
|
+
const TEST_BACKUP_DIR = join(TEST_DIR, "settings-backup");
|
|
10
|
+
|
|
11
|
+
const { patchSettingsSectionWithBackup, rollbackSettings, listBackups } = await import(
|
|
12
|
+
"../settings-backup"
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// ── 测试辅助 ────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function writeTestSettings(content: Record<string, any>) {
|
|
18
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
19
|
+
writeFileSync(TEST_SETTINGS, JSON.stringify(content, null, 2));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function readTestSettings(): Record<string, any> {
|
|
23
|
+
if (!existsSync(TEST_SETTINGS)) return {};
|
|
24
|
+
return JSON.parse(readFileSync(TEST_SETTINGS, "utf-8"));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function cleanupTestDir() {
|
|
28
|
+
if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function countBackups(): number {
|
|
32
|
+
if (!existsSync(TEST_BACKUP_DIR)) return 0;
|
|
33
|
+
return readdirSync(TEST_BACKUP_DIR).filter((f) => f.endsWith(".json")).length;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── 测试 ─────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
describe("settings-backup", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
cleanupTestDir();
|
|
41
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
cleanupTestDir();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ── patchSettingsSectionWithBackup ──────────────────
|
|
48
|
+
|
|
49
|
+
describe("patchSettingsSectionWithBackup", () => {
|
|
50
|
+
it("修改后自动创建备份", () => {
|
|
51
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
52
|
+
|
|
53
|
+
const result = patchSettingsSectionWithBackup(
|
|
54
|
+
"context",
|
|
55
|
+
{ distillThreshold: 8000 },
|
|
56
|
+
{ distillThreshold: 5000, agingThreshold: 20 },
|
|
57
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(result.config.distillThreshold).toBe(8000);
|
|
61
|
+
expect(result.config.agingThreshold).toBe(20); // defaults 填充
|
|
62
|
+
expect(existsSync(TEST_BACKUP_DIR)).toBe(true);
|
|
63
|
+
expect(countBackups()).toBe(1);
|
|
64
|
+
|
|
65
|
+
// 备份内容应该是修改前的值
|
|
66
|
+
const backupFiles = readdirSync(TEST_BACKUP_DIR).filter((f) => f.endsWith(".json"));
|
|
67
|
+
const backupContent = JSON.parse(readFileSync(join(TEST_BACKUP_DIR, backupFiles[0]), "utf-8"));
|
|
68
|
+
expect(backupContent.context.distillThreshold).toBe(5000);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("多次修改产生多个备份", async () => {
|
|
72
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
73
|
+
|
|
74
|
+
patchSettingsSectionWithBackup(
|
|
75
|
+
"context",
|
|
76
|
+
{ distillThreshold: 6000 },
|
|
77
|
+
{ distillThreshold: 5000 },
|
|
78
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
79
|
+
);
|
|
80
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
81
|
+
patchSettingsSectionWithBackup(
|
|
82
|
+
"context",
|
|
83
|
+
{ distillThreshold: 7000 },
|
|
84
|
+
{ distillThreshold: 5000 },
|
|
85
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(countBackups()).toBe(2);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("超过 maxBackups 时清理旧备份", async () => {
|
|
92
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < 5; i++) {
|
|
95
|
+
patchSettingsSectionWithBackup(
|
|
96
|
+
"context",
|
|
97
|
+
{ distillThreshold: 5000 + i },
|
|
98
|
+
{ distillThreshold: 5000 },
|
|
99
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, maxBackups: 3 },
|
|
100
|
+
);
|
|
101
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
expect(countBackups()).toBe(3);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("类型校验不通过时返回错误但不写入", () => {
|
|
108
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
109
|
+
|
|
110
|
+
const result = patchSettingsSectionWithBackup(
|
|
111
|
+
"context",
|
|
112
|
+
{ distillThreshold: "wrong-type" as any },
|
|
113
|
+
{ distillThreshold: 5000 },
|
|
114
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, validate: true },
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
118
|
+
expect(result.errors[0].key).toBe("distillThreshold");
|
|
119
|
+
// settings.json 不应该被修改
|
|
120
|
+
expect(readTestSettings().context.distillThreshold).toBe(5000);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("validate: false 跳过类型校验", () => {
|
|
124
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
125
|
+
|
|
126
|
+
const result = patchSettingsSectionWithBackup(
|
|
127
|
+
"context",
|
|
128
|
+
{ distillThreshold: "skip-validation" as any },
|
|
129
|
+
{ distillThreshold: 5000 },
|
|
130
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, validate: false },
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
expect(result.errors).toEqual([]);
|
|
134
|
+
expect(readTestSettings().context.distillThreshold).toBe("skip-validation");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("备份目录不存在时自动创建", () => {
|
|
138
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
139
|
+
expect(existsSync(TEST_BACKUP_DIR)).toBe(false);
|
|
140
|
+
|
|
141
|
+
patchSettingsSectionWithBackup(
|
|
142
|
+
"context",
|
|
143
|
+
{ distillThreshold: 8000 },
|
|
144
|
+
{ distillThreshold: 5000 },
|
|
145
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
expect(existsSync(TEST_BACKUP_DIR)).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("备份文件名包含时间戳", () => {
|
|
152
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
153
|
+
|
|
154
|
+
patchSettingsSectionWithBackup(
|
|
155
|
+
"context",
|
|
156
|
+
{ distillThreshold: 8000 },
|
|
157
|
+
{ distillThreshold: 5000 },
|
|
158
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const files = readdirSync(TEST_BACKUP_DIR).filter((f) => f.endsWith(".json"));
|
|
162
|
+
expect(files.length).toBe(1);
|
|
163
|
+
expect(files[0]).toMatch(/^settings\.\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── 边缘情况 ─────────────────────────────
|
|
167
|
+
|
|
168
|
+
it("settings.json 不存在时创建新文件", () => {
|
|
169
|
+
// 不写 settings.json,让它不存在
|
|
170
|
+
expect(existsSync(TEST_SETTINGS)).toBe(false);
|
|
171
|
+
|
|
172
|
+
const result = patchSettingsSectionWithBackup(
|
|
173
|
+
"context",
|
|
174
|
+
{ distillThreshold: 8000 },
|
|
175
|
+
{ distillThreshold: 5000 },
|
|
176
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
expect(result.config.distillThreshold).toBe(8000);
|
|
180
|
+
expect(existsSync(TEST_SETTINGS)).toBe(true);
|
|
181
|
+
expect(readTestSettings().context.distillThreshold).toBe(8000);
|
|
182
|
+
// 不存在时不创建备份(没有东西可备份)
|
|
183
|
+
expect(countBackups()).toBe(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("settings.json 内容是非法 JSON 时当作空对象处理", () => {
|
|
187
|
+
mkdirSync(TEST_DIR, { recursive: true });
|
|
188
|
+
writeFileSync(TEST_SETTINGS, "{ broken json !!!");
|
|
189
|
+
|
|
190
|
+
const result = patchSettingsSectionWithBackup(
|
|
191
|
+
"context",
|
|
192
|
+
{ distillThreshold: 8000 },
|
|
193
|
+
{ distillThreshold: 5000 },
|
|
194
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// 不崩溃,当作空对象处理
|
|
198
|
+
expect(result.config.distillThreshold).toBe(8000);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("section 不存在时新增 section", () => {
|
|
202
|
+
writeTestSettings({ other: { foo: 1 } });
|
|
203
|
+
|
|
204
|
+
const result = patchSettingsSectionWithBackup(
|
|
205
|
+
"context",
|
|
206
|
+
{ distillThreshold: 8000 },
|
|
207
|
+
{ distillThreshold: 5000 },
|
|
208
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
expect(result.config.distillThreshold).toBe(8000);
|
|
212
|
+
const settings = readTestSettings();
|
|
213
|
+
expect(settings.context.distillThreshold).toBe(8000);
|
|
214
|
+
expect(settings.other.foo).toBe(1); // 其他 section 不受影响
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("patch 值为 undefined 时跳过该 key", () => {
|
|
218
|
+
writeTestSettings({ context: { distillThreshold: 5000, agingThreshold: 20 } });
|
|
219
|
+
|
|
220
|
+
patchSettingsSectionWithBackup(
|
|
221
|
+
"context",
|
|
222
|
+
{ distillThreshold: undefined },
|
|
223
|
+
{ distillThreshold: 5000, agingThreshold: 20 },
|
|
224
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// undefined 不应该覆盖现有值
|
|
228
|
+
const settings = readTestSettings();
|
|
229
|
+
expect(settings.context.distillThreshold).toBe(5000);
|
|
230
|
+
expect(settings.context.agingThreshold).toBe(20);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("patch 值为 null 时被类型校验拦截(null !== number)", () => {
|
|
234
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
235
|
+
|
|
236
|
+
const result = patchSettingsSectionWithBackup(
|
|
237
|
+
"context",
|
|
238
|
+
{ distillThreshold: null as any },
|
|
239
|
+
{ distillThreshold: 5000 },
|
|
240
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
// null 被校验拦截 — 类型不匹配(null vs number)
|
|
244
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
245
|
+
expect(readTestSettings().context.distillThreshold).toBe(5000);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("patch 值为 null 且 validate:false 时正常写入 null", () => {
|
|
249
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
250
|
+
|
|
251
|
+
patchSettingsSectionWithBackup(
|
|
252
|
+
"context",
|
|
253
|
+
{ distillThreshold: null as any },
|
|
254
|
+
{ distillThreshold: 5000 },
|
|
255
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, validate: false },
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
expect(readTestSettings().context.distillThreshold).toBeNull();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("backup: false 跳过备份创建", () => {
|
|
262
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
263
|
+
|
|
264
|
+
patchSettingsSectionWithBackup(
|
|
265
|
+
"context",
|
|
266
|
+
{ distillThreshold: 8000 },
|
|
267
|
+
{ distillThreshold: 5000 },
|
|
268
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(countBackups()).toBe(0);
|
|
272
|
+
expect(readTestSettings().context.distillThreshold).toBe(8000);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("空 patch 不修改配置值但正常写入", () => {
|
|
276
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
277
|
+
|
|
278
|
+
const result = patchSettingsSectionWithBackup(
|
|
279
|
+
"context",
|
|
280
|
+
{},
|
|
281
|
+
{ distillThreshold: 5000 },
|
|
282
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
expect(result.config.distillThreshold).toBe(5000);
|
|
286
|
+
expect(readTestSettings().context.distillThreshold).toBe(5000);
|
|
287
|
+
// 空修改也会备份(安全起见)
|
|
288
|
+
expect(countBackups()).toBe(1);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("defaults 中有嵌套对象时不深度校验", () => {
|
|
292
|
+
writeTestSettings({});
|
|
293
|
+
|
|
294
|
+
const result = patchSettingsSectionWithBackup(
|
|
295
|
+
"mcp",
|
|
296
|
+
{ disabled: ["server-a"] },
|
|
297
|
+
{ disabled: [] },
|
|
298
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
expect(result.errors).toEqual([]);
|
|
302
|
+
expect(result.config.disabled).toEqual(["server-a"]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("保留其他 section 不受影响", () => {
|
|
306
|
+
writeTestSettings({
|
|
307
|
+
other: { foo: 1 },
|
|
308
|
+
context: { distillThreshold: 5000 },
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
patchSettingsSectionWithBackup(
|
|
312
|
+
"context",
|
|
313
|
+
{ distillThreshold: 8000 },
|
|
314
|
+
{ distillThreshold: 5000 },
|
|
315
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
expect(readTestSettings().other.foo).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// ── rollbackSettings ──────────────────────────────
|
|
323
|
+
|
|
324
|
+
describe("rollbackSettings", () => {
|
|
325
|
+
it("恢复到最近一次备份", () => {
|
|
326
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
327
|
+
|
|
328
|
+
patchSettingsSectionWithBackup(
|
|
329
|
+
"context",
|
|
330
|
+
{ distillThreshold: 8000 },
|
|
331
|
+
{ distillThreshold: 5000 },
|
|
332
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
333
|
+
);
|
|
334
|
+
expect(readTestSettings().context.distillThreshold).toBe(8000);
|
|
335
|
+
|
|
336
|
+
rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
|
|
337
|
+
expect(readTestSettings().context.distillThreshold).toBe(5000);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it("无备份时抛错", () => {
|
|
341
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
342
|
+
expect(() => rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR })).toThrow(
|
|
343
|
+
/无可用备份/,
|
|
344
|
+
);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("回滚后删除已恢复的备份", () => {
|
|
348
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
349
|
+
|
|
350
|
+
patchSettingsSectionWithBackup(
|
|
351
|
+
"context",
|
|
352
|
+
{ distillThreshold: 8000 },
|
|
353
|
+
{ distillThreshold: 5000 },
|
|
354
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
355
|
+
);
|
|
356
|
+
expect(countBackups()).toBe(1);
|
|
357
|
+
|
|
358
|
+
rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
|
|
359
|
+
expect(countBackups()).toBe(0);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("连续两次回滚(第二次无备份应抛错)", async () => {
|
|
363
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
364
|
+
|
|
365
|
+
patchSettingsSectionWithBackup(
|
|
366
|
+
"context",
|
|
367
|
+
{ distillThreshold: 6000 },
|
|
368
|
+
{ distillThreshold: 5000 },
|
|
369
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
370
|
+
);
|
|
371
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
372
|
+
patchSettingsSectionWithBackup(
|
|
373
|
+
"context",
|
|
374
|
+
{ distillThreshold: 7000 },
|
|
375
|
+
{ distillThreshold: 5000 },
|
|
376
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// 第一次回滚:7000 → 6000
|
|
380
|
+
rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
|
|
381
|
+
expect(readTestSettings().context.distillThreshold).toBe(6000);
|
|
382
|
+
expect(countBackups()).toBe(1);
|
|
383
|
+
|
|
384
|
+
// 第二次回滚:6000 → 5000
|
|
385
|
+
rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
|
|
386
|
+
expect(readTestSettings().context.distillThreshold).toBe(5000);
|
|
387
|
+
expect(countBackups()).toBe(0);
|
|
388
|
+
|
|
389
|
+
// 第三次回滚:无备份,抛错
|
|
390
|
+
expect(() => rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR })).toThrow();
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("回滚不影响备份目录外的文件", () => {
|
|
394
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
395
|
+
// 在备份目录外创建一个文件
|
|
396
|
+
const extraFile = join(TEST_DIR, "other-file.json");
|
|
397
|
+
writeFileSync(extraFile, '{"test": true}');
|
|
398
|
+
|
|
399
|
+
patchSettingsSectionWithBackup(
|
|
400
|
+
"context",
|
|
401
|
+
{ distillThreshold: 8000 },
|
|
402
|
+
{ distillThreshold: 5000 },
|
|
403
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
404
|
+
);
|
|
405
|
+
|
|
406
|
+
rollbackSettings({ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR });
|
|
407
|
+
|
|
408
|
+
expect(existsSync(extraFile)).toBe(true);
|
|
409
|
+
expect(JSON.parse(readFileSync(extraFile, "utf-8")).test).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// ── listBackups ──────────────────────────────────
|
|
414
|
+
|
|
415
|
+
describe("listBackups", () => {
|
|
416
|
+
it("返回备份列表(按时间倒序)", async () => {
|
|
417
|
+
writeTestSettings({ context: { distillThreshold: 5000 } });
|
|
418
|
+
|
|
419
|
+
patchSettingsSectionWithBackup(
|
|
420
|
+
"context",
|
|
421
|
+
{ distillThreshold: 6000 },
|
|
422
|
+
{ distillThreshold: 5000 },
|
|
423
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
424
|
+
);
|
|
425
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
426
|
+
patchSettingsSectionWithBackup(
|
|
427
|
+
"context",
|
|
428
|
+
{ distillThreshold: 7000 },
|
|
429
|
+
{ distillThreshold: 5000 },
|
|
430
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR },
|
|
431
|
+
);
|
|
432
|
+
|
|
433
|
+
const backups = listBackups({ backupDir: TEST_BACKUP_DIR });
|
|
434
|
+
expect(backups.length).toBe(2);
|
|
435
|
+
// 最新的在前
|
|
436
|
+
expect(backups[0].timestamp > backups[1].timestamp).toBe(true);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("无备份时返回空数组", () => {
|
|
440
|
+
const backups = listBackups({ backupDir: TEST_BACKUP_DIR });
|
|
441
|
+
expect(backups).toEqual([]);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("备份目录不存在时返回空数组", () => {
|
|
445
|
+
const backups = listBackups({ backupDir: "/nonexistent/path" });
|
|
446
|
+
expect(backups).toEqual([]);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe("文件锁", () => {
|
|
451
|
+
it("加锁期间其他操作不会被覆盖", () => {
|
|
452
|
+
cleanupTestDir();
|
|
453
|
+
writeTestSettings({ context: { threshold: 100 }, otherSection: { keep: true } });
|
|
454
|
+
|
|
455
|
+
// 模拟:在 patch 加锁期间,另一个进程写入了新字段
|
|
456
|
+
// 这里通过 patch 两次验证加锁读-写是原子的
|
|
457
|
+
const result1 = patchSettingsSectionWithBackup(
|
|
458
|
+
"context",
|
|
459
|
+
{ threshold: 200 },
|
|
460
|
+
{ threshold: 100 },
|
|
461
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
|
|
462
|
+
);
|
|
463
|
+
const result2 = patchSettingsSectionWithBackup(
|
|
464
|
+
"shepherd",
|
|
465
|
+
{ maxWarnings: 3 },
|
|
466
|
+
{ maxWarnings: 5 },
|
|
467
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
// 两次 patch 都成功
|
|
471
|
+
expect(result1.config.threshold).toBe(200);
|
|
472
|
+
expect(result2.config.maxWarnings).toBe(3);
|
|
473
|
+
|
|
474
|
+
// 文件中两个 section 都保留
|
|
475
|
+
const finalSettings = readTestSettings();
|
|
476
|
+
expect(finalSettings.context.threshold).toBe(200);
|
|
477
|
+
expect(finalSettings.shepherd.maxWarnings).toBe(3);
|
|
478
|
+
expect(finalSettings.otherSection.keep).toBe(true);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("锁文件在操作后会被清理", () => {
|
|
482
|
+
cleanupTestDir();
|
|
483
|
+
writeTestSettings({ context: { threshold: 100 } });
|
|
484
|
+
|
|
485
|
+
patchSettingsSectionWithBackup(
|
|
486
|
+
"context",
|
|
487
|
+
{ threshold: 200 },
|
|
488
|
+
{ threshold: 100 },
|
|
489
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
// 锁目录不应残留
|
|
493
|
+
expect(existsSync(`${TEST_SETTINGS}.lock`)).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it("过时锁会被自动清理", () => {
|
|
497
|
+
cleanupTestDir();
|
|
498
|
+
writeTestSettings({ context: { threshold: 100 } });
|
|
499
|
+
|
|
500
|
+
// 创建一个过时锁(mtime 设为 20 秒前)
|
|
501
|
+
const lockDir = `${TEST_SETTINGS}.lock`;
|
|
502
|
+
mkdirSync(lockDir);
|
|
503
|
+
// 手动修改 mtime 为 20 秒前
|
|
504
|
+
const { utimesSync } = require("node:fs");
|
|
505
|
+
const oldTime = (Date.now() - 20_000) / 1000;
|
|
506
|
+
utimesSync(lockDir, oldTime, oldTime);
|
|
507
|
+
|
|
508
|
+
// patch 应该能清理过时锁并正常执行
|
|
509
|
+
const result = patchSettingsSectionWithBackup(
|
|
510
|
+
"context",
|
|
511
|
+
{ threshold: 300 },
|
|
512
|
+
{ threshold: 100 },
|
|
513
|
+
{ settingsPath: TEST_SETTINGS, backupDir: TEST_BACKUP_DIR, backup: false },
|
|
514
|
+
);
|
|
515
|
+
|
|
516
|
+
expect(result.config.threshold).toBe(300);
|
|
517
|
+
expect(existsSync(lockDir)).toBe(false);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings.ts 读取操作测试(getSettingsSection, getSettingsValue)
|
|
3
|
+
*
|
|
4
|
+
* 使用有状态的 fs mock:writeFileSync 更新存储区,readFileSync 读取最新内容。
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
7
|
+
|
|
8
|
+
const store = vi.hoisted(() => {
|
|
9
|
+
let content = "";
|
|
10
|
+
return { get: () => content, set: (v: string) => { content = v; } };
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
const mockOs = vi.hoisted(() => ({
|
|
14
|
+
homedir: vi.fn().mockReturnValue("/fake-home"),
|
|
15
|
+
}));
|
|
16
|
+
vi.mock("node:os", () => mockOs);
|
|
17
|
+
|
|
18
|
+
const mockFs = vi.hoisted(() => ({
|
|
19
|
+
existsSync: vi.fn(),
|
|
20
|
+
readFileSync: vi.fn(() => store.get()),
|
|
21
|
+
writeFileSync: vi.fn((_path: string, data: string) => store.set(data)),
|
|
22
|
+
}));
|
|
23
|
+
vi.mock("node:fs", () => mockFs);
|
|
24
|
+
|
|
25
|
+
import { getSettingsSection, getSettingsValue } from "../settings";
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
store.set("");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function mockSettings(content: Record<string, unknown>): void {
|
|
33
|
+
store.set(JSON.stringify(content));
|
|
34
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── getSettingsSection ──────────────────────────────────
|
|
38
|
+
|
|
39
|
+
describe("getSettingsSection", () => {
|
|
40
|
+
it("returns defaults when settings.json does not exist", () => {
|
|
41
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
42
|
+
const result = getSettingsSection("context", { threshold: 5000, active: true });
|
|
43
|
+
expect(result).toEqual({ threshold: 5000, active: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("returns defaults when settings.json is empty object", () => {
|
|
47
|
+
mockSettings({});
|
|
48
|
+
const result = getSettingsSection("context", { threshold: 5000 });
|
|
49
|
+
expect(result).toEqual({ threshold: 5000 });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("merges stored values over defaults", () => {
|
|
53
|
+
mockSettings({ context: { threshold: 8000 } });
|
|
54
|
+
const result = getSettingsSection("context", { threshold: 5000, active: true });
|
|
55
|
+
expect(result).toEqual({ threshold: 8000, active: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("ignores stored keys not in defaults", () => {
|
|
59
|
+
mockSettings({ context: { threshold: 8000, junk: "ignore" } });
|
|
60
|
+
const result = getSettingsSection("context", { threshold: 5000 });
|
|
61
|
+
expect(result).toEqual({ threshold: 8000 });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns defaults when section exists but is empty", () => {
|
|
65
|
+
mockSettings({ context: {} });
|
|
66
|
+
const result = getSettingsSection("context", { threshold: 5000, active: true });
|
|
67
|
+
expect(result).toEqual({ threshold: 5000, active: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns defaults when settings.json is invalid JSON", () => {
|
|
71
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
72
|
+
store.set("{ invalid json ");
|
|
73
|
+
const result = getSettingsSection("context", { threshold: 5000 });
|
|
74
|
+
expect(result).toEqual({ threshold: 5000 });
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("handles section not present in settings", () => {
|
|
78
|
+
mockSettings({ otherSection: { key: 1 } });
|
|
79
|
+
const result = getSettingsSection("context", { threshold: 5000 });
|
|
80
|
+
expect(result).toEqual({ threshold: 5000 });
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── getSettingsValue ────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
describe("getSettingsValue", () => {
|
|
87
|
+
it("returns value at simple path", () => {
|
|
88
|
+
mockSettings({ recording: { enabled: true } });
|
|
89
|
+
expect(getSettingsValue("recording.enabled", false)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns fallback when path does not exist", () => {
|
|
93
|
+
mockSettings({});
|
|
94
|
+
expect(getSettingsValue("recording.enabled", false)).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("returns fallback when settings.json does not exist", () => {
|
|
98
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
99
|
+
expect(getSettingsValue("some.key", "default")).toBe("default");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("returns nested fallback for deep path", () => {
|
|
103
|
+
mockSettings({ a: { b: {} } });
|
|
104
|
+
expect(getSettingsValue("a.b.c.d", "fallback")).toBe("fallback");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("returns null when value is null", () => {
|
|
108
|
+
mockSettings({ key: null });
|
|
109
|
+
expect(getSettingsValue("key", "fallback")).toBeNull();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns fallback when intermediate key is null", () => {
|
|
113
|
+
mockSettings({ a: null });
|
|
114
|
+
expect(getSettingsValue("a.b", "fallback")).toBe("fallback");
|
|
115
|
+
});
|
|
116
|
+
});
|