dsh-config-manager 0.1.78 → 0.1.79
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/adapters/credentials.d.ts +6 -2
- package/lib/adapters/credentials.js +19 -3
- package/lib/adapters/file-collection.js +2 -1
- package/lib/adapters/mcp.d.ts +48 -2
- package/lib/adapters/mcp.js +265 -3
- package/lib/adapters/messages.d.ts +3 -0
- package/lib/adapters/messages.js +7 -0
- package/lib/adapters/plugin-files.js +2 -1
- package/lib/adapters/plugins.js +105 -20
- package/lib/adapters/pnpm-workspace.d.ts +32 -0
- package/lib/adapters/pnpm-workspace.js +113 -2
- package/lib/adapters/prompts.d.ts +6 -2
- package/lib/adapters/prompts.js +17 -2
- package/lib/adapters/providers.d.ts +19 -2
- package/lib/adapters/providers.js +59 -3
- package/lib/adapters/settings.d.ts +6 -2
- package/lib/adapters/settings.js +13 -2
- package/lib/adapters/skills.d.ts +25 -1
- package/lib/adapters/skills.js +60 -0
- package/lib/adapters/ui.d.ts +6 -2
- package/lib/adapters/ui.js +13 -2
- package/lib/adapters/units.d.ts +18 -0
- package/lib/adapters/units.js +39 -0
- package/lib/adapters/workspaces.js +2 -1
- package/lib/client.d.ts +400 -112
- package/lib/client.js +2845 -1524
- package/lib/core/backup.js +20 -0
- package/lib/core/mcp-connector-store.d.ts +159 -0
- package/lib/core/mcp-connector-store.js +445 -0
- package/lib/core/messages-core.d.ts +2 -0
- package/lib/core/messages-core.js +4 -0
- package/lib/core/messages.d.ts +20 -0
- package/lib/core/messages.js +44 -0
- package/lib/core/phase3-host.js +18 -1
- package/lib/core/plugin-cli.d.ts +22 -2
- package/lib/core/plugin-cli.js +22 -3
- package/lib/core/recovery-orchestrator.d.ts +3 -1
- package/lib/core/recovery-orchestrator.js +75 -16
- package/lib/core/rollback-activity.d.ts +89 -0
- package/lib/core/rollback-activity.js +350 -0
- package/lib/core/rollback-ledger.d.ts +0 -9
- package/lib/core/rollback-ledger.js +37 -0
- package/lib/core/rollback-merge.d.ts +22 -2
- package/lib/core/rollback-merge.js +63 -13
- package/lib/core/rollback-point.d.ts +93 -0
- package/lib/core/rollback-point.js +420 -101
- package/lib/core/rollback-undo-journal.d.ts +123 -0
- package/lib/core/rollback-undo-journal.js +517 -0
- package/lib/core/self-update.js +2 -4
- package/lib/core/text-fidelity.d.ts +89 -0
- package/lib/core/text-fidelity.js +176 -0
- package/lib/core/types.d.ts +12 -3
- package/lib/core/update-check.js +2 -4
- package/lib/core/validator.d.ts +9 -0
- package/lib/core/validator.js +12 -0
- package/lib/foreign/bundle.js +42 -3
- package/lib/foreign/claude-code.js +358 -26
- package/lib/foreign/claude-sessions.d.ts +51 -8
- package/lib/foreign/claude-sessions.js +111 -13
- package/lib/foreign/read-claude-code.d.ts +33 -4
- package/lib/foreign/read-claude-code.js +446 -65
- package/lib/foreign/read-claude-plugins.d.ts +36 -0
- package/lib/foreign/read-claude-plugins.js +403 -0
- package/lib/foreign/registry.js +25 -2
- package/lib/foreign/truth-table.js +6 -4
- package/lib/foreign/types.d.ts +170 -2
- package/lib/index.d.ts +14 -5
- package/lib/index.js +60 -16
- package/lib/market/git-file-writer.js +7 -1
- package/lib/profiles/dsh-profile-launcher.d.ts +20 -0
- package/lib/profiles/dsh-profile-launcher.js +58 -1
- package/lib/profiles/dsh-profile-shared.d.ts +8 -2
- package/lib/profiles/dsh-profile-shared.js +8 -2
- package/lib/routes/foreign.js +8 -1
- package/lib/routes/index.d.ts +1 -1
- package/lib/routes/kit.d.ts +2 -2
- package/lib/routes/sync.js +173 -9
- package/lib/schema/manifest.d.ts +6 -1
- package/lib/schema/manifest.js +55 -0
- package/lib/schema/types.d.ts +47 -2
- package/lib/sync/autosync-config.d.ts +73 -0
- package/lib/sync/autosync-config.js +89 -0
- package/lib/sync/autosync-scheduler.d.ts +80 -6
- package/lib/sync/autosync-scheduler.js +263 -40
- package/lib/sync/gist/gist-transport.d.ts +15 -1
- package/lib/sync/gist/gist-transport.js +66 -5
- package/lib/sync/gist/messages.d.ts +3 -0
- package/lib/sync/gist/messages.js +7 -0
- package/lib/sync/git/git-transport.d.ts +42 -3
- package/lib/sync/git/git-transport.js +146 -3
- package/lib/sync/merge.d.ts +47 -14
- package/lib/sync/merge.js +242 -222
- package/lib/sync/messages.d.ts +8 -0
- package/lib/sync/messages.js +19 -0
- package/lib/sync/risk.d.ts +19 -2
- package/lib/sync/risk.js +10 -3
- package/lib/sync/s3/messages.d.ts +6 -0
- package/lib/sync/s3/messages.js +13 -0
- package/lib/sync/s3/s3-transport.d.ts +9 -1
- package/lib/sync/s3/s3-transport.js +78 -6
- package/lib/sync/sync-engine.d.ts +193 -4
- package/lib/sync/sync-engine.js +450 -37
- package/lib/sync/sync-history.d.ts +11 -0
- package/lib/sync/sync-history.js +20 -0
- package/lib/sync/sync-state.d.ts +43 -0
- package/lib/sync/sync-state.js +44 -0
- package/lib/sync/sync-watcher.d.ts +76 -0
- package/lib/sync/sync-watcher.js +202 -0
- package/lib/sync/text-merge.d.ts +85 -0
- package/lib/sync/text-merge.js +513 -0
- package/lib/sync/transport.d.ts +48 -0
- package/lib/sync/transport.js +12 -0
- package/lib/sync/watch-scope.d.ts +38 -0
- package/lib/sync/watch-scope.js +95 -0
- package/lib/sync/webdav/webdav-transport.d.ts +16 -2
- package/lib/sync/webdav/webdav-transport.js +128 -11
- package/lib/ui/foreign-view.js +10 -0
- package/lib/ui/i18n.d.ts +8 -0
- package/lib/ui/i18n.js +20 -0
- package/lib/ui/rollback-view.d.ts +12 -0
- package/lib/ui/rollback-view.js +2 -0
- package/lib/ui/sync-settings-view.d.ts +47 -0
- package/lib/ui/sync-settings-view.js +24 -0
- package/lib/ui/types.d.ts +21 -0
- package/lib/utils/env-lock.d.ts +1 -0
- package/lib/utils/env-lock.js +7 -4
- package/package.json +6 -6
- package/src/adapters/credentials.ts +22 -4
- package/src/adapters/file-collection.ts +2 -1
- package/src/adapters/mcp.test.ts +235 -1
- package/src/adapters/mcp.ts +276 -5
- package/src/adapters/messages.ts +7 -0
- package/src/adapters/plugin-files.ts +2 -1
- package/src/adapters/plugins.test.ts +176 -2
- package/src/adapters/plugins.ts +107 -21
- package/src/adapters/pnpm-workspace.test.ts +89 -1
- package/src/adapters/pnpm-workspace.ts +127 -2
- package/src/adapters/prompts.ts +20 -3
- package/src/adapters/providers.ts +62 -4
- package/src/adapters/settings.ts +16 -3
- package/src/adapters/skills.test.ts +141 -0
- package/src/adapters/skills.ts +58 -2
- package/src/adapters/ui.ts +16 -3
- package/src/adapters/units-entries.test.ts +190 -0
- package/src/adapters/units.ts +50 -0
- package/src/adapters/workspaces.ts +2 -1
- package/src/client/about/PluginUpdateDialog.tsx +4 -2
- package/src/client/about/ReleaseNotesDialog.tsx +4 -2
- package/src/client/chat/RollbackReviewDialog.tsx +3 -2
- package/src/client/common/Skeleton.tsx +32 -0
- package/src/client/common/routes.ts +9 -0
- package/src/client/config-manager.module.css +321 -11
- package/src/client/home/HomePanel.tsx +14 -6
- package/src/client/import/ForeignImportView.tsx +3 -1
- package/src/client/import/import-wizard-steps.tsx +2 -1
- package/src/client/library/ArtifactRow.tsx +12 -2
- package/src/client/library/LibraryActions.tsx +6 -3
- package/src/client/library/LibraryPanel.tsx +21 -3
- package/src/client/library/RestorePlanView.tsx +4 -2
- package/src/client/locales.ts +3 -0
- package/src/client/recovery/RecoveryPanel.tsx +54 -7
- package/src/client/recovery/RollbackCard.tsx +7 -2
- package/src/client/recovery/RollbackDiffPane.tsx +3 -2
- package/src/client/recovery/recovery-locales.ts +20 -2
- package/src/client/recovery/recovery-view.test.ts +39 -1
- package/src/client/recovery/recovery-view.ts +23 -1
- package/src/client/run-store.test.ts +1 -1
- package/src/client/run-store.ts +4 -0
- package/src/client/sync/AutosyncCard.tsx +49 -6
- package/src/client/sync/MergeModal.tsx +219 -0
- package/src/client/sync/MergePreviewView.tsx +306 -0
- package/src/client/sync/SyncActivityBar.tsx +31 -0
- package/src/client/sync/SyncConfirmView.tsx +2 -1
- package/src/client/sync/SyncPanel.tsx +130 -87
- package/src/client/sync/SyncProbeRow.tsx +162 -0
- package/src/client/sync/SyncSettingsView.test.ts +27 -2
- package/src/client/sync/WorkDirCard.tsx +44 -6
- package/src/client/sync/history-model.ts +4 -1
- package/src/client/sync/merge-view.test.ts +234 -0
- package/src/client/sync/merge-view.ts +371 -0
- package/src/client/sync/probe-view.test.ts +90 -0
- package/src/client/sync/probe-view.ts +63 -0
- package/src/client/sync/remote-device-view.test.ts +35 -0
- package/src/client/sync/remote-device-view.ts +25 -0
- package/src/client/sync/sync-api.ts +212 -2
- package/src/client/sync/sync-entry.test.ts +78 -0
- package/src/client/sync/sync-entry.ts +53 -0
- package/src/client/sync/sync-locales.ts +234 -2
- package/src/client/sync/sync-view.test.ts +18 -1
- package/src/client/sync/sync-view.ts +20 -0
- package/src/core/backup.test.ts +41 -0
- package/src/core/backup.ts +20 -0
- package/src/core/mcp-connector-store.test.ts +265 -0
- package/src/core/mcp-connector-store.ts +516 -0
- package/src/core/messages-core.ts +4 -0
- package/src/core/messages.ts +46 -0
- package/src/core/phase3-host.ts +18 -1
- package/src/core/phase3-production-integration.test.ts +4 -0
- package/src/core/plugin-cli.test.ts +26 -0
- package/src/core/plugin-cli.ts +30 -4
- package/src/core/recovery-orchestrator.test.ts +104 -5
- package/src/core/recovery-orchestrator.ts +74 -17
- package/src/core/rollback-activity.test.ts +311 -0
- package/src/core/rollback-activity.ts +367 -0
- package/src/core/rollback-cross-session.test.ts +694 -0
- package/src/core/rollback-interrupted.test.ts +137 -0
- package/src/core/rollback-ledger.ts +32 -0
- package/src/core/rollback-merge.test.ts +130 -0
- package/src/core/rollback-merge.ts +78 -14
- package/src/core/rollback-point.ts +468 -91
- package/src/core/rollback-undo-journal.test.ts +508 -0
- package/src/core/rollback-undo-journal.ts +571 -0
- package/src/core/self-update.ts +2 -5
- package/src/core/text-fidelity.test.ts +189 -0
- package/src/core/text-fidelity.ts +197 -0
- package/src/core/types.ts +11 -3
- package/src/core/update-check.ts +2 -5
- package/src/core/validator.ts +14 -0
- package/src/foreign/bundle-origin.test.ts +265 -0
- package/src/foreign/bundle.ts +45 -5
- package/src/foreign/claude-code.test.ts +268 -0
- package/src/foreign/claude-code.ts +406 -25
- package/src/foreign/claude-plugins.test.ts +234 -0
- package/src/foreign/claude-sessions.test.ts +141 -0
- package/src/foreign/claude-sessions.ts +179 -23
- package/src/foreign/file-budget.test.ts +4 -1
- package/src/foreign/read-claude-code.test.ts +320 -0
- package/src/foreign/read-claude-code.ts +490 -71
- package/src/foreign/read-claude-plugins.ts +465 -0
- package/src/foreign/registry.ts +25 -2
- package/src/foreign/session-ir.test.ts +65 -0
- package/src/foreign/truth-table.test.ts +81 -0
- package/src/foreign/truth-table.ts +6 -3
- package/src/foreign/types.ts +162 -2
- package/src/index.facade.test.ts +26 -4
- package/src/index.ts +76 -19
- package/src/market/git-file-writer.test.ts +4 -0
- package/src/market/git-file-writer.ts +7 -1
- package/src/profiles/dsh-profile-launcher.test.ts +68 -3
- package/src/profiles/dsh-profile-launcher.ts +56 -1
- package/src/profiles/dsh-profile-shared.ts +8 -2
- package/src/routes/foreign.ts +8 -1
- package/src/routes/index.ts +1 -1
- package/src/routes/kit.ts +2 -2
- package/src/routes/sync.ts +176 -9
- package/src/schema/manifest-schema.test.ts +87 -2
- package/src/schema/manifest.ts +57 -1
- package/src/schema/types.ts +45 -2
- package/src/sync/autosync-config.test.ts +73 -1
- package/src/sync/autosync-config.ts +124 -0
- package/src/sync/autosync-risk-tier.test.ts +38 -2
- package/src/sync/autosync-scheduler.test.ts +264 -12
- package/src/sync/autosync-scheduler.ts +296 -44
- package/src/sync/gist/gist-conditional-write.test.ts +223 -0
- package/src/sync/gist/gist-transport.ts +73 -7
- package/src/sync/gist/messages.ts +7 -0
- package/src/sync/git/git-probe.test.ts +80 -0
- package/src/sync/git/git-transport.test.ts +185 -0
- package/src/sync/git/git-transport.ts +178 -5
- package/src/sync/merge.test.ts +188 -55
- package/src/sync/merge.ts +282 -224
- package/src/sync/messages.ts +19 -0
- package/src/sync/risk.ts +29 -5
- package/src/sync/s3/messages.ts +13 -0
- package/src/sync/s3/s3-conditional-write.test.ts +330 -0
- package/src/sync/s3/s3-transport.ts +87 -9
- package/src/sync/sync-device-identity.test.ts +267 -0
- package/src/sync/sync-engine.test.ts +379 -0
- package/src/sync/sync-engine.ts +535 -36
- package/src/sync/sync-history.test.ts +16 -1
- package/src/sync/sync-history.ts +26 -0
- package/src/sync/sync-state.test.ts +64 -0
- package/src/sync/sync-state.ts +79 -0
- package/src/sync/sync-watcher.test.ts +197 -0
- package/src/sync/sync-watcher.ts +246 -0
- package/src/sync/text-merge.test.ts +358 -0
- package/src/sync/text-merge.ts +554 -0
- package/src/sync/transport.ts +57 -0
- package/src/sync/watch-scope.test.ts +122 -0
- package/src/sync/watch-scope.ts +120 -0
- package/src/sync/webdav/webdav-conditional-write.test.ts +360 -0
- package/src/sync/webdav/webdav-transport.ts +137 -14
- package/src/ui/foreign-view.test.ts +34 -0
- package/src/ui/foreign-view.ts +10 -0
- package/src/ui/i18n.ts +20 -0
- package/src/ui/rollback-view.ts +14 -0
- package/src/ui/sync-settings-view.test.ts +28 -1
- package/src/ui/sync-settings-view.ts +43 -0
- package/src/ui/types.ts +22 -0
- package/src/utils/env-lock.ts +8 -5
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **监听范围**单测(src/sync/watch-scope.ts)。
|
|
3
|
+
*
|
|
4
|
+
* 盯三件事:
|
|
5
|
+
* 1. 路径必须覆盖「承载可同步分区的落盘位置」,且一律 posix 相对 $DSH_HOME;
|
|
6
|
+
* 2. 绝**不**监听会话日志 / 同步工作副本 / 自身状态文件(自激循环 + 铁律冲突);
|
|
7
|
+
* 3. 噪声判据只吞依赖树、.git 与原子写临时文件 —— 误吞会让真实改动漏同步。
|
|
8
|
+
*/
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
WATCH_IGNORED_SEGMENTS,
|
|
15
|
+
buildWatchTargets,
|
|
16
|
+
isNoiseRelPath,
|
|
17
|
+
targetAbsPath,
|
|
18
|
+
toHomeRelPath,
|
|
19
|
+
} from './watch-scope.ts';
|
|
20
|
+
|
|
21
|
+
const HOME = path.join(path.sep, 'home', 'u', '.dsh');
|
|
22
|
+
|
|
23
|
+
test('buildWatchTargets:默认 profile=web,覆盖全部分区的落盘位置(posix 相对路径)', () => {
|
|
24
|
+
const targets = buildWatchTargets({ homeDir: HOME });
|
|
25
|
+
const rels = targets.map((t) => t.relPath);
|
|
26
|
+
for (const rel of rels) {
|
|
27
|
+
assert.ok(!rel.startsWith('/'), rel + ' 必须是 home 相对路径');
|
|
28
|
+
assert.ok(!rel.includes('\\'), rel + ' 必须用 posix 分隔符');
|
|
29
|
+
assert.ok(!rel.startsWith('./'), rel + ' 不得带 ./ 前缀');
|
|
30
|
+
}
|
|
31
|
+
// 关键落盘点逐条钉住(漏一个 = 那类改动永远要等定时器)
|
|
32
|
+
for (const expected of [
|
|
33
|
+
'settings.yaml',
|
|
34
|
+
'cordis.patch.yml',
|
|
35
|
+
'AGENTS.md',
|
|
36
|
+
'profiles/web/cordis.patch.yml',
|
|
37
|
+
'profiles/web/pnpm-workspace.yaml',
|
|
38
|
+
'profiles/web/package.json',
|
|
39
|
+
'profiles/web/patches',
|
|
40
|
+
'skills',
|
|
41
|
+
'.agent-presets',
|
|
42
|
+
'dsh-config-manager/sync/sync-config.json',
|
|
43
|
+
'dsh-config-manager/sync/sync-selection.json',
|
|
44
|
+
'dsh-config-manager/sync/ui-prefs.json',
|
|
45
|
+
'dsh-config-manager/market/market-config.json',
|
|
46
|
+
'dsh-config-manager/exports/.backup-notes.json',
|
|
47
|
+
]) {
|
|
48
|
+
assert.ok(rels.includes(expected), '缺监听目标:' + expected);
|
|
49
|
+
}
|
|
50
|
+
// 分区可见性:诊断要能说出「这条路径为什么被监听」
|
|
51
|
+
for (const t of targets) assert.ok(t.sections.length > 0, t.relPath + ' 必须声明承载的分区');
|
|
52
|
+
const covered = new Set(targets.flatMap((t) => [...t.sections]));
|
|
53
|
+
for (const id of ['settings', 'providers', 'ui', 'prompts', 'mcp', 'plugins', 'skills', 'agentPresets', 'agentInstructions', 'self']) {
|
|
54
|
+
assert.ok(covered.has(id as never), '分区 ' + id + ' 没有任何监听路径');
|
|
55
|
+
}
|
|
56
|
+
// 目录目标才递归,文件目标恒 false(Windows 上递归 fs.watch 更贵)
|
|
57
|
+
for (const t of targets) {
|
|
58
|
+
if (t.kind === 'file') assert.equal(t.recursive, false, t.relPath + ' 文件目标不得设递归');
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('buildWatchTargets:profile 决定 profile 层路径;空串回落 web', () => {
|
|
63
|
+
const desktop = buildWatchTargets({ homeDir: HOME, profile: 'desktop' }).map((t) => t.relPath);
|
|
64
|
+
assert.ok(desktop.includes('profiles/desktop/cordis.patch.yml'));
|
|
65
|
+
assert.ok(!desktop.includes('profiles/web/cordis.patch.yml'), '不得同时监听别的 profile');
|
|
66
|
+
const empty = buildWatchTargets({ homeDir: HOME, profile: '' }).map((t) => t.relPath);
|
|
67
|
+
assert.ok(empty.includes('profiles/web/cordis.patch.yml'), '空 profile 回落 web(与 core/backup.ts 同口径)');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('buildWatchTargets:绝不监听 sessions / 工作副本 / 自身状态(自激循环与铁律)', () => {
|
|
71
|
+
const rels = buildWatchTargets({ homeDir: HOME }).map((t) => t.relPath);
|
|
72
|
+
assert.ok(!rels.includes(''), '绝不监听 home 根本身(会递归吃到 sessions/ 与 node_modules)');
|
|
73
|
+
assert.ok(!rels.some((r) => r === '.' || r === './'), '绝不监听 home 根本身');
|
|
74
|
+
assert.ok(!rels.some((r) => r.split('/').includes('sessions')), '会话日志可能几 GB,必须等定时器');
|
|
75
|
+
assert.ok(!rels.includes('profiles'), '绝不递归 profiles/(含整棵 pnpm 依赖树)');
|
|
76
|
+
// 调度器每轮都改写这些文件 → 监听即自激
|
|
77
|
+
for (const selfState of [
|
|
78
|
+
'dsh-config-manager/sync/sync-state.json',
|
|
79
|
+
'dsh-config-manager/sync/sync-history.json',
|
|
80
|
+
'dsh-config-manager/sync/sync-autosync.json',
|
|
81
|
+
'dsh-config-manager/sync/backup-schedule.json',
|
|
82
|
+
]) {
|
|
83
|
+
assert.ok(!rels.includes(selfState), '绝不要监听自身状态文件:' + selfState);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('toHomeRelPath:home 内给 posix 相对路径,home 外与交叉盘给 null', () => {
|
|
88
|
+
assert.equal(toHomeRelPath(HOME, path.join(HOME, 'skills', 'a', 'SKILL.md')), 'skills/a/SKILL.md');
|
|
89
|
+
assert.equal(toHomeRelPath(HOME, path.join(HOME, 'settings.yaml')), 'settings.yaml');
|
|
90
|
+
assert.equal(toHomeRelPath(HOME, HOME), null, 'home 自身没有相对路径');
|
|
91
|
+
assert.equal(toHomeRelPath(HOME, path.join(HOME, '..', 'other', 'x.json')), null, 'home 外一律 null');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('isNoiseRelPath:只吞依赖树 / .git / 原子写临时文件(宁可多跑一轮,绝不误吞真实改动)', () => {
|
|
95
|
+
for (const noise of [
|
|
96
|
+
'',
|
|
97
|
+
'node_modules/pkg/index.js',
|
|
98
|
+
'profiles/web/node_modules/.bin/x',
|
|
99
|
+
'skills/.git/HEAD',
|
|
100
|
+
'settings.yaml.tmp-1234-0',
|
|
101
|
+
'dsh-config-manager/sync/sync-config.json.tmp-9-1',
|
|
102
|
+
]) {
|
|
103
|
+
assert.equal(isNoiseRelPath(noise), true, noise + ' 应视为噪声');
|
|
104
|
+
}
|
|
105
|
+
for (const real of [
|
|
106
|
+
'settings.yaml',
|
|
107
|
+
'AGENTS.md',
|
|
108
|
+
'skills/nodejs-testing/SKILL.md',
|
|
109
|
+
'profiles/web/patches/fix.patch',
|
|
110
|
+
'dsh-config-manager/sync/sync-config.json',
|
|
111
|
+
'.agent-presets/reviewer.json',
|
|
112
|
+
]) {
|
|
113
|
+
assert.equal(isNoiseRelPath(real), false, real + ' 是真实改动,不得过滤');
|
|
114
|
+
}
|
|
115
|
+
assert.deepEqual([...WATCH_IGNORED_SEGMENTS], ['node_modules', '.git']);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('targetAbsPath:home + posix 相对路径还原成本机绝对路径', () => {
|
|
119
|
+
const target = buildWatchTargets({ homeDir: HOME }).find((t) => t.relPath === 'profiles/web/patches');
|
|
120
|
+
assert.ok(target !== undefined);
|
|
121
|
+
assert.equal(targetAbsPath(HOME, target!), path.join(HOME, 'profiles', 'web', 'patches'));
|
|
122
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* m-autosync-realtime:**监听范围**(纯函数,host 侧)。
|
|
3
|
+
*
|
|
4
|
+
* 自动同步要「像 VS Code 一样」在本地改动后立刻上行,就必须知道**哪些磁盘路径承载了
|
|
5
|
+
* 可同步的分区内容**。这份映射不能从注册表的 ZIP 内路径推出来 —— 两者是两件事:
|
|
6
|
+
* - 注册表 `SECTION_JSON_PATHS` / `SECTION_FILE_PREFIXES` 说的是**快照 ZIP 内部**的布局;
|
|
7
|
+
* - 真正被监视的是 `$DSH_HOME` 下的落盘位置(`skills/`、`settings.yaml`、
|
|
8
|
+
* `profiles/<p>/cordis.patch.yml` … 见 `src/core/backup.ts` 的 FILE_BASES 与
|
|
9
|
+
* `src/core/patch-layers.ts` 的两层 patch)。
|
|
10
|
+
*
|
|
11
|
+
* 因此本文件是「分区 → home 相对路径」的**唯一静态表**,只做事实声明,不做 I/O。
|
|
12
|
+
* 监听器(`sync-watcher.ts`)只负责 fs.watch + 去抖;判定「变了哪些分区」始终回到引擎的
|
|
13
|
+
* `hasLocalChanges()`(按分区 hash 比对基线),这里绝不猜。
|
|
14
|
+
*
|
|
15
|
+
* 关键取舍(勿“优化”成监听整个 home):
|
|
16
|
+
* - **不递归监听 `$DSH_HOME` 根**:`sessions/`(会话日志,可能几个 GB)、
|
|
17
|
+
* `node_modules`、`dsh-config-manager/{sync/work,snapshots,exports,tmp}` 全在里面,
|
|
18
|
+
* 一旦监听就会自己把自己点着(同步写工作副本 → 触发 → 再同步),且与「sessions 绝不自动
|
|
19
|
+
* 上下行」的铁律直接冲突;
|
|
20
|
+
* - **只监听 `profiles/<p>` 下的具体文件与 `patches/`**,绝不递归 `profiles/`:
|
|
21
|
+
* 那下面有整个 pnpm 依赖树;
|
|
22
|
+
* - `self` 分区只监听白名单里那几个 `*.json`(与 `src/adapters/self.ts` 的
|
|
23
|
+
* SELF_CONFIG_FILES 对齐),**刻意不监听 `sync/sync-state.json` / `sync/sync-history.json` /
|
|
24
|
+
* `sync/sync-autosync.json` / `sync/backup-schedule.json`** —— 调度器每轮都会改写它们,
|
|
25
|
+
* 监听就是自激循环;
|
|
26
|
+
* - `node_modules` / `.git` / 原子写临时文件在任何层级都被过滤。
|
|
27
|
+
*/
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
import type { SectionId } from '../schema/types.ts';
|
|
31
|
+
|
|
32
|
+
/** 监听目标的形态:单个文件,或(递归)目录。 */
|
|
33
|
+
export type WatchTargetKind = 'file' | 'dir';
|
|
34
|
+
|
|
35
|
+
export interface WatchTarget {
|
|
36
|
+
/** home 相对路径,posix 分隔符(Windows 上也用 `/`,便于比较与日志) */
|
|
37
|
+
relPath: string;
|
|
38
|
+
kind: WatchTargetKind;
|
|
39
|
+
/** 目录目标是否递归(file 恒 false) */
|
|
40
|
+
recursive: boolean;
|
|
41
|
+
/** 该路径承载的分区(诊断/日志用,不参与「是否触发」判定) */
|
|
42
|
+
sections: readonly SectionId[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 组装监听目标所需的宿主事实(只读)。 */
|
|
46
|
+
export interface WatchScopeInput {
|
|
47
|
+
/** `$DSH_HOME` 绝对路径 */
|
|
48
|
+
homeDir: string;
|
|
49
|
+
/** 当前 profile(决定 plugins / prompts / mcp 的落盘位置);缺省 'web'(与 core/backup.ts 一致) */
|
|
50
|
+
profile?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 任意层级都要过滤的目录名(依赖树、git 内部、同步工作副本)。 */
|
|
54
|
+
export const WATCH_IGNORED_SEGMENTS: readonly string[] = ['node_modules', '.git'];
|
|
55
|
+
|
|
56
|
+
/** 任意层级都要过滤的文件名模式(原子写临时文件:`x.json.tmp-1234-0`)。 */
|
|
57
|
+
const TMP_FILE_RE = /\.tmp-/u;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 组装监听目标清单。
|
|
61
|
+
*
|
|
62
|
+
* 清单顺序即日志顺序(稳定,便于测试与排查);路径一律 posix 相对 `$DSH_HOME`。
|
|
63
|
+
*/
|
|
64
|
+
export function buildWatchTargets(input: WatchScopeInput): WatchTarget[] {
|
|
65
|
+
const profile = input.profile !== undefined && input.profile !== '' ? input.profile : 'web';
|
|
66
|
+
const profileDir = `profiles/${profile}`;
|
|
67
|
+
const dataBase = 'dsh-config-manager';
|
|
68
|
+
return [
|
|
69
|
+
// settings / providers / ui:同一个 settings.yaml 的顶层 namespace
|
|
70
|
+
{ relPath: 'settings.yaml', kind: 'file', recursive: false, sections: ['settings', 'providers', 'ui'] },
|
|
71
|
+
// prompts / mcp:两层组合 patch 的 home 层
|
|
72
|
+
{ relPath: 'cordis.patch.yml', kind: 'file', recursive: false, sections: ['prompts', 'mcp'] },
|
|
73
|
+
// agentInstructions:home 根 AGENTS.md
|
|
74
|
+
{ relPath: 'AGENTS.md', kind: 'file', recursive: false, sections: ['agentInstructions'] },
|
|
75
|
+
// prompts / mcp / plugins:两层组合 patch 的 profile 层 + pnpm 工作区声明 + profile manifest
|
|
76
|
+
{ relPath: `${profileDir}/cordis.patch.yml`, kind: 'file', recursive: false, sections: ['prompts', 'mcp', 'plugins'] },
|
|
77
|
+
{ relPath: `${profileDir}/pnpm-workspace.yaml`, kind: 'file', recursive: false, sections: ['plugins'] },
|
|
78
|
+
{ relPath: `${profileDir}/package.json`, kind: 'file', recursive: false, sections: ['plugins'] },
|
|
79
|
+
// plugins:pnpm patch 文件(`patchFile:<profile 相对路径>`)
|
|
80
|
+
{ relPath: `${profileDir}/patches`, kind: 'dir', recursive: true, sections: ['plugins'] },
|
|
81
|
+
// skills / agentPresets:整棵目录
|
|
82
|
+
{ relPath: 'skills', kind: 'dir', recursive: true, sections: ['skills'] },
|
|
83
|
+
{ relPath: '.agent-presets', kind: 'dir', recursive: true, sections: ['agentPresets'] },
|
|
84
|
+
// self:**只**监听白名单配置文件(与 adapters/self.ts 的 SELF_CONFIG_FILES 对齐)。
|
|
85
|
+
// 刻意不含 sync-state.json / sync-history.json / sync-autosync.json / backup-schedule.json。
|
|
86
|
+
{ relPath: `${dataBase}/sync/sync-config.json`, kind: 'file', recursive: false, sections: ['self'] },
|
|
87
|
+
{ relPath: `${dataBase}/sync/sync-selection.json`, kind: 'file', recursive: false, sections: ['self'] },
|
|
88
|
+
{ relPath: `${dataBase}/sync/ui-prefs.json`, kind: 'file', recursive: false, sections: ['self'] },
|
|
89
|
+
{ relPath: `${dataBase}/market/market-config.json`, kind: 'file', recursive: false, sections: ['self'] },
|
|
90
|
+
{ relPath: `${dataBase}/exports/.backup-notes.json`, kind: 'file', recursive: false, sections: ['self'] },
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 把任一绝对路径转成 home 相对 posix 路径;不在 home 内 → null。 */
|
|
95
|
+
export function toHomeRelPath(homeDir: string, absPath: string): string | null {
|
|
96
|
+
const rel = path.relative(homeDir, absPath);
|
|
97
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
98
|
+
return rel.split(path.sep).join('/');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 该 home 相对路径是否属于「绝不该触发同步」的噪声。
|
|
103
|
+
*
|
|
104
|
+
* 判据只有两类(宁少不多:漏过滤只是多跑一轮空同步,误过滤会漏掉真实改动):
|
|
105
|
+
* 依赖树 / git 内部目录,以及原子写临时文件。
|
|
106
|
+
*/
|
|
107
|
+
export function isNoiseRelPath(relPath: string): boolean {
|
|
108
|
+
if (relPath === '') return true;
|
|
109
|
+
const segments = relPath.split('/');
|
|
110
|
+
for (const segment of segments) {
|
|
111
|
+
if (WATCH_IGNORED_SEGMENTS.includes(segment)) return true;
|
|
112
|
+
}
|
|
113
|
+
const base = segments[segments.length - 1] ?? '';
|
|
114
|
+
return TMP_FILE_RE.test(base);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 监视目标的绝对路径。 */
|
|
118
|
+
export function targetAbsPath(homeDir: string, target: WatchTarget): string {
|
|
119
|
+
return path.join(homeDir, target.relPath.split('/').join(path.sep));
|
|
120
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebDAV 通道:本轮借鉴项的**针对性**单测(不重复 webdav-transport.test.ts 的既有契约)。
|
|
3
|
+
* - ① 条件写:索引 ETag → If-Match;索引缺失且远端无内容 → If-None-Match: *;
|
|
4
|
+
* 索引 404 但远端已有内容 → 零写入中止(既有行为的回归钉子);
|
|
5
|
+
* - ① 索引 PUT 回 412 → SyncTransportError(kind='conflict'),文案来自字典键 sync.webdav.concurrentWrite;
|
|
6
|
+
* - ③ 首连逐级 MKCOL:直连 409 → 对 baseUrl 每一层发 MKCOL → 重试目标集合;
|
|
7
|
+
* probe() 的 createdCollections **只含本次真建出来的层级**;
|
|
8
|
+
* - ② 探针:PROPFIND 401/403 必须抛错,绝不谎报连接正常。
|
|
9
|
+
*/
|
|
10
|
+
import test from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
|
|
13
|
+
import { WebDavTransport, WebDavTransportError } from './webdav-transport.ts';
|
|
14
|
+
import type { WebDavRequestFn, WebDavResponse, WebDavTransportOptions } from './webdav-transport.ts';
|
|
15
|
+
import { SyncTransportError } from '../transport.ts';
|
|
16
|
+
import type { SyncSnapshot } from '../transport.ts';
|
|
17
|
+
import { zhMsg } from '../../core/messages.ts';
|
|
18
|
+
|
|
19
|
+
const BASE = 'https://dav.example.com/dav/config';
|
|
20
|
+
const COL = BASE + '/dsh-config-manager';
|
|
21
|
+
const COL_URL = COL + '/';
|
|
22
|
+
const INDEX_URL = COL + '/index.json';
|
|
23
|
+
const BLOBS_INDEX_URL = COL + '/blobs-index.json';
|
|
24
|
+
const SNAPSHOT_URL = COL + '/snap-001.json';
|
|
25
|
+
const TEST_PASSWORD = 'super-secret-password-9876';
|
|
26
|
+
|
|
27
|
+
interface MockCall { method: string; url: string; headers?: Record<string, string>; body?: string }
|
|
28
|
+
|
|
29
|
+
function res(status: number, bodyText = '', headers?: Record<string, string>): WebDavResponse {
|
|
30
|
+
return {
|
|
31
|
+
status,
|
|
32
|
+
ok: status >= 200 && status < 300,
|
|
33
|
+
...(headers !== undefined ? { headers } : {}),
|
|
34
|
+
async text() { return bodyText; },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function makeOptions(request: WebDavRequestFn, overrides: Partial<WebDavTransportOptions> = {}): WebDavTransportOptions {
|
|
39
|
+
return {
|
|
40
|
+
baseUrl: BASE,
|
|
41
|
+
username: 'alice',
|
|
42
|
+
credentials: { getPassword: async () => TEST_PASSWORD },
|
|
43
|
+
request,
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 记录每次请求的 mock 包装(请求头一并记录,条件写断言依赖它) */
|
|
49
|
+
function recorder(calls: MockCall[], handler: (m: MockCall) => WebDavResponse): WebDavRequestFn {
|
|
50
|
+
return async (method, url, opts = {}) => {
|
|
51
|
+
const rec: MockCall = { method, url, headers: opts.headers, body: opts.body };
|
|
52
|
+
calls.push(rec);
|
|
53
|
+
return handler(rec);
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sampleSnapshot(overrides: Partial<SyncSnapshot> = {}): SyncSnapshot {
|
|
58
|
+
return {
|
|
59
|
+
id: 'snap-001',
|
|
60
|
+
createdAt: '2026-10-18T10:00:00.000Z',
|
|
61
|
+
manifest: {
|
|
62
|
+
schemaVersion: 1,
|
|
63
|
+
dshVersion: '1.2.3',
|
|
64
|
+
platform: 'win32',
|
|
65
|
+
sectionIds: ['settings', 'providers'],
|
|
66
|
+
containsSecrets: false,
|
|
67
|
+
},
|
|
68
|
+
sections: {
|
|
69
|
+
settings: { version: 1, namespaces: { general: { value: { theme: 'dark' }, revision: 1, secrets: [] } } },
|
|
70
|
+
providers: { version: 1, providers: { deepseek: { route: '/v1' } } },
|
|
71
|
+
},
|
|
72
|
+
...overrides,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const putIndexCalls = (calls: MockCall[]): MockCall[] => calls.filter((c) => c.method === 'PUT' && c.url === INDEX_URL);
|
|
77
|
+
const mkcolUrls = (calls: MockCall[]): string[] => calls.filter((c) => c.method === 'MKCOL').map((c) => c.url);
|
|
78
|
+
|
|
79
|
+
/* ---------------- ① 条件写 ---------------- */
|
|
80
|
+
|
|
81
|
+
test('条件写①:GET index 回 ETag → 随后的索引 PUT 带 If-Match 钉住该 ETag', async () => {
|
|
82
|
+
const calls: MockCall[] = [];
|
|
83
|
+
const ETAG = '"v1-abc123"';
|
|
84
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
85
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
86
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(200, '[]', { etag: ETAG });
|
|
87
|
+
if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(201, '');
|
|
88
|
+
if (m.method === 'PUT' && m.url === INDEX_URL) return res(201, '');
|
|
89
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
90
|
+
})));
|
|
91
|
+
|
|
92
|
+
await t.upload(sampleSnapshot());
|
|
93
|
+
|
|
94
|
+
const puts = putIndexCalls(calls);
|
|
95
|
+
assert.equal(puts.length, 1, '索引必须只 PUT 一次');
|
|
96
|
+
assert.equal(puts[0]!.headers?.['If-Match'], ETAG, '有 ETag 时必须用 If-Match 钉住读到的那一版');
|
|
97
|
+
assert.equal(puts[0]!.headers?.['If-None-Match'], undefined, '有 ETag 时不得同时发 If-None-Match');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('条件写①:索引 404 且远端确无内容(blobs-index 也 404)→ PUT 带 If-None-Match: *', async () => {
|
|
101
|
+
const calls: MockCall[] = [];
|
|
102
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
103
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
104
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(404, '');
|
|
105
|
+
if (m.method === 'GET' && m.url === BLOBS_INDEX_URL) return res(404, '');
|
|
106
|
+
if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(201, '');
|
|
107
|
+
if (m.method === 'PUT' && m.url === INDEX_URL) return res(201, '');
|
|
108
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
109
|
+
})));
|
|
110
|
+
|
|
111
|
+
await t.upload(sampleSnapshot());
|
|
112
|
+
|
|
113
|
+
const puts = putIndexCalls(calls);
|
|
114
|
+
assert.equal(puts.length, 1);
|
|
115
|
+
assert.equal(puts[0]!.headers?.['If-None-Match'], '*', '首推(远端无内容)必须用 If-None-Match: * 防两设备同时首推');
|
|
116
|
+
assert.equal(puts[0]!.headers?.['If-Match'], undefined);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('回归:索引 404 但远端已有内容(blobs-index 有记录)→ 任何字节都不写就中止', async () => {
|
|
120
|
+
const calls: MockCall[] = [];
|
|
121
|
+
const blobHash = 'a'.repeat(64);
|
|
122
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
123
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
124
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(404, '');
|
|
125
|
+
if (m.method === 'GET' && m.url === BLOBS_INDEX_URL) return res(200, JSON.stringify({ [blobHash]: 1 }));
|
|
126
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
127
|
+
})));
|
|
128
|
+
|
|
129
|
+
await assert.rejects(
|
|
130
|
+
() => t.upload(sampleSnapshot()),
|
|
131
|
+
(err: unknown) => {
|
|
132
|
+
assert.ok(err instanceof WebDavTransportError, '必须是 WebDavTransportError');
|
|
133
|
+
assert.equal(err.kind, 'protocol', '缺信息而不是事实 → protocol(不可重试)');
|
|
134
|
+
assert.equal(err.retryable, false);
|
|
135
|
+
return true;
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
const writes = calls.filter((c) => c.method !== 'MKCOL' && c.method !== 'GET');
|
|
139
|
+
assert.deepEqual(writes.map((c) => c.method), [], '零写入:索引 404 + 远端有内容时不得有任何 PUT/DELETE');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('条件写①:索引 PUT 回 412 → 抛 conflict,文案来自字典键 sync.webdav.concurrentWrite', async () => {
|
|
143
|
+
const calls: MockCall[] = [];
|
|
144
|
+
const ETAG = '"v1-abc123"';
|
|
145
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
146
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
147
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(200, '[]', { etag: ETAG });
|
|
148
|
+
if (m.method === 'PUT' && m.url === SNAPSHOT_URL) return res(201, '');
|
|
149
|
+
if (m.method === 'PUT' && m.url === INDEX_URL) return res(412, 'precondition failed');
|
|
150
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
151
|
+
})));
|
|
152
|
+
|
|
153
|
+
await assert.rejects(
|
|
154
|
+
() => t.upload(sampleSnapshot()),
|
|
155
|
+
(err: unknown) => {
|
|
156
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
157
|
+
assert.ok(err instanceof SyncTransportError, '必须是统一错误基类(上层按 kind 分流)');
|
|
158
|
+
assert.equal(err.kind, 'conflict');
|
|
159
|
+
assert.equal(err.retryable, false, '并发冲突需人工重跑,绝不自动重试');
|
|
160
|
+
assert.equal(err.status, 412);
|
|
161
|
+
assert.equal(
|
|
162
|
+
err.message,
|
|
163
|
+
zhMsg('sync.webdav.concurrentWrite', { status: '412', url: INDEX_URL }),
|
|
164
|
+
'文案必须来自字典键(不是硬编码)',
|
|
165
|
+
);
|
|
166
|
+
assert.match(err.message, /412/);
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('条件写①(删除路径):delete 读 index 拿 ETag → 写回剩余索引的 PUT 带 If-Match', async () => {
|
|
173
|
+
const calls: MockCall[] = [];
|
|
174
|
+
const ETAG = '"v1-del"';
|
|
175
|
+
const meta = {
|
|
176
|
+
id: 'snap-001',
|
|
177
|
+
createdAt: '2026-10-18T09:00:00.000Z',
|
|
178
|
+
sections: {},
|
|
179
|
+
manifest: { schemaVersion: 1, dshVersion: '1', platform: 'win32', sectionIds: [], containsSecrets: false },
|
|
180
|
+
};
|
|
181
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
182
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(200, JSON.stringify([meta]), { etag: ETAG });
|
|
183
|
+
if (m.method === 'DELETE' && m.url === SNAPSHOT_URL) return res(204, '');
|
|
184
|
+
if (m.method === 'PUT' && m.url === INDEX_URL) return res(201, '');
|
|
185
|
+
if (m.method === 'GET' && m.url === BLOBS_INDEX_URL) return res(404, '');
|
|
186
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
187
|
+
})));
|
|
188
|
+
|
|
189
|
+
await t.delete('snap-001');
|
|
190
|
+
|
|
191
|
+
const puts = putIndexCalls(calls);
|
|
192
|
+
assert.equal(puts.length, 1, '删除后必须写回一次索引');
|
|
193
|
+
assert.equal(puts[0]!.headers?.['If-Match'], ETAG, '删除路径的索引写回同样是条件写');
|
|
194
|
+
assert.equal(puts[0]!.headers?.['If-None-Match'], undefined);
|
|
195
|
+
assert.equal(puts[0]!.body, '[]', '剩余索引确实摘掉了目标条目');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('条件写①(删除路径):索引写回回 412 → conflict(绝不静默吞掉并发冲突)', async () => {
|
|
199
|
+
const calls: MockCall[] = [];
|
|
200
|
+
const meta = {
|
|
201
|
+
id: 'snap-001',
|
|
202
|
+
createdAt: '2026-10-18T09:00:00.000Z',
|
|
203
|
+
sections: {},
|
|
204
|
+
manifest: { schemaVersion: 1, dshVersion: '1', platform: 'win32', sectionIds: [], containsSecrets: false },
|
|
205
|
+
};
|
|
206
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
207
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(200, JSON.stringify([meta]), { etag: '"v1-del"' });
|
|
208
|
+
if (m.method === 'DELETE' && m.url === SNAPSHOT_URL) return res(204, '');
|
|
209
|
+
if (m.method === 'PUT' && m.url === INDEX_URL) return res(412, 'precondition failed');
|
|
210
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
211
|
+
})));
|
|
212
|
+
|
|
213
|
+
await assert.rejects(
|
|
214
|
+
() => t.delete('snap-001'),
|
|
215
|
+
(err: unknown) => {
|
|
216
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
217
|
+
assert.equal(err.kind, 'conflict');
|
|
218
|
+
assert.equal(err.status, 412);
|
|
219
|
+
assert.equal(err.message, zhMsg('sync.webdav.concurrentWrite', { status: '412', url: INDEX_URL }));
|
|
220
|
+
return true;
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
/* ---------------- ③ 首连逐级 MKCOL ---------------- */
|
|
226
|
+
|
|
227
|
+
test('③ 首连自建:MKCOL 首次 409 → 逐层补齐 baseUrl → 重试目标集合;probe 只回报本次真建的层级', async () => {
|
|
228
|
+
const calls: MockCall[] = [];
|
|
229
|
+
const DAV_URL = 'https://dav.example.com/dav/';
|
|
230
|
+
const CONFIG_URL = 'https://dav.example.com/dav/config/';
|
|
231
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
232
|
+
if (m.method === 'MKCOL' && m.url === COL_URL) {
|
|
233
|
+
// 首连 = 缺父目录(409);重试 = 建成功(201)
|
|
234
|
+
const prior = calls.filter((c) => c.method === 'MKCOL' && c.url === COL_URL).length;
|
|
235
|
+
return prior === 1 ? res(409, 'Conflict') : res(201, '');
|
|
236
|
+
}
|
|
237
|
+
if (m.method === 'MKCOL' && m.url === DAV_URL) return res(201, ''); // 本次真建
|
|
238
|
+
if (m.method === 'MKCOL' && m.url === CONFIG_URL) return res(405, ''); // 早就存在 → 不回报
|
|
239
|
+
if (m.method === 'PROPFIND') return res(207, '');
|
|
240
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(404, '');
|
|
241
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
242
|
+
})));
|
|
243
|
+
|
|
244
|
+
const result = await t.probe();
|
|
245
|
+
|
|
246
|
+
assert.deepEqual(mkcolUrls(calls), [COL_URL, DAV_URL, CONFIG_URL, COL_URL],
|
|
247
|
+
'MKCOL 顺序 = 首连目标集合(409) → baseUrl 每一层(top-down) → 重试目标集合');
|
|
248
|
+
assert.deepEqual(result.createdCollections, [DAV_URL, COL_URL],
|
|
249
|
+
'createdCollections = 本次所有真建出来的目录(含重试建出的目标集合;405 的 config/ 不进)');
|
|
250
|
+
assert.equal(result.snapshotCount, 0, '索引 404(全新远端)= 0 份快照');
|
|
251
|
+
assert.equal(result.message, zhMsg('sync.webdav.probeOk', { count: '0' }));
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('③ 首连自建:重试仍回 409(网盘「已存在」语义)→ 按成功接受,不谎报已创建', async () => {
|
|
255
|
+
const calls: MockCall[] = [];
|
|
256
|
+
const DAV_URL = 'https://dav.example.com/dav/';
|
|
257
|
+
const CONFIG_URL = 'https://dav.example.com/dav/config/';
|
|
258
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
259
|
+
if (m.method === 'MKCOL' && m.url === COL_URL) return res(409, 'Conflict'); // 首连与重试都 409
|
|
260
|
+
if (m.method === 'MKCOL' && m.url === DAV_URL) return res(405, ''); // 上级已存在
|
|
261
|
+
if (m.method === 'MKCOL' && m.url === CONFIG_URL) return res(409, ''); // 上级已存在(网盘回 409)
|
|
262
|
+
if (m.method === 'PROPFIND') return res(207, '');
|
|
263
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(404, '');
|
|
264
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
265
|
+
})));
|
|
266
|
+
|
|
267
|
+
const result = await t.probe();
|
|
268
|
+
assert.deepEqual(mkcolUrls(calls), [COL_URL, DAV_URL, CONFIG_URL, COL_URL],
|
|
269
|
+
'重试确实发生(不是首连 409 就直接放弃)');
|
|
270
|
+
assert.equal(result.createdCollections, undefined,
|
|
271
|
+
'没有任何一层真返回 201/200/204 → 省略 createdCollections(不谎报「已创建」)');
|
|
272
|
+
assert.equal(result.snapshotCount, 0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('③ 首连失败(非 409):401/403/5xx 必须立即抛错,不进入逐级自建', async () => {
|
|
276
|
+
for (const status of [401, 403, 500]) {
|
|
277
|
+
const calls: MockCall[] = [];
|
|
278
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
279
|
+
if (m.method === 'MKCOL') return res(status, 'nope');
|
|
280
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
281
|
+
})));
|
|
282
|
+
|
|
283
|
+
await assert.rejects(
|
|
284
|
+
() => t.probe(),
|
|
285
|
+
(err: unknown) => {
|
|
286
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
287
|
+
assert.equal(err.status, status);
|
|
288
|
+
assert.equal(err.kind, status >= 500 ? 'server' : 'auth');
|
|
289
|
+
return true;
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
assert.equal(calls.filter((c) => c.method === 'MKCOL').length, 1,
|
|
293
|
+
'非 409 的首连失败不得触发逐级 MKCOL(更不得吞成成功)');
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
/* ---------------- ② 探针 ---------------- */
|
|
298
|
+
|
|
299
|
+
for (const status of [401, 403]) {
|
|
300
|
+
test('② 探针:PROPFIND ' + status + ' → 抛错(kind=auth),绝不谎报连接正常', async () => {
|
|
301
|
+
const calls: MockCall[] = [];
|
|
302
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
303
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
304
|
+
if (m.method === 'PROPFIND') return res(status, 'denied');
|
|
305
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
306
|
+
})));
|
|
307
|
+
|
|
308
|
+
await assert.rejects(
|
|
309
|
+
() => t.probe(),
|
|
310
|
+
(err: unknown) => {
|
|
311
|
+
assert.ok(err instanceof WebDavTransportError);
|
|
312
|
+
assert.equal(err.kind, 'auth');
|
|
313
|
+
assert.equal(err.retryable, false);
|
|
314
|
+
assert.equal(err.status, status);
|
|
315
|
+
assert.equal(err.message, zhMsg('sync.webdav.probeUnreachable', { status: String(status), url: COL_URL }));
|
|
316
|
+
return true;
|
|
317
|
+
},
|
|
318
|
+
);
|
|
319
|
+
assert.equal(calls.some((c) => c.method === 'GET'), false,
|
|
320
|
+
'PROPFIND 鉴权失败后不得继续读索引(更不得回报连接正常)');
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
test('② 探针:服务端不支持 PROPFIND(405)→ 退回索引作证据,不算失败', async () => {
|
|
325
|
+
const calls: MockCall[] = [];
|
|
326
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
327
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
328
|
+
if (m.method === 'PROPFIND') return res(405, '');
|
|
329
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(200, '[]');
|
|
330
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
331
|
+
})));
|
|
332
|
+
|
|
333
|
+
const result = await t.probe();
|
|
334
|
+
assert.equal(result.snapshotCount, 0);
|
|
335
|
+
assert.equal(result.createdCollections, undefined, '本次没有新建层级 → 省略该字段');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('② 探针:PROPFIND 207 + index 有 2 条 → snapshotCount=2、message 带计数', async () => {
|
|
339
|
+
const calls: MockCall[] = [];
|
|
340
|
+
const mkMeta = (id: string, createdAt: string): Record<string, unknown> => ({
|
|
341
|
+
id,
|
|
342
|
+
createdAt,
|
|
343
|
+
sections: {},
|
|
344
|
+
manifest: { schemaVersion: 1, dshVersion: '1', platform: 'win32', sectionIds: [], containsSecrets: false },
|
|
345
|
+
});
|
|
346
|
+
const metas = [mkMeta('snap-a', '2026-10-18T08:00:00.000Z'), mkMeta('snap-b', '2026-10-18T09:00:00.000Z')];
|
|
347
|
+
const t = new WebDavTransport(makeOptions(recorder(calls, (m) => {
|
|
348
|
+
if (m.method === 'MKCOL') return res(201, '');
|
|
349
|
+
if (m.method === 'PROPFIND') return res(207, '');
|
|
350
|
+
if (m.method === 'GET' && m.url === INDEX_URL) return res(200, JSON.stringify(metas));
|
|
351
|
+
return res(500, 'unexpected ' + m.method + ' ' + m.url);
|
|
352
|
+
})));
|
|
353
|
+
|
|
354
|
+
const result = await t.probe();
|
|
355
|
+
assert.equal(result.snapshotCount, 2);
|
|
356
|
+
assert.equal(result.message, zhMsg('sync.webdav.probeOk', { count: '2' }));
|
|
357
|
+
assert.equal(calls.filter((c) => c.method === 'PROPFIND').length, 1);
|
|
358
|
+
assert.equal(calls.filter((c) => c.method === 'PUT' || c.method === 'DELETE').length, 0,
|
|
359
|
+
'探针零写入(索引与快照都不写)');
|
|
360
|
+
});
|