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.
- package/lib/client.d.ts +319 -2
- package/lib/client.js +1271 -109
- package/lib/client.js.map +1 -1
- package/lib/core/run-registry.d.ts +2 -2
- package/lib/core/run-registry.js +5 -1
- package/lib/core/run-registry.js.map +1 -1
- package/lib/index.js +408 -2
- package/lib/index.js.map +1 -1
- package/lib/sync/ancestor.d.ts +17 -0
- package/lib/sync/ancestor.js +64 -0
- package/lib/sync/ancestor.js.map +1 -0
- package/lib/sync/autosync-config.d.ts +34 -0
- package/lib/sync/autosync-config.js +116 -0
- package/lib/sync/autosync-config.js.map +1 -0
- package/lib/sync/autosync-scheduler.d.ts +95 -0
- package/lib/sync/autosync-scheduler.js +379 -0
- package/lib/sync/autosync-scheduler.js.map +1 -0
- package/lib/sync/merge.d.ts +42 -0
- package/lib/sync/merge.js +325 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/review-queue.d.ts +51 -0
- package/lib/sync/review-queue.js +119 -0
- package/lib/sync/review-queue.js.map +1 -0
- package/lib/sync/risk.d.ts +59 -0
- package/lib/sync/risk.js +76 -0
- package/lib/sync/risk.js.map +1 -0
- package/lib/sync/sync-config.d.ts +7 -2
- package/lib/sync/sync-config.js +24 -4
- package/lib/sync/sync-config.js.map +1 -1
- package/lib/sync/sync-engine.d.ts +88 -2
- package/lib/sync/sync-engine.js +333 -8
- package/lib/sync/sync-engine.js.map +1 -1
- package/lib/sync/sync-history.d.ts +39 -0
- package/lib/sync/sync-history.js +88 -0
- package/lib/sync/sync-history.js.map +1 -0
- package/lib/sync/sync-session.d.ts +39 -0
- package/lib/sync/sync-session.js +54 -0
- package/lib/sync/sync-session.js.map +1 -0
- package/lib/sync/sync-state.d.ts +7 -2
- package/lib/sync/sync-state.js +9 -5
- package/lib/sync/sync-state.js.map +1 -1
- package/lib/ui/i18n.d.ts +63 -0
- package/lib/ui/i18n.js +130 -1
- package/lib/ui/i18n.js.map +1 -1
- package/package.json +1 -1
- package/src/client/config-manager.module.css +18 -0
- package/src/client/sync/SyncConfirmView.tsx +301 -0
- package/src/client/sync/SyncHistoryView.test.ts +40 -0
- package/src/client/sync/SyncHistoryView.tsx +155 -0
- package/src/client/sync/SyncSettingsView.tsx +241 -12
- package/src/client/sync/history-model.test.ts +82 -0
- package/src/client/sync/history-model.ts +112 -0
- package/src/client/sync/sync-api.test.ts +155 -0
- package/src/client/sync/sync-api.ts +219 -0
- package/src/client/sync/sync-locales.ts +162 -1
- package/src/client/sync/sync-view-v2.test.ts +131 -0
- package/src/client/sync/sync-view.ts +151 -4
- package/src/core/run-registry.ts +7 -3
- package/src/index.ts +420 -3
- package/src/sync/ancestor.test.ts +152 -0
- package/src/sync/ancestor.ts +81 -0
- package/src/sync/autosync-config.test.ts +93 -0
- package/src/sync/autosync-config.ts +137 -0
- package/src/sync/autosync-scheduler.test.ts +191 -0
- package/src/sync/autosync-scheduler.ts +443 -0
- package/src/sync/merge.test.ts +204 -0
- package/src/sync/merge.ts +360 -0
- package/src/sync/review-queue.test.ts +120 -0
- package/src/sync/review-queue.ts +167 -0
- package/src/sync/risk.test.ts +131 -0
- package/src/sync/risk.ts +122 -0
- package/src/sync/sync-config.test.ts +106 -0
- package/src/sync/sync-config.ts +23 -4
- package/src/sync/sync-engine.test.ts +392 -3
- package/src/sync/sync-engine.ts +383 -10
- package/src/sync/sync-history.test.ts +85 -0
- package/src/sync/sync-history.ts +126 -0
- package/src/sync/sync-session.test.ts +137 -0
- package/src/sync/sync-session.ts +76 -0
- package/src/sync/sync-state.test.ts +48 -2
- package/src/sync/sync-state.ts +11 -5
- package/src/ui/i18n.ts +132 -3
|
@@ -13,6 +13,7 @@ import os from 'node:os';
|
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
|
|
15
15
|
import { SyncEngine } from './sync-engine.ts';
|
|
16
|
+
import type { SyncApplyPlan } from './risk.ts';
|
|
16
17
|
import { hashSection, loadSyncState, SYNC_STATE_FILE } from './sync-state.ts';
|
|
17
18
|
import { computeSnapshotMeta } from './transport.ts';
|
|
18
19
|
import type { SyncSnapshot, SyncSnapshotMeta, SyncTransport } from './transport.ts';
|
|
@@ -62,7 +63,7 @@ function makeEngine(opts: {
|
|
|
62
63
|
transport: MemSyncTransport;
|
|
63
64
|
stateDir: string;
|
|
64
65
|
localSnapshotsDir?: string;
|
|
65
|
-
extra?: ConstructorParameters<typeof SyncEngine>[0]
|
|
66
|
+
extra?: Partial<ConstructorParameters<typeof SyncEngine>[0]>;
|
|
66
67
|
}) {
|
|
67
68
|
const adapters = createAdapters({ namespaces: NS });
|
|
68
69
|
const importer = new Importer({ ctx: opts.ctx, adapters, snapshotStore: new MemSnapshotStore() });
|
|
@@ -75,7 +76,7 @@ function makeEngine(opts: {
|
|
|
75
76
|
importer,
|
|
76
77
|
now: () => new Date('2026-08-16T12:00:00.000Z'),
|
|
77
78
|
...opts.extra,
|
|
78
|
-
});
|
|
79
|
+
} as ConstructorParameters<typeof SyncEngine>[0]);
|
|
79
80
|
}
|
|
80
81
|
|
|
81
82
|
test('push: 收集 portable 分区 → 上传快照 → 更新 sync-state → 本地散文件副本', async () => {
|
|
@@ -114,7 +115,7 @@ test('push: 收集 portable 分区 → 上传快照 → 更新 sync-state →
|
|
|
114
115
|
assert.equal(state.sections['settings']?.updatedAt, '2026-08-16T12:00:00.000Z');
|
|
115
116
|
assert.deepEqual(state.transport, { type: 'memory', ref: '' });
|
|
116
117
|
const raw = JSON.parse(await fs.readFile(path.join(tmp, SYNC_STATE_FILE), 'utf8'));
|
|
117
|
-
assert.equal(raw.schemaVersion,
|
|
118
|
+
assert.equal(raw.schemaVersion, 2);
|
|
118
119
|
|
|
119
120
|
// 本地散文件副本(复用 t2 layout 布局)
|
|
120
121
|
assert.ok((await fs.stat(path.join(local, 'sync-001', 'manifest.json'))).isFile());
|
|
@@ -297,3 +298,391 @@ test('push: 全部 portable 分区导出失败 → ok=false + 明确 message', a
|
|
|
297
298
|
await fs.rm(tmp, { recursive: true, force: true });
|
|
298
299
|
}
|
|
299
300
|
});
|
|
301
|
+
|
|
302
|
+
// ─── P2c M2:applyMergePlan 单元测试 ──────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
/** 测试用 mock Importer:注入 analyzeImport / createImportPlan / executeImportPlan 的可控行为。 */
|
|
305
|
+
class MockImporter {
|
|
306
|
+
ok = true;
|
|
307
|
+
executeCalls = 0;
|
|
308
|
+
warnings: string[] = [];
|
|
309
|
+
analyzeImpl: () => Promise<unknown> = async () => ({ valid: true, compatibility: 'full' });
|
|
310
|
+
createPlanImpl: () => Promise<unknown> = async () => ({
|
|
311
|
+
items: [{ id: 'mock', kind: 'Update', adapter: 'settings', description: 'mock', severity: 'info', target: undefined }],
|
|
312
|
+
globalStrategy: 'merge',
|
|
313
|
+
pathMappings: [],
|
|
314
|
+
missingSecrets: [],
|
|
315
|
+
needsRestart: false,
|
|
316
|
+
estimatedActions: { settings: 1 } as Record<string, number>,
|
|
317
|
+
});
|
|
318
|
+
executeImpl: () => Promise<unknown> = async () => ({
|
|
319
|
+
ok: this.ok,
|
|
320
|
+
executed: [],
|
|
321
|
+
needsRestart: false,
|
|
322
|
+
missingSecrets: [],
|
|
323
|
+
warnings: this.warnings,
|
|
324
|
+
rollback: null,
|
|
325
|
+
snapshotId: null,
|
|
326
|
+
});
|
|
327
|
+
async analyzeImport(_zipPath: string): Promise<unknown> { return await this.analyzeImpl(); }
|
|
328
|
+
async createImportPlan(_zipPath: string, _decisions: unknown): Promise<unknown> { return await this.createPlanImpl(); }
|
|
329
|
+
async executeImportPlan(_zipPath: string, _plan: unknown, _opts: unknown): Promise<unknown> {
|
|
330
|
+
this.executeCalls += 1;
|
|
331
|
+
return await this.executeImpl();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function makeEngineWithMockImporter(opts: {
|
|
336
|
+
ctx: ReturnType<typeof makeContext>;
|
|
337
|
+
transport: MemSyncTransport;
|
|
338
|
+
stateDir: string;
|
|
339
|
+
localSnapshotsDir?: string;
|
|
340
|
+
mockImporter: MockImporter;
|
|
341
|
+
}) {
|
|
342
|
+
// 把 mock 注入到 SyncEngine 的 importer 槽位 —— Importer 是类,类型上不能完全替换;
|
|
343
|
+
// 这里用对象字面量 duck-type 兼容 Importer 的三个方法。
|
|
344
|
+
return makeEngine({
|
|
345
|
+
...opts,
|
|
346
|
+
extra: { importer: opts.mockImporter as unknown as Importer },
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
test('applyMergePlan: 成功路径 → ApplyReport{ok:true, applied, restoreId, rolledBack:false}', async () => {
|
|
351
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-apply-ok-'));
|
|
352
|
+
const localDir = path.join(tmp, 'snapshots');
|
|
353
|
+
try {
|
|
354
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
355
|
+
seedSource(ctx);
|
|
356
|
+
const transport = new MemSyncTransport();
|
|
357
|
+
const mock = new MockImporter();
|
|
358
|
+
mock.ok = true;
|
|
359
|
+
const engine = makeEngineWithMockImporter({
|
|
360
|
+
ctx, transport, stateDir: tmp, localSnapshotsDir: localDir, mockImporter: mock,
|
|
361
|
+
});
|
|
362
|
+
const apply: SyncApplyPlan = {
|
|
363
|
+
autoApply: [{
|
|
364
|
+
id: 'settings',
|
|
365
|
+
decision: 'useRemote',
|
|
366
|
+
conflicts: [],
|
|
367
|
+
merged: { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 5, secrets: [] } } },
|
|
368
|
+
}],
|
|
369
|
+
review: [],
|
|
370
|
+
skipped: [],
|
|
371
|
+
};
|
|
372
|
+
const report = await engine.applyMergePlan(apply);
|
|
373
|
+
assert.equal(report.ok, true, 'success path → ok:true');
|
|
374
|
+
assert.deepEqual(report.applied, ['settings']);
|
|
375
|
+
assert.notEqual(report.restoreId, '', 'restoreId 应非空');
|
|
376
|
+
assert.equal(report.rolledBack, false);
|
|
377
|
+
assert.equal(report.review.length, 0);
|
|
378
|
+
assert.equal(report.warnings.length, 0);
|
|
379
|
+
assert.equal(mock.executeCalls, 1, 'Importer.executeImportPlan 应被调用一次');
|
|
380
|
+
// 祖先基线应被更新:sync-state.lastSnapshotId 非空 + 落 ancestor 副本
|
|
381
|
+
const state = await loadSyncState(tmp);
|
|
382
|
+
assert.notEqual(state.lastSnapshotId, '', 'push 后 lastSnapshotId 已被 recordBaseline 更新');
|
|
383
|
+
// localSnapshotsDir 下应有写出的祖先目录
|
|
384
|
+
const dirs = await fs.readdir(localDir);
|
|
385
|
+
assert.ok(dirs.length > 0, '祖先副本已写入');
|
|
386
|
+
} finally {
|
|
387
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test('applyMergePlan: 失败路径 → 整体回滚 + enqueueItems + ApplyReport{ok:false,rolledBack:true,review}', async () => {
|
|
392
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-apply-fail-'));
|
|
393
|
+
try {
|
|
394
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
395
|
+
seedSource(ctx);
|
|
396
|
+
const transport = new MemSyncTransport();
|
|
397
|
+
const mock = new MockImporter();
|
|
398
|
+
mock.ok = false; // Importer.executeImportPlan 返回 ok:false
|
|
399
|
+
const engine = makeEngineWithMockImporter({
|
|
400
|
+
ctx, transport, stateDir: tmp, mockImporter: mock,
|
|
401
|
+
});
|
|
402
|
+
const apply: SyncApplyPlan = {
|
|
403
|
+
autoApply: [{
|
|
404
|
+
id: 'settings',
|
|
405
|
+
decision: 'useRemote',
|
|
406
|
+
conflicts: [],
|
|
407
|
+
merged: { version: 1, namespaces: { general: { value: { theme: 'light' }, revision: 5, secrets: [] } } },
|
|
408
|
+
}],
|
|
409
|
+
review: [],
|
|
410
|
+
skipped: [],
|
|
411
|
+
};
|
|
412
|
+
const report = await engine.applyMergePlan(apply);
|
|
413
|
+
assert.equal(report.ok, false, 'failure path → ok:false');
|
|
414
|
+
assert.equal(report.rolledBack, true);
|
|
415
|
+
assert.equal(report.applied.length, 0);
|
|
416
|
+
assert.notEqual(report.restoreId, '', 'restoreId 应透传以便排查');
|
|
417
|
+
assert.deepEqual(report.review, [], '不再写 review-queue(§7.4)');
|
|
418
|
+
// sync-review-queue.json 不应被写入
|
|
419
|
+
const rqPath = path.join(tmp, 'sync-review-queue.json');
|
|
420
|
+
const rqExists = await fs.stat(rqPath).then(() => true).catch(() => false);
|
|
421
|
+
assert.equal(rqExists, false, 'review-queue.json 不应再被写入');
|
|
422
|
+
// recordBaseline 不应在失败路径调用:sync-state.lastSnapshotId 应仍为空
|
|
423
|
+
const state = await loadSyncState(tmp);
|
|
424
|
+
assert.equal(state.lastSnapshotId, '', '失败时不应 recordBaseline');
|
|
425
|
+
} finally {
|
|
426
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
test('applyMergePlan: 空 autoApply → 直接返回 ok:true 空报告(无 Importer 调用)', async () => {
|
|
431
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-apply-empty-'));
|
|
432
|
+
try {
|
|
433
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
434
|
+
seedSource(ctx);
|
|
435
|
+
const transport = new MemSyncTransport();
|
|
436
|
+
const mock = new MockImporter();
|
|
437
|
+
const engine = makeEngineWithMockImporter({
|
|
438
|
+
ctx, transport, stateDir: tmp, mockImporter: mock,
|
|
439
|
+
});
|
|
440
|
+
const apply: SyncApplyPlan = { autoApply: [], review: [], skipped: [] };
|
|
441
|
+
const report = await engine.applyMergePlan(apply);
|
|
442
|
+
assert.equal(report.ok, true);
|
|
443
|
+
assert.equal(report.applied.length, 0);
|
|
444
|
+
assert.equal(mock.executeCalls, 0, '空 autoApply 不应触发 Importer');
|
|
445
|
+
} finally {
|
|
446
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test('applyMergePlan: Importer 缺失 → 抛错(构造期校验)', async () => {
|
|
451
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-apply-noimporter-'));
|
|
452
|
+
try {
|
|
453
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
454
|
+
seedSource(ctx);
|
|
455
|
+
const transport = new MemSyncTransport();
|
|
456
|
+
// 不传 importer → SyncEngine 内部无 importer
|
|
457
|
+
const engine = new SyncEngine({
|
|
458
|
+
ctx, transport, stateDir: tmp, adapters: createAdapters({ namespaces: NS }),
|
|
459
|
+
now: () => new Date('2026-08-16T12:00:00.000Z'),
|
|
460
|
+
// 注意:未传 importer
|
|
461
|
+
});
|
|
462
|
+
const apply: SyncApplyPlan = {
|
|
463
|
+
autoApply: [{ id: 'settings', decision: 'useRemote', conflicts: [], merged: { version: 1, namespaces: {} } }],
|
|
464
|
+
review: [], skipped: [],
|
|
465
|
+
};
|
|
466
|
+
await assert.rejects(() => engine.applyMergePlan(apply), /缺少 importer/);
|
|
467
|
+
} finally {
|
|
468
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ─── P2a M4:merge / recordBaseline / push-baseline ──────────────────────────────
|
|
473
|
+
|
|
474
|
+
test('push: 完成后 sync-state.lastSnapshotId 指向本次推送快照(祖先基线已记录)', async () => {
|
|
475
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-baseline-'));
|
|
476
|
+
try {
|
|
477
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
478
|
+
seedSource(ctx);
|
|
479
|
+
const transport = new MemSyncTransport();
|
|
480
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp });
|
|
481
|
+
await engine.push({ snapshotId: 'sync-base' });
|
|
482
|
+
const state = await loadSyncState(tmp);
|
|
483
|
+
assert.equal(state.lastSnapshotId, 'sync-base', 'push 后 lastSnapshotId 应等于本次快照 id');
|
|
484
|
+
assert.equal(state.lastSyncAt, '2026-08-16T12:00:00.000Z');
|
|
485
|
+
} finally {
|
|
486
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
487
|
+
}
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('recordBaseline: 写本地祖先副本 + 更新 sync-state + 触发裁剪', async () => {
|
|
491
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-record-'));
|
|
492
|
+
try {
|
|
493
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
494
|
+
seedSource(ctx);
|
|
495
|
+
const transport = new MemSyncTransport();
|
|
496
|
+
const local = path.join(tmp, 'ancestors');
|
|
497
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: local });
|
|
498
|
+
const snapshot = (await transport.list()).length === 0
|
|
499
|
+
? null
|
|
500
|
+
: (transport.metas[0] && (await transport.download(transport.metas[0].id)));
|
|
501
|
+
void snapshot;
|
|
502
|
+
// 走一遍 push 让 ancestors 目录被建立
|
|
503
|
+
await engine.push({ snapshotId: 'sync-anc-1' });
|
|
504
|
+
await engine.push({ snapshotId: 'sync-anc-2' });
|
|
505
|
+
// 显式再调一次 recordBaseline(模拟合并 apply 完成后更新基线)
|
|
506
|
+
const newSnap: SyncSnapshot = {
|
|
507
|
+
id: 'sync-explicit',
|
|
508
|
+
createdAt: '2026-08-16T13:00:00.000Z',
|
|
509
|
+
manifest: {
|
|
510
|
+
schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32',
|
|
511
|
+
sectionIds: ['settings'], containsSecrets: false,
|
|
512
|
+
},
|
|
513
|
+
sections: { settings: { version: 1, namespaces: {} } },
|
|
514
|
+
};
|
|
515
|
+
await engine.recordBaseline('sync-explicit', newSnap.sections, '2026-08-16T13:00:00.000Z');
|
|
516
|
+
const state = await loadSyncState(tmp);
|
|
517
|
+
assert.equal(state.lastSnapshotId, 'sync-explicit');
|
|
518
|
+
assert.ok((await fs.stat(path.join(local, 'sync-explicit', 'manifest.json'))).isFile(), '祖先副本已写');
|
|
519
|
+
} finally {
|
|
520
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
test('merge: 不写本地配置、不执行导入,返回 MergePlan', async () => {
|
|
525
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-sync-engine-merge-'));
|
|
526
|
+
try {
|
|
527
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
528
|
+
seedSource(ctx);
|
|
529
|
+
// 远端与本地不同:本地 settings.theme = dark;远端 = light
|
|
530
|
+
const remote: SyncSnapshot = {
|
|
531
|
+
id: 'remote-merge',
|
|
532
|
+
createdAt: '2026-08-16T12:00:00.000Z',
|
|
533
|
+
manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
|
|
534
|
+
sections: {
|
|
535
|
+
settings: {
|
|
536
|
+
version: 1,
|
|
537
|
+
namespaces: {
|
|
538
|
+
general: { value: { theme: 'light', language: 'zh-CN' }, revision: 5, secrets: [] },
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
const transport = new MemSyncTransport();
|
|
544
|
+
transport.snapshots.set(remote.id, remote);
|
|
545
|
+
transport.metas.push(computeSnapshotMeta(remote));
|
|
546
|
+
// 祖先:与本地相同(本地未改 → useRemote)
|
|
547
|
+
const ancestor: SyncSnapshot = {
|
|
548
|
+
id: 'anc',
|
|
549
|
+
createdAt: '2026-08-15T00:00:00.000Z',
|
|
550
|
+
manifest: { schemaVersion: 1, dshVersion: '1.2.3', platform: 'win32', sectionIds: ['settings'], containsSecrets: false },
|
|
551
|
+
sections: {
|
|
552
|
+
settings: {
|
|
553
|
+
version: 1,
|
|
554
|
+
namespaces: { general: { value: { theme: 'dark', language: 'zh-CN' }, revision: 3, secrets: [] } },
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
const local = path.join(tmp, 'ancestors');
|
|
559
|
+
// 预置祖先副本
|
|
560
|
+
const { writeSnapshotToDir } = await import('./layout.ts');
|
|
561
|
+
await writeSnapshotToDir(ancestor, path.join(local, 'anc'));
|
|
562
|
+
// 预置 sync-state(指向祖先 id)
|
|
563
|
+
const { saveSyncState } = await import('./sync-state.ts');
|
|
564
|
+
await saveSyncState(tmp, {
|
|
565
|
+
schemaVersion: 2,
|
|
566
|
+
lastSyncAt: '2026-08-15T00:00:00.000Z',
|
|
567
|
+
sections: { settings: { hash: '0'.repeat(64), updatedAt: '2026-08-15T00:00:00.000Z' } },
|
|
568
|
+
lastSnapshotId: 'anc',
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const engine = makeEngine({ ctx, transport, stateDir: tmp, localSnapshotsDir: local });
|
|
572
|
+
const plan = await engine.merge();
|
|
573
|
+
assert.ok(plan.sections.length >= 1, '至少包含 settings 分区');
|
|
574
|
+
const settings = plan.sections.find((s) => s.id === 'settings');
|
|
575
|
+
assert.ok(settings, 'settings 在 MergePlan 中');
|
|
576
|
+
// 本地未改(=祖先)、远端改了 → useRemote
|
|
577
|
+
assert.equal(settings!.decision, 'useRemote');
|
|
578
|
+
// 零写入:目标 settings 未被覆盖
|
|
579
|
+
assert.deepEqual(ctx.settings.ns.get('general')?.value, { theme: 'dark', language: 'zh-CN' });
|
|
580
|
+
// transport 仅被 list/download 调用(无 upload/delete)
|
|
581
|
+
assert.deepEqual(transport.calls, ['list', 'download']);
|
|
582
|
+
} finally {
|
|
583
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ─── P3:applyItems(一键同步逐项执行)测试 ──────────────────────────────
|
|
588
|
+
|
|
589
|
+
import type { ImportPlan } from '../core/types.ts';
|
|
590
|
+
|
|
591
|
+
function makeImportPlan(seed: string): ImportPlan {
|
|
592
|
+
return {
|
|
593
|
+
items: [{
|
|
594
|
+
id: `settings:general-${seed}`,
|
|
595
|
+
kind: 'Update',
|
|
596
|
+
adapter: 'settings',
|
|
597
|
+
description: `Update settings.general (${seed})`,
|
|
598
|
+
severity: 'info',
|
|
599
|
+
target: { adapter: 'settings', ref: 'general' },
|
|
600
|
+
}],
|
|
601
|
+
globalStrategy: 'merge',
|
|
602
|
+
pathMappings: [],
|
|
603
|
+
missingSecrets: [],
|
|
604
|
+
needsRestart: false,
|
|
605
|
+
estimatedActions: { settings: 1 } as unknown as Record<SectionId, number>,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
test('applyItems: 成功路径 → 执行子计划 + recordBaseline', async () => {
|
|
610
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-ok-'));
|
|
611
|
+
try {
|
|
612
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
613
|
+
seedSource(ctx);
|
|
614
|
+
const transport = new MemSyncTransport();
|
|
615
|
+
const mock = new MockImporter();
|
|
616
|
+
mock.ok = true;
|
|
617
|
+
const engine = makeEngineWithMockImporter({
|
|
618
|
+
ctx, transport, stateDir: tmp, mockImporter: mock,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// 需要真实 ZIP 路径(applyItems 用 executeImportPlan 的 zipPath)
|
|
622
|
+
// 用 mock importer 时 zipPath 可以被 mock 忽略
|
|
623
|
+
const zipPath = path.join(tmp, 'session.zip');
|
|
624
|
+
await fs.writeFile(zipPath, 'mock-zip-content');
|
|
625
|
+
|
|
626
|
+
const report = await engine.applyItems(zipPath, makeImportPlan('ok'));
|
|
627
|
+
assert.equal(report.ok, true);
|
|
628
|
+
assert.deepEqual(report.applied, ['settings']);
|
|
629
|
+
assert.notEqual(report.restoreId, '', 'restoreId 应非空');
|
|
630
|
+
assert.equal(report.rolledBack, false);
|
|
631
|
+
assert.equal(mock.executeCalls, 1, 'Importer.executeImportPlan 应被调用一次');
|
|
632
|
+
// recordBaseline 应被调用(lastSnapshotId 非空)
|
|
633
|
+
const state = await loadSyncState(tmp);
|
|
634
|
+
assert.notEqual(state.lastSnapshotId, '', 'applyItems 成功后 lastSnapshotId 非空');
|
|
635
|
+
} finally {
|
|
636
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
637
|
+
}
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
test('applyItems: 失败路径 → 整体回滚 + ok:false', async () => {
|
|
641
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-fail-'));
|
|
642
|
+
try {
|
|
643
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
644
|
+
seedSource(ctx);
|
|
645
|
+
const transport = new MemSyncTransport();
|
|
646
|
+
const mock = new MockImporter();
|
|
647
|
+
mock.ok = false;
|
|
648
|
+
const engine = makeEngineWithMockImporter({
|
|
649
|
+
ctx, transport, stateDir: tmp, mockImporter: mock,
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
const zipPath = path.join(tmp, 'session.zip');
|
|
653
|
+
await fs.writeFile(zipPath, 'mock-zip-content');
|
|
654
|
+
|
|
655
|
+
const report = await engine.applyItems(zipPath, makeImportPlan('fail'));
|
|
656
|
+
assert.equal(report.ok, false);
|
|
657
|
+
assert.equal(report.rolledBack, true);
|
|
658
|
+
assert.notEqual(report.restoreId, '', 'restoreId 应透传');
|
|
659
|
+
// 不再写 review-queue
|
|
660
|
+
const rqPath = path.join(tmp, 'sync-review-queue.json');
|
|
661
|
+
const rqExists = await fs.stat(rqPath).then(() => true).catch(() => false);
|
|
662
|
+
assert.equal(rqExists, false, '失败路径不应写 review-queue');
|
|
663
|
+
} finally {
|
|
664
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test('applyItems: 空子计划 → 直接返回 ok:true(不调 Importer)', async () => {
|
|
669
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dsh-applyitems-empty-'));
|
|
670
|
+
try {
|
|
671
|
+
const ctx = makeContext('win32', 'C:\\Users\\alice');
|
|
672
|
+
seedSource(ctx);
|
|
673
|
+
const transport = new MemSyncTransport();
|
|
674
|
+
const mock = new MockImporter();
|
|
675
|
+
const engine = makeEngineWithMockImporter({
|
|
676
|
+
ctx, transport, stateDir: tmp, mockImporter: mock,
|
|
677
|
+
});
|
|
678
|
+
const emptyPlan: ImportPlan = {
|
|
679
|
+
items: [], globalStrategy: 'merge', pathMappings: [], missingSecrets: [], needsRestart: false, estimatedActions: {} as unknown as Record<SectionId, number>,
|
|
680
|
+
};
|
|
681
|
+
const report = await engine.applyItems(path.join(tmp, 'none.zip'), emptyPlan);
|
|
682
|
+
assert.equal(report.ok, true);
|
|
683
|
+
assert.deepEqual(report.applied, []);
|
|
684
|
+
assert.equal(mock.executeCalls, 0, '空子计划不应触发 Importer');
|
|
685
|
+
} finally {
|
|
686
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
687
|
+
}
|
|
688
|
+
});
|