@charara97/dsh-session-sync 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,373 @@
1
+ /**
2
+ * 端到端冒烟测试:在临时目录里造两台「机器」和一个裸仓库当远端,
3
+ * 验证 push / pull / 单会话范围 / 会话分叉冲突 / 冲突裁决 / 工作区同步 这些真实路径。
4
+ *
5
+ * 运行:node tests/engine.test.mjs
6
+ * 它只碰自己创建的临时目录,不会读写真实的 ~/.dsh,也不会改 ~/.gitconfig。
7
+ */
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { randomUUID } from 'node:crypto';
13
+ import { SyncEngine } from '../lib/engine.js';
14
+ import { sessionSlugFromCwd } from '../lib/session.js';
15
+ import { zstdEncode, zstdDecode, run } from '../lib/util.js';
16
+
17
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'dcs-test-'));
18
+ const remoteDir = path.join(root, 'remote.git');
19
+ const results = [];
20
+
21
+ function step(name, fn) {
22
+ return Promise.resolve()
23
+ .then(fn)
24
+ .then(() => { results.push(['ok', name]); process.stdout.write(` ✓ ${name}\n`); })
25
+ .catch((err) => { results.push(['fail', name, err]); process.stdout.write(` ✗ ${name}\n ${err?.message || err}\n`); });
26
+ }
27
+
28
+ function userMsg(text, seq, time) {
29
+ return { type: 'user/message', seq, time, data: { content: [{ type: 'text', text }], role: 'user', id: randomUUID() }, surfaceOp: 'append' };
30
+ }
31
+ function assistantMsg(text, seq, time) {
32
+ return { type: 'assistant/message', seq, time, data: { turn: 1, step: seq, message: { role: 'assistant', content: [{ type: 'text', text }] } }, surfaceOp: 'append' };
33
+ }
34
+
35
+ function sessionBuffer(id, cwd, events = []) {
36
+ const header = { type: 'session', version: 4, id, createdAt: 1791000000000, cwd, isSeeded: false, delegationDepth: 0, agentPreset: 'standard' };
37
+ return zstdEncode([header, ...events].map((r) => JSON.stringify(r)).join('\n') + '\n');
38
+ }
39
+
40
+ /** 全新机器:只有一个空的 DSH HOME */
41
+ function makeEmptyHome(name) {
42
+ const home = path.join(root, name);
43
+ fs.mkdirSync(home, { recursive: true });
44
+ return home;
45
+ }
46
+
47
+ /** 已经有本地数据的机器 */
48
+ function makeHome(name, { sessions = [], workspaces = [] } = {}) {
49
+ const home = path.join(root, name);
50
+ fs.mkdirSync(path.join(home, 'sessions'), { recursive: true });
51
+ fs.mkdirSync(path.join(home, 'storages', 'session_projcache', 'sessions'), { recursive: true });
52
+ for (const s of sessions) {
53
+ const dir = path.join(home, 'sessions', sessionSlugFromCwd(s.cwd), s.id);
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ fs.writeFileSync(path.join(dir, 'session.v4.jsonl.zstd'), sessionBuffer(s.id, s.cwd, s.events || []));
56
+ fs.writeFileSync(path.join(home, 'storages', 'session_projcache', 'sessions', s.id + '.json'),
57
+ JSON.stringify({ version: 7, record: { identity: { cwd: s.cwd }, rows: { title: { ver: 1, seq: 1, val: s.title || s.id } } } }));
58
+ }
59
+ const tables = {};
60
+ for (const w of workspaces) {
61
+ tables[w.id] = { path: w.path, title: w.title, sessionIds: w.sessionIds || [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z' };
62
+ }
63
+ fs.writeFileSync(path.join(home, 'storages', 'workspace.json'), JSON.stringify({
64
+ unit: { name: 'workspace', version: 2 },
65
+ global: { initialized: true, workspaceIds: workspaces.map((w) => w.id), archivedSessionIds: [], pinnedSessionIds: [] },
66
+ tables: { workspaces: tables },
67
+ }, null, 2));
68
+ return home;
69
+ }
70
+
71
+ function findSessionFile(home, id) {
72
+ const root2 = path.join(home, 'sessions');
73
+ if (!fs.existsSync(root2)) return null;
74
+ for (const slug of fs.readdirSync(root2)) {
75
+ const p = path.join(root2, slug, id, 'session.v4.jsonl.zstd');
76
+ if (fs.existsSync(p)) return p;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function appendToSession(home, id, events) {
82
+ const file = findSessionFile(home, id);
83
+ assert.ok(file, `找不到会话文件 ${id}`);
84
+ const rows = zstdDecode(fs.readFileSync(file)).toString('utf8').split('\n').filter(Boolean);
85
+ const next = rows.length - 1;
86
+ fs.writeFileSync(file, zstdEncode(rows.concat(events.map((ev, i) => JSON.stringify({ ...ev, seq: next + i }))).join('\n') + '\n'));
87
+ }
88
+
89
+ function readSessionRows(home, id) {
90
+ const p = findSessionFile(home, id);
91
+ if (!p) return null;
92
+ return zstdDecode(fs.readFileSync(p)).toString('utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
93
+ }
94
+
95
+ function textsOf(rows) {
96
+ return rows.map((x) => x.data?.content?.[0]?.text).filter(Boolean);
97
+ }
98
+
99
+ const silent = () => {};
100
+ const S1 = 'session-1111aaaa-0000-4000-8000-000000000001';
101
+ const S2 = 'session-2222bbbb-0000-4000-8000-000000000002';
102
+ const WS_A = 'ws-aaaa-1111-2222-333333333333';
103
+ const CWD_A = '/home/alice/project';
104
+ const PROJ_A = path.join(root, 'machine-a', 'proj');
105
+ const PROJ_B = path.join(root, 'machine-b', 'proj');
106
+
107
+ async function main() {
108
+ process.stdout.write(`临时目录:${root}\n`);
109
+
110
+ fs.mkdirSync(remoteDir, { recursive: true });
111
+ assert.equal((await run('git', ['init', '--bare', '-b', 'main', remoteDir])).code, 0);
112
+ // `file://` 传输需要显式允许;只影响本测试进程,不碰用户真实的 ~/.gitconfig。
113
+ const fakeGlobal = path.join(root, 'gitconfig');
114
+ fs.writeFileSync(fakeGlobal, '[protocol "file"]\n\tallow = always\n');
115
+ process.env.GIT_CONFIG_GLOBAL = fakeGlobal;
116
+
117
+ const REMOTE = 'file://' + remoteDir;
118
+ const cfgOverride = { remote: REMOTE, branch: 'main', gitUserName: 'test', gitUserEmail: 'test@localhost', autoRepo: false };
119
+
120
+ const homeA = makeHome('home-a', {
121
+ sessions: [
122
+ { id: S1, cwd: CWD_A, title: '会话一', events: [userMsg('你好', 0, 1791000001000), assistantMsg('你好呀', 1, 1791000002000)] },
123
+ { id: S2, cwd: CWD_A, title: '会话二', events: [userMsg('第二个会话', 0, 1791000003000)] },
124
+ ],
125
+ workspaces: [{ id: WS_A, path: PROJ_A, title: 'proj', sessionIds: [S1] }],
126
+ });
127
+ fs.mkdirSync(path.join(PROJ_A, 'src'), { recursive: true });
128
+ fs.writeFileSync(path.join(PROJ_A, 'README.md'), 'hello from A\n');
129
+ fs.writeFileSync(path.join(PROJ_A, 'src', 'index.js'), 'console.log(1)\n');
130
+ fs.mkdirSync(path.join(PROJ_A, 'node_modules', 'x'), { recursive: true });
131
+ fs.writeFileSync(path.join(PROJ_A, 'node_modules', 'x', 'index.js'), 'should not sync\n');
132
+
133
+ const engineA = new SyncEngine(homeA, cfgOverride, silent);
134
+
135
+ await step('A:push 全部会话(含工作区)', async () => {
136
+ const r = await engineA.execute({ direction: 'push', scope: 'all', includeWorkspace: true, reason: 'test' });
137
+ assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
138
+ assert.ok(r.result.pushed.length >= 1, '主数据已推送');
139
+ assert.equal(r.result.conflicts.length, 0, '没有冲突');
140
+ });
141
+
142
+ await step('远端确实收到了主分支与工作区分支', async () => {
143
+ const b = await run('git', ['--git-dir', remoteDir, 'branch', '--list', '--format=%(refname:short)']);
144
+ assert.ok(b.out.includes('main'), 'main 分支存在');
145
+ assert.ok(b.out.includes(`ws/${WS_A}`), 'ws 分支存在');
146
+ });
147
+
148
+ await step('A:远端已被推上去的提交里不含 node_modules', async () => {
149
+ const ls = await run('git', ['--git-dir', remoteDir, 'ls-tree', '-r', '--name-only', `ws/${WS_A}`]);
150
+ assert.ok(!ls.out.includes('node_modules'), 'node_modules 未上传');
151
+ assert.ok(ls.out.includes('README.md'), 'README.md 已上传');
152
+ });
153
+
154
+ /* ============ 第二台机器:全新 HOME ============ */
155
+ const homeB = makeEmptyHome('home-b');
156
+ const engineB = new SyncEngine(homeB, { ...cfgOverride, workspaceBase: path.join(root, 'machine-b', 'ws') }, silent);
157
+
158
+ await step('B(全新机器):pull 全部会话并取回工作区文件', async () => {
159
+ const r = await engineB.execute({ direction: 'pull', scope: 'all', includeWorkspace: true, reason: 'test' });
160
+ assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
161
+ const rows = readSessionRows(homeB, S1);
162
+ assert.ok(rows && rows.length === 3, '会话一取回(表头 + 2 事件)');
163
+ const target = path.join(root, 'machine-b', 'ws', 'proj');
164
+ assert.ok(fs.existsSync(path.join(target, 'README.md')), '工作区文件取回');
165
+ assert.ok(fs.existsSync(path.join(target, 'src', 'index.js')), '子目录文件取回');
166
+ assert.ok(!fs.existsSync(path.join(target, 'node_modules')), 'node_modules 未取回');
167
+ });
168
+
169
+ await step('B:全新机器上第一次取回没有产生任何假冲突', async () => {
170
+ const st = await engineB.status();
171
+ assert.equal(st.pending.length, 0, '没有待裁决记录');
172
+ });
173
+
174
+ await step('会话归属:取回的会话被登记在对应工作区', async () => {
175
+ const owner = engineB.sessionToWorkspace();
176
+ assert.equal(owner.get(S1), WS_A, '会话一属于工作区 A');
177
+ });
178
+
179
+ await step('B:单会话 push 只提交该会话', async () => {
180
+ appendToSession(homeB, S2, [assistantMsg('B 追加的内容', 1, 1791000010000)]);
181
+ const r = await engineB.execute({ direction: 'push', scope: 'session', sessionIds: [S2], includeWorkspace: false, reason: 'test' });
182
+ assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
183
+ assert.equal(r.result.commits.length, 1, '只产生一个提交');
184
+ });
185
+
186
+ await step('A:pull 单会话取回 B 的追加内容', async () => {
187
+ const before = readSessionRows(homeA, S2).length;
188
+ const r = await engineA.execute({ direction: 'pull', scope: 'session', sessionIds: [S2], includeWorkspace: false, reason: 'test' });
189
+ assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
190
+ assert.equal(readSessionRows(homeA, S2).length, before + 1, '纯追加的事件被自动合并进来');
191
+ });
192
+
193
+ await step('分叉:两边各自改了同一个会话 → pull 停下报告冲突且不动本机文件', async () => {
194
+ appendToSession(homeA, S1, [userMsg('A 的分支内容', 2, 1791000020000)]);
195
+ const pushA = await engineA.execute({ direction: 'push', scope: 'session', sessionIds: [S1], includeWorkspace: false, reason: 'test' });
196
+ assert.equal(pushA.ok, true, JSON.stringify(pushA.result?.error || pushA.error));
197
+ appendToSession(homeB, S1, [userMsg('B 的分支内容', 2, 1791000021000)]);
198
+ const beforeB = fs.readFileSync(findSessionFile(homeB, S1));
199
+ const pullB = await engineB.execute({ direction: 'pull', scope: 'session', sessionIds: [S1], includeWorkspace: false, reason: 'test' });
200
+ assert.equal(pullB.ok, false, '应当报告未完成');
201
+ assert.equal(pullB.result.conflicts.length, 1, '恰好一项冲突');
202
+ assert.equal(pullB.result.conflicts[0].kind, 'session-log');
203
+ // 会话投影缓存是可再生的派生数据,不该升级成需要用户裁决的冲突
204
+ assert.ok(!pullB.result.conflicts.some((c) => c.path.includes('session_projcache')), '可再生缓存不进冲突清单');
205
+ assert.ok(beforeB.equals(fs.readFileSync(findSessionFile(homeB, S1))), '冲突时本机会话文件一个字节都不能变');
206
+ globalThis.__pendingB = pullB.result.pendingId;
207
+ });
208
+
209
+ await step('裁决「两边都留」:远端版本导入成新会话并登记进工作区', async () => {
210
+ const pendingId = globalThis.__pendingB;
211
+ assert.ok(pendingId, '有待裁决记录');
212
+ const pending = engineB.listPending().find((p) => p.id === pendingId);
213
+ assert.equal(pending.conflicts.length, 1);
214
+ const c = pending.conflicts[0];
215
+ const r = await engineB.resolveConflicts({ pendingId, decisions: [{ id: c.id, choice: 'both' }] });
216
+ assert.equal(r.ok, true, JSON.stringify(r.error));
217
+ const newId = r.detail.both[0].newSessionId;
218
+ assert.ok(newId && newId !== c.sessionId, '生成了新会话 id');
219
+ const rows = readSessionRows(homeB, newId);
220
+ assert.ok(rows && rows[0].id === newId, '新会话表头 id 已改写');
221
+ assert.ok(textsOf(rows).includes('A 的分支内容'), '新会话里是远端那一支的内容');
222
+ assert.ok(textsOf(readSessionRows(homeB, c.sessionId)).includes('B 的分支内容'), '本机原会话保留自己的版本');
223
+ const ws = JSON.parse(fs.readFileSync(path.join(homeB, 'storages', 'workspace.json'), 'utf8'));
224
+ assert.ok(Object.values(ws.tables.workspaces).some((w) => w.sessionIds.includes(newId)), '新会话已登记到工作区');
225
+ assert.equal(engineB.listPending().length, 0, '待裁决记录已清空');
226
+ });
227
+
228
+ await step('全量 pull 的分叉:裁决「采用远端」后本机变成远端版本', async () => {
229
+ const decideAll = async (engine, pullResult, pick) => {
230
+ const pending = engine.listPending().find((p) => p.id === pullResult.result.pendingId);
231
+ assert.ok(pending, '有待裁决记录');
232
+ const decisions = pending.conflicts.map((x) => ({ id: x.id, choice: pick(x) }));
233
+ const r = await engine.resolveConflicts({ pendingId: pending.id, decisions });
234
+ assert.equal(r.ok, true, JSON.stringify(r.error));
235
+ return pending;
236
+ };
237
+
238
+ // 上一轮对 S1 选了「两边都留」并产生了真正的合并提交 → 这个裁决被记录了下来,
239
+ // 所以这次全量取回不该再为它报一次冲突(旧模型会因为分叉没被收束而反复报)。
240
+ const pull0 = await engineB.execute({ direction: 'pull', scope: 'all', includeWorkspace: false, reason: 'test' });
241
+ assert.equal(pull0.ok, true, `已裁决过的会话不该重复报冲突:${pull0.result?.error || ''}`);
242
+ assert.equal(engineB.listPending().length, 0, '没有待裁决记录');
243
+
244
+ // 分支一致后,B 追加 S2 可以正常推送(推的是整条分支,也就带上了 B 对 S1 的裁决)
245
+ appendToSession(homeB, S2, [userMsg('B 第二轮', 3, 1791000030000)]);
246
+ const pushB = await engineB.execute({ direction: 'push', scope: 'session', sessionIds: [S2], includeWorkspace: false, reason: 'test' });
247
+ assert.equal(pushB.ok, true, JSON.stringify(pushB.result?.error || pushB.error));
248
+
249
+ // A 取回:远端把 S1 收敛成了 B 那一支,A 本机仍是自己那一支 —— 这是真正的分叉,必须裁决
250
+ const pullA = await engineA.execute({ direction: 'pull', scope: 'all', includeWorkspace: false, reason: 'test' });
251
+ if (!pullA.ok) {
252
+ const pa = engineA.listPending().find((p) => p.id === pullA.result.pendingId);
253
+ assert.ok(pa.conflicts.some((x) => x.sessionId === S1), 'A 侧应看到 S1 分叉');
254
+ // A 那边选「两边都留」:远端(B 的决定)成为当前版本,A 自己那一支导入成新会话
255
+ await decideAll(engineA, pullA, (x) => (x.sessionId === S1 ? 'both' : 'remote'));
256
+ }
257
+ assert.equal(engineA.listPending().length, 0, 'A 的待裁决记录已清空');
258
+ const pullA2 = await engineA.execute({ direction: 'pull', scope: 'all', includeWorkspace: false, reason: 'test' });
259
+ assert.equal(pullA2.ok, true, JSON.stringify(pullA2.result?.error || pullA2.error));
260
+ assert.ok(textsOf(readSessionRows(homeA, S2)).includes('B 第二轮'), 'S2 的纯追加被自动合并');
261
+
262
+ // A 再改一轮并推送;B 在没拉到的情况下也改一轮 → 真分叉
263
+ appendToSession(homeA, S2, [userMsg('A 第三轮', 4, 1791000032000)]);
264
+ const pushA = await engineA.execute({ direction: 'push', scope: 'all', includeWorkspace: false, reason: 'test' });
265
+ assert.equal(pushA.ok, true, JSON.stringify(pushA.result?.error || pushA.error));
266
+ appendToSession(homeB, S2, [userMsg('B 第三轮', 4, 1791000033000)]);
267
+
268
+ const pullB = await engineB.execute({ direction: 'pull', scope: 'all', includeWorkspace: false, reason: 'test' });
269
+ assert.equal(pullB.ok, false, '全量 pull 应报告冲突');
270
+ const pending = engineB.listPending().find((p) => p.id === pullB.result.pendingId);
271
+ const c = pending.conflicts.find((x) => x.sessionId === S2);
272
+ assert.ok(c, '分叉会话在冲突清单里');
273
+ const r = await engineB.resolveConflicts({
274
+ pendingId: pending.id,
275
+ decisions: pending.conflicts.map((x) => ({ id: x.id, choice: x.sessionId === S2 ? 'remote' : 'local' })),
276
+ });
277
+ assert.equal(r.ok, true, JSON.stringify(r.error));
278
+ assert.ok(textsOf(readSessionRows(homeB, S2)).includes('A 第三轮'), '本机已是远端版本');
279
+ assert.equal(engineB.listPending().length, 0, '待裁决记录已清空');
280
+ });
281
+
282
+ await step('工作区分叉:冲突被记录、本机项目文件不被覆盖,裁决后可两边都留', async () => {
283
+ fs.writeFileSync(path.join(PROJ_A, 'README.md'), 'A 改过的内容\n');
284
+ const pushA = await engineA.execute({ direction: 'push', scope: 'all', includeWorkspace: true, reason: 'test' });
285
+ assert.equal(pushA.ok, true, JSON.stringify(pushA.result?.error || pushA.error));
286
+ const bReadme = path.join(root, 'machine-b', 'ws', 'proj', 'README.md');
287
+ fs.writeFileSync(bReadme, 'B 改过的内容\n');
288
+ const pullB = await engineB.execute({ direction: 'pull', scope: 'all', includeWorkspace: true, reason: 'test' });
289
+ assert.ok(fs.readFileSync(bReadme, 'utf8').includes('B 改过'), '有冲突时本机项目文件保持原样');
290
+ assert.equal(pullB.ok, false, '工作区冲突应报告');
291
+ const pending = engineB.listPending().find((p) => p.id === pullB.result.pendingId);
292
+ const c = pending.conflicts.find((x) => x.path === 'README.md' && x.repo.startsWith('ws:'));
293
+ assert.ok(c, 'README.md 在冲突清单里');
294
+ const r = await engineB.resolveConflicts({ pendingId: pending.id, decisions: [{ id: c.id, choice: 'both' }] });
295
+ assert.equal(r.ok, true, JSON.stringify(r.error));
296
+ const copy = r.detail.both.find((x) => x.newPath && x.newPath.includes('README.md'));
297
+ assert.ok(copy, '远端版本另存成了副本');
298
+ assert.ok(fs.existsSync(path.join(root, 'machine-b', 'ws', 'proj', copy.newPath)), '副本文件确实存在');
299
+ assert.ok(fs.readFileSync(bReadme, 'utf8').includes('B 改过'), '本机版本仍然是当前文件');
300
+ });
301
+
302
+ await step('工作区忽略文件:自动创建、规则生效、文件本身不被同步', async () => {
303
+ const ig = path.join(PROJ_A, '.dsh-chat-sync-ignore');
304
+ assert.ok(fs.existsSync(ig), '同步工作区时应当自动创建 .dsh-chat-sync-ignore');
305
+ assert.ok(fs.readFileSync(ig, 'utf8').includes('gitignore'), '新建的模板应当说明写法');
306
+
307
+ // 用户自己编辑:排除一个目录、一类文件,并留一个 ! 例外
308
+ fs.writeFileSync(ig, '# 我自己的规则\ndist/\n*.secret\n!keep.secret\n');
309
+ fs.mkdirSync(path.join(PROJ_A, 'dist'), { recursive: true });
310
+ fs.writeFileSync(path.join(PROJ_A, 'dist', 'bundle.js'), 'x\n');
311
+ fs.writeFileSync(path.join(PROJ_A, 'a.secret'), 'x\n');
312
+ fs.writeFileSync(path.join(PROJ_A, 'keep.secret'), 'x\n');
313
+
314
+ const r = await engineA.execute({ direction: 'push', scope: 'all', includeWorkspace: true });
315
+ assert.equal(r.ok, true, JSON.stringify(r.result?.error || r.error));
316
+ const ls = await run('git', ['--git-dir', remoteDir, 'ls-tree', '-r', '--name-only', `ws/${WS_A}`]);
317
+ const files = ls.out.split('\n').map((x) => x.trim()).filter(Boolean);
318
+ assert.ok(!files.some((f) => f.startsWith('dist/')), `dist/ 不该被上传,实际:${files.join(',')}`);
319
+ assert.ok(!files.includes('a.secret'), '*.secret 不该被上传');
320
+ assert.ok(files.includes('keep.secret'), '! 例外应当仍然上传');
321
+ assert.ok(!files.includes('.dsh-chat-sync-ignore'), '忽略文件本身不该被同步(它是本机专属设置)');
322
+ });
323
+
324
+ await step('路径重映射:记录里别的用户名的主目录会换成本机主目录', () => {
325
+ const r = engineB.resolveWorkspacePath({ id: 'nope', path: '/home/someone-else/reports' }, { workspaceBase: '' });
326
+ assert.equal(r.source, 'remapped');
327
+ assert.equal(r.path, path.join(os.homedir(), 'reports'));
328
+ });
329
+
330
+ await step('已有本地数据 + 无共同历史:pull 停下报告冲突,本机文件不动', async () => {
331
+ const homeC = makeHome('home-c', {
332
+ sessions: [{ id: 'session-cccc3333-0000-4000-8000-000000000003', cwd: '/home/carol/x', title: 'C 自己的会话', events: [userMsg('C 本机内容', 0, 1791000040000)] }],
333
+ workspaces: [{ id: 'ws-cccc-3333', path: path.join(root, 'machine-c', 'proj'), title: 'proj-c', sessionIds: [] }],
334
+ });
335
+ const wsFile = path.join(homeC, 'storages', 'workspace.json');
336
+ const before = fs.readFileSync(wsFile);
337
+ const engineC = new SyncEngine(homeC, cfgOverride, silent);
338
+ const r = await engineC.execute({ direction: 'pull', scope: 'all', includeWorkspace: false, reason: 'test' });
339
+ assert.equal(r.ok, false, '无共同历史且两边都登记了工作区,应报告冲突');
340
+ assert.ok(r.result.conflicts.length >= 1, '至少一项冲突');
341
+ assert.ok(before.equals(fs.readFileSync(wsFile)), '本机 workspace.json 在裁决前不能被改动');
342
+ // 全部保留本机 → 合并完成且本机登记表仍是自己那份
343
+ const pending = engineC.listPending().find((p) => p.id === r.result.pendingId);
344
+ const decisions = pending.conflicts.map((c) => ({ id: c.id, choice: 'local' }));
345
+ const rr = await engineC.resolveConflicts({ pendingId: pending.id, decisions });
346
+ assert.equal(rr.ok, true, JSON.stringify(rr.error));
347
+ assert.ok(before.equals(fs.readFileSync(wsFile)), '选择「保留本机」后登记表仍是本机那份');
348
+ // 远端独有的会话文件必须已经进来(不是被丢弃)
349
+ assert.ok(readSessionRows(homeC, S1), '远端独有的会话仍然被取回');
350
+ });
351
+
352
+ await step('配置读写:remote/branch 落到 chat-sync.json', () => {
353
+ const saved = engineA.writeConfig({ branch: 'main' });
354
+ assert.equal(saved.branch, 'main');
355
+ const onDisk = JSON.parse(fs.readFileSync(path.join(homeA, 'chat-sync.json'), 'utf8'));
356
+ assert.equal(onDisk.branch, 'main');
357
+ // 旧插件的配置文件不能被我们改写
358
+ assert.ok(!fs.existsSync(path.join(homeA, 'dsh-sync.json')), '没有凭空创建 dsh-sync.json');
359
+ });
360
+
361
+ const failed = results.filter((x) => x[0] === 'fail');
362
+ process.stdout.write(`\n${results.length - failed.length}/${results.length} 通过\n`);
363
+ if (failed.length) {
364
+ for (const [, name, err] of failed) process.stdout.write(`失败:${name}\n${err?.stack || err}\n`);
365
+ process.stdout.write(`(保留现场:${root})\n`);
366
+ process.exitCode = 1;
367
+ } else {
368
+ try { fs.rmSync(root, { recursive: true, force: true }); } catch { /* ignore */ }
369
+ process.stdout.write(`已清理 ${root}\n`);
370
+ }
371
+ }
372
+
373
+ main().catch((e) => { console.error(e); process.exitCode = 1; });