dsh-config-manager 0.1.19 → 0.1.21

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.
Files changed (82) hide show
  1. package/lib/client.d.ts +319 -2
  2. package/lib/client.js +1271 -109
  3. package/lib/client.js.map +1 -1
  4. package/lib/core/run-registry.d.ts +2 -2
  5. package/lib/core/run-registry.js +5 -1
  6. package/lib/core/run-registry.js.map +1 -1
  7. package/lib/index.js +408 -2
  8. package/lib/index.js.map +1 -1
  9. package/lib/sync/ancestor.d.ts +17 -0
  10. package/lib/sync/ancestor.js +64 -0
  11. package/lib/sync/ancestor.js.map +1 -0
  12. package/lib/sync/autosync-config.d.ts +34 -0
  13. package/lib/sync/autosync-config.js +116 -0
  14. package/lib/sync/autosync-config.js.map +1 -0
  15. package/lib/sync/autosync-scheduler.d.ts +95 -0
  16. package/lib/sync/autosync-scheduler.js +379 -0
  17. package/lib/sync/autosync-scheduler.js.map +1 -0
  18. package/lib/sync/merge.d.ts +42 -0
  19. package/lib/sync/merge.js +325 -0
  20. package/lib/sync/merge.js.map +1 -0
  21. package/lib/sync/review-queue.d.ts +51 -0
  22. package/lib/sync/review-queue.js +119 -0
  23. package/lib/sync/review-queue.js.map +1 -0
  24. package/lib/sync/risk.d.ts +59 -0
  25. package/lib/sync/risk.js +76 -0
  26. package/lib/sync/risk.js.map +1 -0
  27. package/lib/sync/sync-config.d.ts +7 -2
  28. package/lib/sync/sync-config.js +24 -4
  29. package/lib/sync/sync-config.js.map +1 -1
  30. package/lib/sync/sync-engine.d.ts +88 -2
  31. package/lib/sync/sync-engine.js +333 -8
  32. package/lib/sync/sync-engine.js.map +1 -1
  33. package/lib/sync/sync-history.d.ts +39 -0
  34. package/lib/sync/sync-history.js +88 -0
  35. package/lib/sync/sync-history.js.map +1 -0
  36. package/lib/sync/sync-session.d.ts +39 -0
  37. package/lib/sync/sync-session.js +54 -0
  38. package/lib/sync/sync-session.js.map +1 -0
  39. package/lib/sync/sync-state.d.ts +7 -2
  40. package/lib/sync/sync-state.js +9 -5
  41. package/lib/sync/sync-state.js.map +1 -1
  42. package/lib/ui/i18n.d.ts +63 -0
  43. package/lib/ui/i18n.js +130 -1
  44. package/lib/ui/i18n.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/client/config-manager.module.css +18 -0
  47. package/src/client/sync/SyncConfirmView.tsx +301 -0
  48. package/src/client/sync/SyncHistoryView.test.ts +40 -0
  49. package/src/client/sync/SyncHistoryView.tsx +155 -0
  50. package/src/client/sync/SyncSettingsView.tsx +241 -12
  51. package/src/client/sync/history-model.test.ts +82 -0
  52. package/src/client/sync/history-model.ts +112 -0
  53. package/src/client/sync/sync-api.test.ts +155 -0
  54. package/src/client/sync/sync-api.ts +219 -0
  55. package/src/client/sync/sync-locales.ts +162 -1
  56. package/src/client/sync/sync-view-v2.test.ts +131 -0
  57. package/src/client/sync/sync-view.ts +151 -4
  58. package/src/core/run-registry.ts +7 -3
  59. package/src/index.ts +420 -3
  60. package/src/sync/ancestor.test.ts +152 -0
  61. package/src/sync/ancestor.ts +81 -0
  62. package/src/sync/autosync-config.test.ts +93 -0
  63. package/src/sync/autosync-config.ts +137 -0
  64. package/src/sync/autosync-scheduler.test.ts +191 -0
  65. package/src/sync/autosync-scheduler.ts +443 -0
  66. package/src/sync/merge.test.ts +204 -0
  67. package/src/sync/merge.ts +360 -0
  68. package/src/sync/review-queue.test.ts +120 -0
  69. package/src/sync/review-queue.ts +167 -0
  70. package/src/sync/risk.test.ts +131 -0
  71. package/src/sync/risk.ts +122 -0
  72. package/src/sync/sync-config.test.ts +106 -0
  73. package/src/sync/sync-config.ts +23 -4
  74. package/src/sync/sync-engine.test.ts +392 -3
  75. package/src/sync/sync-engine.ts +383 -10
  76. package/src/sync/sync-history.test.ts +85 -0
  77. package/src/sync/sync-history.ts +126 -0
  78. package/src/sync/sync-session.test.ts +137 -0
  79. package/src/sync/sync-session.ts +76 -0
  80. package/src/sync/sync-state.test.ts +48 -2
  81. package/src/sync/sync-state.ts +11 -5
  82. package/src/ui/i18n.ts +132 -3
@@ -0,0 +1,81 @@
1
+ /**
2
+ * m-sync-flow:共同祖先快照存储助手。
3
+ * 与 push 落盘的 localSnapshotsDir 共用目录布局;按 snapshotId 读写、按 createdAt 保留最近 N 个。
4
+ * 纯逻辑 + 可注入 fs;不触碰真实 ~/.dsh。
5
+ */
6
+ import { createSnapshotFs, joinFs } from './fs.ts';
7
+ import type { SnapshotFs } from './fs.ts';
8
+ import { readSnapshotFromDir, writeSnapshotToDir } from './layout.ts';
9
+ import { SNAPSHOT_MANIFEST_FILE } from './layout.ts';
10
+ import type { SyncSnapshot } from './transport.ts';
11
+
12
+ /** 默认保留的祖先快照数量(最旧超出则被裁剪)。 */
13
+ export const DEFAULT_ANCESTOR_KEEP = 10;
14
+
15
+ /** 按 snapshotId 读取祖先快照目录;不存在抛错,不静默降级。 */
16
+ export async function loadAncestor(
17
+ localSnapshotsDir: string,
18
+ snapshotId: string,
19
+ fsx: SnapshotFs = createSnapshotFs(),
20
+ ): Promise<SyncSnapshot> {
21
+ if (typeof snapshotId !== 'string' || snapshotId === '') {
22
+ throw new Error('loadAncestor: snapshotId 不能为空');
23
+ }
24
+ const dir = joinFs(localSnapshotsDir, snapshotId);
25
+ return await readSnapshotFromDir(dir, fsx);
26
+ }
27
+
28
+ /** 把合并后的快照写入本地祖先副本目录(覆盖同名 id)。 */
29
+ export async function writeAncestor(
30
+ localSnapshotsDir: string,
31
+ snapshot: SyncSnapshot,
32
+ fsx: SnapshotFs = createSnapshotFs(),
33
+ ): Promise<void> {
34
+ const dir = joinFs(localSnapshotsDir, snapshot.id);
35
+ await writeSnapshotToDir(snapshot, dir, fsx);
36
+ }
37
+
38
+ /** 列出 localSnapshotsDir 下全部祖先快照目录的 (id, createdAt) 对,按 createdAt 升序。 */
39
+ export async function listAncestors(
40
+ localSnapshotsDir: string,
41
+ fsx: SnapshotFs = createSnapshotFs(),
42
+ ): Promise<Array<{ id: string; createdAt: string }>> {
43
+ if (!(await fsx.exists(localSnapshotsDir))) return [];
44
+ const names = await fsx.readdir(localSnapshotsDir);
45
+ const out: Array<{ id: string; createdAt: string }> = [];
46
+ for (const name of names) {
47
+ const dir = joinFs(localSnapshotsDir, name);
48
+ const manifestPath = joinFs(dir, SNAPSHOT_MANIFEST_FILE);
49
+ if (!(await fsx.exists(manifestPath))) continue;
50
+ try {
51
+ const raw = Buffer.from(await fsx.readFile(manifestPath)).toString('utf8');
52
+ const parsed = JSON.parse(raw) as { id?: unknown; createdAt?: unknown };
53
+ if (typeof parsed.id === 'string' && typeof parsed.createdAt === 'string') {
54
+ out.push({ id: parsed.id, createdAt: parsed.createdAt });
55
+ }
56
+ } catch {
57
+ // 单个快照目录损坏不影响整体列表
58
+ }
59
+ }
60
+ out.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0));
61
+ return out;
62
+ }
63
+
64
+ /** 保留最近 keep 个祖先(按 createdAt 升序的最末 keep 个);其余删除。
65
+ * keep <= 0 视为保留全部(不做任何删除)。
66
+ * 返回被删除的祖先 id 列表(按 createdAt 升序,即先删最旧)。 */
67
+ export async function pruneAncestors(
68
+ localSnapshotsDir: string,
69
+ keep: number = DEFAULT_ANCESTOR_KEEP,
70
+ fsx: SnapshotFs = createSnapshotFs(),
71
+ ): Promise<string[]> {
72
+ const all = await listAncestors(localSnapshotsDir, fsx);
73
+ if (keep <= 0 || all.length <= keep) return [];
74
+ const toRemove = all.slice(0, all.length - keep);
75
+ const removed: string[] = [];
76
+ for (const a of toRemove) {
77
+ await fsx.remove(joinFs(localSnapshotsDir, a.id));
78
+ removed.push(a.id);
79
+ }
80
+ return removed;
81
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * autosync-config 测试:sync-autosync.json 读写往返、缺省值、损坏 JSON 回退缺省、原子写。
3
+ */
4
+ import test from 'node:test';
5
+ import assert from 'node:assert/strict';
6
+ import fs from 'node:fs/promises';
7
+ import os from 'node:os';
8
+ import path from 'node:path';
9
+
10
+ import {
11
+ readAutosyncConfig, writeAutosyncConfig, AUTOSYNC_CONFIG_FILE,
12
+ AUTOSYNC_CONFIG_SCHEMA_VERSION, DEFAULT_AUTOSYNC_INTERVAL,
13
+ DEFAULT_STARTUP_MIN_INTERVAL_MS,
14
+ } from './autosync-config.ts';
15
+
16
+ test('writeAutosyncConfig + readAutosyncConfig:写入 → 读回字段一致', async () => {
17
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-rt-'));
18
+ try {
19
+ await writeAutosyncConfig(dir, {
20
+ enabled: true,
21
+ interval: '15m',
22
+ startupMinIntervalMs: 300000,
23
+ consecutiveFailures: 2,
24
+ lastRunAt: '2026-08-16T12:00:00.000Z',
25
+ lastRunStatus: 'skipped',
26
+ lastRunMessage: '有冲突项,跳过',
27
+ lastRunHistoryId: 'hist-1',
28
+ });
29
+ const cfg = await readAutosyncConfig(dir);
30
+ assert.equal(cfg.enabled, true);
31
+ assert.equal(cfg.interval, '15m');
32
+ assert.equal(cfg.startupMinIntervalMs, 300000);
33
+ assert.equal(cfg.consecutiveFailures, 2);
34
+ assert.equal(cfg.lastRunAt, '2026-08-16T12:00:00.000Z');
35
+ assert.equal(cfg.lastRunStatus, 'skipped');
36
+ assert.equal(cfg.lastRunMessage, '有冲突项,跳过');
37
+ assert.equal(cfg.lastRunHistoryId, 'hist-1');
38
+ // 原始文件校验
39
+ const raw = JSON.parse(await fs.readFile(path.join(dir, AUTOSYNC_CONFIG_FILE), 'utf8'));
40
+ assert.equal(raw.schemaVersion, AUTOSYNC_CONFIG_SCHEMA_VERSION);
41
+ assert.equal(raw.enabled, true);
42
+ assert.equal(raw.interval, '15m');
43
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
44
+ });
45
+
46
+ test('readAutosyncConfig:文件不存在 → 返回缺省值', async () => {
47
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-default-'));
48
+ try {
49
+ const cfg = await readAutosyncConfig(dir);
50
+ assert.equal(cfg.enabled, false);
51
+ assert.equal(cfg.interval, DEFAULT_AUTOSYNC_INTERVAL);
52
+ assert.equal(cfg.startupMinIntervalMs, DEFAULT_STARTUP_MIN_INTERVAL_MS);
53
+ assert.equal(cfg.consecutiveFailures, 0);
54
+ assert.equal(cfg.lastRunAt, undefined);
55
+ assert.equal(cfg.lastRunStatus, undefined);
56
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
57
+ });
58
+
59
+ test('readAutosyncConfig:损坏 JSON → 回退缺省', async () => {
60
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-corrupt-'));
61
+ try {
62
+ await fs.writeFile(path.join(dir, AUTOSYNC_CONFIG_FILE), '{not-json', 'utf8');
63
+ const cfg = await readAutosyncConfig(dir);
64
+ assert.equal(cfg.enabled, false);
65
+ assert.equal(cfg.interval, DEFAULT_AUTOSYNC_INTERVAL);
66
+ assert.equal(cfg.startupMinIntervalMs, DEFAULT_STARTUP_MIN_INTERVAL_MS);
67
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
68
+ });
69
+
70
+ test('readAutosyncConfig:不支持的 schemaVersion → 回退缺省', async () => {
71
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-badver-'));
72
+ try {
73
+ await fs.writeFile(path.join(dir, AUTOSYNC_CONFIG_FILE),
74
+ JSON.stringify({ schemaVersion: 99, enabled: true }), 'utf8');
75
+ const cfg = await readAutosyncConfig(dir);
76
+ assert.equal(cfg.enabled, false);
77
+ } finally { await fs.rm(dir, { recursive: true, force: true }); }
78
+ });
79
+
80
+ test('writeAutosyncConfig:原子写(自动创建目录)', async () => {
81
+ const base = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-autosync-mkdir-'));
82
+ try {
83
+ const dir = path.join(base, 'nested', 'sync');
84
+ await writeAutosyncConfig(dir, { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 });
85
+ const cfg = await readAutosyncConfig(dir);
86
+ assert.ok(cfg);
87
+ assert.equal(cfg.enabled, true);
88
+ // 确认没有残留临时文件
89
+ const files = await fs.readdir(dir);
90
+ assert.ok(files.length >= 1, '应有配置文件');
91
+ assert.ok(!files.some((f) => f.includes('.tmp')), '不应残留 .tmp 临时文件');
92
+ } finally { await fs.rm(base, { recursive: true, force: true }); }
93
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * m-autosync:自动同步配置持久化(sync-autosync.json)。
3
+ *
4
+ * 与 sync-config.json 并列独立文件:语义清楚、schema 演进独立。
5
+ * schemaVersion:1,字段 { enabled, interval, startupMinIntervalMs, consecutiveFailures,
6
+ * lastRunAt, lastRunStatus, lastRunMessage, lastRunHistoryId }。
7
+ *
8
+ * 原子写(临时文件 + rename),损坏/不支持 schema 回退缺省(enabled=false,
9
+ * interval='30m', startupMinIntervalMs=5*60*1000)。
10
+ */
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import crypto from 'node:crypto';
14
+
15
+ import { parseJsonSafe, stringifyJsonSafe } from '../utils/json.ts';
16
+
17
+ export const AUTOSYNC_CONFIG_FILE = 'sync-autosync.json';
18
+ export const AUTOSYNC_CONFIG_SCHEMA_VERSION = 1;
19
+
20
+ /** 统一间隔类型 */
21
+ export type AutosyncInterval = '5m' | '15m' | '30m' | '60m' | '6h' | '12h' | '24h';
22
+
23
+ /** 缺省间隔 */
24
+ export const DEFAULT_AUTOSYNC_INTERVAL: AutosyncInterval = '30m';
25
+
26
+ /** 缺省启动触发最小间隔阈值(5 分钟,防频繁重启反复同步) */
27
+ export const DEFAULT_STARTUP_MIN_INTERVAL_MS = 5 * 60 * 1000;
28
+
29
+ /** 最近一次自动同步执行状态 */
30
+ export type AutosyncRunStatus = 'success' | 'skipped' | 'failed' | 'partial';
31
+
32
+ /** 自动同步配置(持久化面) */
33
+ export interface AutosyncConfig {
34
+ /** 总开关(同时控制上传 + 下载) */
35
+ enabled: boolean;
36
+ /** 统一间隔 */
37
+ interval: AutosyncInterval;
38
+ /** 重启触发的「自动下载合并」最小间隔阈值(ms) */
39
+ startupMinIntervalMs: number;
40
+ /** 连续失败计数(用于通知判定) */
41
+ consecutiveFailures: number;
42
+ /** 最近一次自动同步执行时间(ISO-8601 UTC);''/undefined = 从未执行 */
43
+ lastRunAt?: string;
44
+ /** 最近一次自动同步执行状态 */
45
+ lastRunStatus?: AutosyncRunStatus;
46
+ lastRunMessage?: string;
47
+ /** 最近一次自动同步触发的同步历史条目 id */
48
+ lastRunHistoryId?: string;
49
+ }
50
+
51
+ /** 缺省配置(首次无文件 / 损坏 / 不支持 schema 时回退) */
52
+ export function defaultAutosyncConfig(): AutosyncConfig {
53
+ return {
54
+ enabled: false,
55
+ interval: DEFAULT_AUTOSYNC_INTERVAL,
56
+ startupMinIntervalMs: DEFAULT_STARTUP_MIN_INTERVAL_MS,
57
+ consecutiveFailures: 0,
58
+ };
59
+ }
60
+
61
+ /** 读取自动同步配置;文件不存在 / 损坏 / 不支持 schema → 缺省值(不抛错)。 */
62
+ export async function readAutosyncConfig(dir: string): Promise<AutosyncConfig> {
63
+ const file = path.join(dir, AUTOSYNC_CONFIG_FILE);
64
+ let raw: string;
65
+ try {
66
+ raw = await fs.readFile(file, 'utf8');
67
+ } catch {
68
+ return defaultAutosyncConfig();
69
+ }
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = parseJsonSafe(raw);
73
+ } catch {
74
+ return defaultAutosyncConfig();
75
+ }
76
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
77
+ return defaultAutosyncConfig();
78
+ }
79
+ const obj = parsed as Record<string, unknown>;
80
+ // schemaVersion 必须为 1(不支持其他版本)
81
+ if (obj['schemaVersion'] !== undefined && obj['schemaVersion'] !== AUTOSYNC_CONFIG_SCHEMA_VERSION) {
82
+ return defaultAutosyncConfig();
83
+ }
84
+ if (obj['schemaVersion'] === undefined) {
85
+ return defaultAutosyncConfig();
86
+ }
87
+ const cfg = defaultAutosyncConfig();
88
+ if (typeof obj['enabled'] === 'boolean') cfg.enabled = obj['enabled'];
89
+ if (isAutosyncInterval(obj['interval'])) cfg.interval = obj['interval'];
90
+ if (typeof obj['startupMinIntervalMs'] === 'number' && Number.isFinite(obj['startupMinIntervalMs']) && obj['startupMinIntervalMs'] > 0) {
91
+ cfg.startupMinIntervalMs = obj['startupMinIntervalMs'];
92
+ }
93
+ if (typeof obj['consecutiveFailures'] === 'number' && Number.isFinite(obj['consecutiveFailures']) && obj['consecutiveFailures'] >= 0) {
94
+ cfg.consecutiveFailures = obj['consecutiveFailures'];
95
+ }
96
+ if (typeof obj['lastRunAt'] === 'string' && obj['lastRunAt'] !== '') cfg.lastRunAt = obj['lastRunAt'];
97
+ if (typeof obj['lastRunStatus'] === 'string' && (obj['lastRunStatus'] === 'success' || obj['lastRunStatus'] === 'skipped' || obj['lastRunStatus'] === 'failed' || obj['lastRunStatus'] === 'partial')) {
98
+ cfg.lastRunStatus = obj['lastRunStatus'];
99
+ }
100
+ if (typeof obj['lastRunMessage'] === 'string') cfg.lastRunMessage = obj['lastRunMessage'];
101
+ if (typeof obj['lastRunHistoryId'] === 'string') cfg.lastRunHistoryId = obj['lastRunHistoryId'];
102
+ return cfg;
103
+ }
104
+
105
+ /** 写入自动同步配置(原子写:临时文件 + rename;自动创建目录)。 */
106
+ export async function writeAutosyncConfig(dir: string, cfg: AutosyncConfig): Promise<void> {
107
+ await fs.mkdir(dir, { recursive: true });
108
+ const payload: Record<string, unknown> = {
109
+ schemaVersion: AUTOSYNC_CONFIG_SCHEMA_VERSION,
110
+ enabled: cfg.enabled,
111
+ interval: cfg.interval,
112
+ startupMinIntervalMs: cfg.startupMinIntervalMs,
113
+ consecutiveFailures: cfg.consecutiveFailures,
114
+ };
115
+ if (cfg.lastRunAt !== undefined && cfg.lastRunAt !== '') payload['lastRunAt'] = cfg.lastRunAt;
116
+ if (cfg.lastRunStatus !== undefined) payload['lastRunStatus'] = cfg.lastRunStatus;
117
+ if (cfg.lastRunMessage !== undefined && cfg.lastRunMessage !== '') payload['lastRunMessage'] = cfg.lastRunMessage;
118
+ if (cfg.lastRunHistoryId !== undefined && cfg.lastRunHistoryId !== '') payload['lastRunHistoryId'] = cfg.lastRunHistoryId;
119
+
120
+ const target = path.join(dir, AUTOSYNC_CONFIG_FILE);
121
+ const tmp = path.join(
122
+ dir,
123
+ `.${AUTOSYNC_CONFIG_FILE}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`,
124
+ );
125
+ const data = stringifyJsonSafe(payload, { space: 2 });
126
+ try {
127
+ await fs.writeFile(tmp, data, 'utf8');
128
+ await fs.rename(tmp, target);
129
+ } catch (err) {
130
+ try { await fs.rm(tmp, { force: true }); } catch { /* ignore */ }
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ function isAutosyncInterval(v: unknown): v is AutosyncInterval {
136
+ return v === '5m' || v === '15m' || v === '30m' || v === '60m' || v === '6h' || v === '12h' || v === '24h';
137
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * AutoSyncScheduler 测试:interval 换算、shouldTriggerStartupRun 阈值、
3
+ * enabled=false 不执行、连续失败通知、冲突跳过。
4
+ *
5
+ * 采用真实 RunRegistry + 注入 readConfig/writeConfig/readSyncConfigFn/readHistoryFn/
6
+ * appendHistoryFn/makeSyncEngine/now,全程不触碰真实网络与真实定时器。
7
+ */
8
+ import test from 'node:test';
9
+ import assert from 'node:assert/strict';
10
+ import fs from 'node:fs/promises';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+
14
+ import {
15
+ AutoSyncScheduler, intervalToMs, shouldTriggerStartupRun, buildAutoApplyPlan,
16
+ } from './autosync-scheduler.ts';
17
+ import { RunRegistry } from '../core/run-registry.ts';
18
+ import { nullLogger } from '../utils/logger.ts';
19
+ import type { AutosyncConfig } from './autosync-config.ts';
20
+ import type { AutosyncHistoryEntry } from './sync-history.ts';
21
+ import type { MergePlan, MergeSectionResult } from './merge.ts';
22
+ import type { SyncEngine } from './sync-engine.ts';
23
+
24
+ test('intervalToMs: 间隔换算正确', () => {
25
+ assert.equal(intervalToMs('5m'), 5 * 60 * 1000);
26
+ assert.equal(intervalToMs('15m'), 15 * 60 * 1000);
27
+ assert.equal(intervalToMs('30m'), 30 * 60 * 1000);
28
+ assert.equal(intervalToMs('60m'), 60 * 60 * 1000);
29
+ assert.equal(intervalToMs('6h'), 6 * 60 * 60 * 1000);
30
+ assert.equal(intervalToMs('12h'), 12 * 60 * 60 * 1000);
31
+ assert.equal(intervalToMs('24h'), 24 * 60 * 60 * 1000);
32
+ });
33
+
34
+ test('shouldTriggerStartupRun: 阈值判断', () => {
35
+ const threshold = 5 * 60 * 1000;
36
+ const now = 1_000_000_000_000;
37
+ assert.equal(shouldTriggerStartupRun(new Date(now - 60 * 1000).toISOString(), threshold, now), false);
38
+ assert.equal(shouldTriggerStartupRun(new Date(now - 6 * 60 * 1000).toISOString(), threshold, now), true);
39
+ assert.equal(shouldTriggerStartupRun(undefined, threshold, now), true);
40
+ assert.equal(shouldTriggerStartupRun(new Date(now - threshold).toISOString(), threshold, now), true);
41
+ assert.equal(shouldTriggerStartupRun(new Date(now - threshold - 1).toISOString(), threshold, now), true);
42
+ assert.equal(shouldTriggerStartupRun(new Date(now - threshold + 1).toISOString(), threshold, now), false);
43
+ });
44
+
45
+ /** 构造一个可控 scheduler:注入全部 fs/engine 依赖,验证 runOnce 行为。 */
46
+ function makeScheduler(opts: {
47
+ cfg: AutosyncConfig;
48
+ engine: Partial<SyncEngine>;
49
+ history: AutosyncHistoryEntry[];
50
+ }) {
51
+ const runs = new RunRegistry();
52
+ const entries: AutosyncHistoryEntry[] = [...opts.history];
53
+ let config = opts.cfg;
54
+ const scheduler = new AutoSyncScheduler({
55
+ syncDir: '/tmp',
56
+ host: { log: nullLogger() },
57
+ makeSyncEngine: () => opts.engine as SyncEngine,
58
+ msg: (k: string) => k,
59
+ runs,
60
+ now: () => new Date(1_000_000_000_000),
61
+ readConfig: async () => config,
62
+ writeConfig: async (c) => { config = c; },
63
+ readSyncConfigFn: async () => ({ repoUrl: 'git@github.com:foo/bar.git' }),
64
+ readHistoryFn: async () => ({ schemaVersion: 1, autosyncEntries: entries, updatedAt: '' }),
65
+ appendHistoryFn: async (e) => { entries.push(e); },
66
+ // 测试不用真实定时器:不调 start()
67
+ });
68
+ return { scheduler, runs, getConfig: () => config, getEntries: () => entries };
69
+ }
70
+
71
+ function mergeResult(id: string, decision: MergeSectionResult['decision']): MergeSectionResult {
72
+ return { id: id as never, decision, conflicts: [], merged: {} as never };
73
+ }
74
+
75
+ function makeMergePlan(ids: Array<[string, MergeSectionResult['decision']]>): MergePlan {
76
+ return { sections: ids.map(([id, decision]) => mergeResult(id, decision)) };
77
+ }
78
+
79
+ test('runOnce: enabled=false → skipped(disabled),不写历史', async () => {
80
+ const cfg: AutosyncConfig = { enabled: false, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
81
+ const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine: {}, history: [] });
82
+ const result = await scheduler.runOnce();
83
+ assert.equal(result.status, 'skipped');
84
+ assert.equal(result.skipReason, 'disabled');
85
+ assert.equal(getEntries().length, 0, 'disabled 不写历史');
86
+ assert.equal(getConfig().consecutiveFailures, 0);
87
+ });
88
+
89
+ test('runOnce: 未配置仓库 → skipped(unconfigured),写历史但不计失败', async () => {
90
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
91
+ const scheduler = new AutoSyncScheduler({
92
+ syncDir: '/tmp',
93
+ host: { log: nullLogger() },
94
+ makeSyncEngine: () => ({} as SyncEngine),
95
+ msg: (k: string) => k,
96
+ runs: new RunRegistry(),
97
+ now: () => new Date(1_000_000_000_000),
98
+ readConfig: async () => cfg,
99
+ writeConfig: async () => {},
100
+ readSyncConfigFn: async () => null,
101
+ readHistoryFn: async () => ({ schemaVersion: 1, autosyncEntries: [], updatedAt: '' }),
102
+ appendHistoryFn: async () => {},
103
+ });
104
+ const result = await scheduler.runOnce();
105
+ assert.equal(result.status, 'skipped');
106
+ assert.equal(result.skipReason, 'unconfigured');
107
+ assert.equal(result.consecutiveFailures, 0, '未配置不累计失败');
108
+ });
109
+
110
+ test('runOnce: merge 抛错 → failed,连续失败计数 +1', async () => {
111
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 2 };
112
+ const engine = { merge: async () => { throw new Error('network down'); } };
113
+ const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine, history: [] });
114
+ const result = await scheduler.runOnce();
115
+ assert.equal(result.status, 'failed');
116
+ assert.equal(result.consecutiveFailures, 3, '连续失败 2→3');
117
+ assert.equal(getConfig().consecutiveFailures, 3);
118
+ assert.ok(getEntries().some((e) => e.status === 'failed'), '写入失败历史');
119
+ });
120
+
121
+ test('runOnce: 有冲突 → skipped(conflict),不写本地,不计失败', async () => {
122
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 1 };
123
+ const engine = {
124
+ merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'conflict']]),
125
+ };
126
+ const { scheduler, getConfig, getEntries } = makeScheduler({ cfg, engine, history: [] });
127
+ const result = await scheduler.runOnce();
128
+ assert.equal(result.status, 'skipped');
129
+ assert.equal(result.skipReason, 'conflict');
130
+ assert.deepEqual(result.conflictedSections, ['settings']);
131
+ assert.equal(result.consecutiveFailures, 1, '冲突跳过不计失败');
132
+ assert.equal(getConfig().consecutiveFailures, 1);
133
+ assert.ok(getEntries().some((e) => e.skipReason === 'conflict'), '写入冲突跳过历史');
134
+ });
135
+
136
+ test('runOnce: 无冲突且无变化 → success(pull,unchanged)', async () => {
137
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
138
+ const engine = {
139
+ merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'skip']]),
140
+ };
141
+ const { scheduler } = makeScheduler({ cfg, engine, history: [] });
142
+ const result = await scheduler.runOnce();
143
+ assert.equal(result.status, 'success');
144
+ assert.equal(result.direction, 'pull');
145
+ assert.equal(result.skipReason, 'unchanged');
146
+ });
147
+
148
+ test('runOnce: 完整双向 → 无冲突合并写本地 + push', async () => {
149
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
150
+ const applyCalls: string[] = [];
151
+ const engine = {
152
+ merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'useRemote']]),
153
+ applyMergePlan: async () => { applyCalls.push('apply'); return { ok: true, applied: ['settings'], restoreId: 'r1', rolledBack: false, review: [], warnings: [] }; },
154
+ push: async () => ({ ok: true, snapshotId: 'snap-push', sections: ['settings'] as never, warnings: [] }),
155
+ };
156
+ const { scheduler, getEntries } = makeScheduler({ cfg, engine, history: [] });
157
+ const result = await scheduler.runOnce();
158
+ assert.equal(result.status, 'success');
159
+ assert.equal(result.direction, 'both');
160
+ assert.deepEqual(result.appliedSections, ['settings']);
161
+ assert.equal(result.pushedSnapshotId, 'snap-push');
162
+ assert.equal(applyCalls.length, 1);
163
+ assert.ok(getEntries().some((e) => e.status === 'success' && e.direction === 'both'), '写入双向成功历史');
164
+ });
165
+
166
+ test('runOnce: startup 变体 → 只做 pull 合并,不上传', async () => {
167
+ const cfg: AutosyncConfig = { enabled: true, interval: '30m', startupMinIntervalMs: 300000, consecutiveFailures: 0 };
168
+ const pushCalls: string[] = [];
169
+ const engine = {
170
+ merge: async (): Promise<MergePlan> => makeMergePlan([['settings', 'useRemote']]),
171
+ applyMergePlan: async () => ({ ok: true, applied: ['settings'], restoreId: 'r1', rolledBack: false, review: [], warnings: [] }),
172
+ push: async () => { pushCalls.push('push'); return { ok: true, snapshotId: 'x', sections: [] as never, warnings: [] }; },
173
+ };
174
+ const { scheduler } = makeScheduler({ cfg, engine, history: [] });
175
+ const result = await scheduler.runOnce({ startup: true });
176
+ assert.equal(result.direction, 'pull', 'startup 不上传');
177
+ assert.equal(pushCalls.length, 0, 'startup 变体不调用 push');
178
+ });
179
+
180
+ test('buildAutoApplyPlan: 把非 skip 非 conflict 项归入 autoApply', () => {
181
+ const plan = makeMergePlan([
182
+ ['settings', 'useRemote'],
183
+ ['providers', 'keepLocal'],
184
+ ['plugins', 'skip'],
185
+ ['mcp', 'conflict'],
186
+ ]);
187
+ const apply = buildAutoApplyPlan(plan);
188
+ assert.deepEqual(apply.autoApply.map((s) => s.id), ['settings', 'providers']);
189
+ assert.deepEqual(apply.review.map((s) => s.id), ['mcp']);
190
+ assert.deepEqual(apply.skipped.map((s) => s.id), ['plugins']);
191
+ });