@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.
- package/LICENSE +21 -0
- package/README.md +234 -2
- package/client.js +1017 -0
- package/cordis.patch.yml +5 -0
- package/docs/design-sync-model.md +182 -0
- package/icon.svg +7 -0
- package/index.js +628 -0
- package/lib/engine.js +2613 -0
- package/lib/progress.js +96 -0
- package/lib/session.js +239 -0
- package/lib/util.js +214 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +56 -4
- package/tests/client-render.test.mjs +588 -0
- package/tests/delivery.test.mjs +399 -0
- package/tests/engine.test.mjs +373 -0
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client.js 的单元测试(React 用测试替身,不是渲染验证)。
|
|
3
|
+
*
|
|
4
|
+
* 本环境没有浏览器控制,也没有可用的 react/react-dom(React 由宿主在浏览器中提供)。
|
|
5
|
+
* 这里用一套最小 React 测试替身替代它:createElement + hooks 按组件实例隔离,
|
|
6
|
+
* 渲染后执行 effect,再重放 effect 引发的状态更新,循环若干轮。
|
|
7
|
+
*
|
|
8
|
+
* 它【只能】证明组件逻辑:渲染期是否抛异常、状态更新是否生效、交互是否发出正确的请求、
|
|
9
|
+
* 文案与 class 是否出现、以及源码层面的样式约束。
|
|
10
|
+
* 它【不能】证明任何外观:布局、间距、配色、溢出、明暗主题下的可读性一律未经检验,
|
|
11
|
+
* 这些必须在真实页面里确认。
|
|
12
|
+
*
|
|
13
|
+
* 运行:node tests/client-render.test.mjs
|
|
14
|
+
*/
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const CLIENT = path.join(here, '..', 'client.js');
|
|
22
|
+
const SRC = fs.readFileSync(CLIENT, 'utf8');
|
|
23
|
+
|
|
24
|
+
/* ================= 最小 React 模拟器 ================= */
|
|
25
|
+
let currentInstance = null;
|
|
26
|
+
let hookCursor = 0;
|
|
27
|
+
const instances = new Map();
|
|
28
|
+
const effectQueue = [];
|
|
29
|
+
|
|
30
|
+
function getInstance(key) {
|
|
31
|
+
let inst = instances.get(key);
|
|
32
|
+
if (!inst) { inst = { hooks: [] }; instances.set(key, inst); }
|
|
33
|
+
return inst;
|
|
34
|
+
}
|
|
35
|
+
function slot(i, init) {
|
|
36
|
+
const h = currentInstance.hooks;
|
|
37
|
+
if (h.length <= i) h.push(init);
|
|
38
|
+
return h[i];
|
|
39
|
+
}
|
|
40
|
+
function depsChanged(a, b) {
|
|
41
|
+
if (!a || !b || a.length !== b.length) return true;
|
|
42
|
+
return a.some((x, i) => !Object.is(x, b[i]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const React = {
|
|
46
|
+
createElement(type, props, ...children) {
|
|
47
|
+
const p = { ...(props || {}) };
|
|
48
|
+
if (children.length === 1) p.children = children[0];
|
|
49
|
+
else if (children.length > 1) p.children = children;
|
|
50
|
+
return { $$el: true, type, props: p };
|
|
51
|
+
},
|
|
52
|
+
useState(init) {
|
|
53
|
+
const i = hookCursor++;
|
|
54
|
+
const inst = currentInstance; // setter 可能在渲染之外被调用(异步 effect),必须捕获实例
|
|
55
|
+
const initial = typeof init === 'function' ? init() : init;
|
|
56
|
+
const cur = slot(i, { value: initial }).value;
|
|
57
|
+
const set = (v) => {
|
|
58
|
+
const s = inst.hooks[i];
|
|
59
|
+
s.value = typeof v === 'function' ? v(s.value) : v;
|
|
60
|
+
inst.dirty = true;
|
|
61
|
+
};
|
|
62
|
+
return [cur, set];
|
|
63
|
+
},
|
|
64
|
+
useRef(init) {
|
|
65
|
+
const i = hookCursor++;
|
|
66
|
+
return slot(i, { current: init });
|
|
67
|
+
},
|
|
68
|
+
useMemo(fn, deps) {
|
|
69
|
+
const i = hookCursor++;
|
|
70
|
+
const s = slot(i, { deps: null, value: undefined });
|
|
71
|
+
if (depsChanged(s.deps, deps)) { s.deps = deps; s.value = fn(); }
|
|
72
|
+
return s.value;
|
|
73
|
+
},
|
|
74
|
+
useCallback(fn, deps) { return React.useMemo(() => fn, deps); },
|
|
75
|
+
useEffect(fn, deps) {
|
|
76
|
+
const i = hookCursor++;
|
|
77
|
+
const s = slot(i, { deps: null, cleanup: undefined });
|
|
78
|
+
if (depsChanged(s.deps, deps)) {
|
|
79
|
+
s.deps = deps;
|
|
80
|
+
effectQueue.push({ inst: currentInstance, hook: s, fn });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/* ================= 假 fetch ================= */
|
|
86
|
+
const CWD = '/home/alice/project';
|
|
87
|
+
const STATUS = {
|
|
88
|
+
ok: true,
|
|
89
|
+
home: '/home/alice/.dsh',
|
|
90
|
+
remote: 'https://github.com/alice/dsh-sync.git',
|
|
91
|
+
branch: 'main',
|
|
92
|
+
proxy: '',
|
|
93
|
+
autoRepo: true,
|
|
94
|
+
repoName: 'dsh-sync',
|
|
95
|
+
workspaceBase: '',
|
|
96
|
+
showSidebarButton: true,
|
|
97
|
+
gitMissing: false,
|
|
98
|
+
ghAvailable: true,
|
|
99
|
+
running: false,
|
|
100
|
+
repo: { exists: true, head: 'abc1234', remoteHead: 'abc1234', ahead: 0, behind: 0, dirty: 3 },
|
|
101
|
+
localSessionCount: 2,
|
|
102
|
+
remoteSessionCount: 1,
|
|
103
|
+
sessions: [
|
|
104
|
+
{ id: 'session-aaaa1111-0000-4000-8000-000000000001', title: '会话一', cwd: CWD, workspaceId: 'ws-1', size: 12345, mtime: 1791000000000, relLog: 'sessions/x/session-aaaa1111-0000-4000-8000-000000000001/session.v4.jsonl.zstd', state: 'differs' },
|
|
105
|
+
{ id: 'session-bbbb2222-0000-4000-8000-000000000002', title: '会话二', cwd: CWD, workspaceId: null, size: 2048, mtime: 1791000001000, relLog: 'sessions/x/session-bbbb2222/session.v4.jsonl.zstd', state: 'local-only' },
|
|
106
|
+
],
|
|
107
|
+
workspaces: [
|
|
108
|
+
{ id: 'ws-1', title: 'proj', path: '/home/alice/project', recordedPath: '/home/bob/project', pathSource: 'remapped', exists: true, sessionCount: 2 },
|
|
109
|
+
{ id: 'ws-2', title: '另一个', path: '/home/alice/other', recordedPath: '/home/alice/other', pathSource: 'recorded', exists: false, sessionCount: 0 },
|
|
110
|
+
],
|
|
111
|
+
pending: [{
|
|
112
|
+
id: 'pending-1',
|
|
113
|
+
createdAt: Date.now(),
|
|
114
|
+
conflicts: [
|
|
115
|
+
{ id: 'main:sessions/x/session-aaaa1111-0000-4000-8000-000000000001/session.v4.jsonl.zstd', repo: 'main', path: 'sessions/x/session-aaaa1111-0000-4000-8000-000000000001/session.v4.jsonl.zstd', kind: 'session-log', reason: 'divergent-session-history', sessionId: 'session-aaaa1111-0000-4000-8000-000000000001', title: '会话一', theirsCopy: 'files/1-theirs' },
|
|
116
|
+
{ id: 'ws:ws-1:README.md', repo: 'ws:ws-1', path: 'README.md', kind: 'file', reason: 'text-overlap', title: null, theirsCopy: 'files/2-theirs' },
|
|
117
|
+
{ id: 'main:sessions/x/session-bbbb2222-0000-4000-8000-000000000002/session.v4.jsonl.zstd', repo: 'main', side: 'delivery', path: 'sessions/x/session-bbbb2222-0000-4000-8000-000000000002/session.v4.jsonl.zstd', kind: 'session-log', reason: 'delete-local', sessionId: 'session-bbbb2222-0000-4000-8000-000000000002', title: '会话二', theirsCopy: 'files/3-theirs' },
|
|
118
|
+
],
|
|
119
|
+
}],
|
|
120
|
+
lastRun: {
|
|
121
|
+
runId: 'r1', direction: 'pull', scope: 'session', running: false, durationMs: 4200,
|
|
122
|
+
error: null, result: { applied: [{ added: true }], merged: [{ path: 'a' }], pushed: [], conflicts: [] },
|
|
123
|
+
},
|
|
124
|
+
current: { runId: 'r2', direction: 'push', scope: 'all', running: true, startedAt: Date.now(), durationMs: 900 },
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const routeState = { pingRunning: true, pingPending: 2 };
|
|
128
|
+
const ROUTES = {
|
|
129
|
+
'status': (body) => { void body; return STATUS; },
|
|
130
|
+
'ping': () => ({ ok: true, running: routeState.pingRunning, pendingCount: routeState.pingPending, remote: STATUS.remote, showSidebarButton: true }),
|
|
131
|
+
'progress': () => ({
|
|
132
|
+
ok: true,
|
|
133
|
+
running: true,
|
|
134
|
+
next: 4,
|
|
135
|
+
entries: [
|
|
136
|
+
{ n: 1, t: Date.now(), level: 'step', stage: 'start', text: '开始推送' },
|
|
137
|
+
{ n: 2, t: Date.now(), level: 'info', stage: 'fetch', text: 'remote: Total 17' },
|
|
138
|
+
{ n: 3, t: Date.now(), level: 'warn', stage: 'conflict', text: '会话一:双方都改动过' },
|
|
139
|
+
],
|
|
140
|
+
summary: { runId: 'r2', direction: 'push', scope: 'all', running: true, durationMs: 900, error: null, result: null },
|
|
141
|
+
}),
|
|
142
|
+
'pending/preview': () => ({
|
|
143
|
+
ok: true, kind: 'session', sessionId: 'x',
|
|
144
|
+
local: { ok: true, events: 12, messages: [{ role: 'user', text: '本机内容' }, { role: 'assistant', text: '本机回复' }] },
|
|
145
|
+
remote: { ok: true, events: 10, messages: [{ role: 'user', text: '远端内容' }] },
|
|
146
|
+
}),
|
|
147
|
+
'config': () => ({ ok: true, configPath: '/home/alice/.dsh/chat-sync.json' }),
|
|
148
|
+
'units': () => ({
|
|
149
|
+
ok: true,
|
|
150
|
+
ignore: { sessions: ['session-bbbb2222-0000-4000-8000-000000000002'], workspaces: [], workspaceFiles: {} },
|
|
151
|
+
units: [
|
|
152
|
+
{ path: 'sessions/x/session-aaaa1111-0000-4000-8000-000000000001/session.v4.jsonl.zstd', state: 'A5', stateLabel: '两边都改了·需裁决', kind: 'session-log', sessionId: 'session-aaaa1111-0000-4000-8000-000000000001', title: '会话一', muted: false, pending: true, needsDecision: true },
|
|
153
|
+
{ path: 'sessions/x/session-bbbb2222-0000-4000-8000-000000000002/session.v4.jsonl.zstd', state: 'IGNORED', stateLabel: '已忽略', kind: 'session-log', sessionId: 'session-bbbb2222-0000-4000-8000-000000000002', title: '会话二', muted: true, pending: false, needsDecision: false },
|
|
154
|
+
{ path: 'settings.yaml', state: 'A2', stateLabel: '仅本机改了', kind: 'text', sessionId: null, title: '', muted: false, pending: false, needsDecision: false },
|
|
155
|
+
],
|
|
156
|
+
}),
|
|
157
|
+
'ignore': () => ({ ok: true, ignore: { sessions: [], workspaces: [], workspaceFiles: {} } }),
|
|
158
|
+
'align': () => ({ ok: true, upToDate: true, ahead: 2, behind: 0 }),
|
|
159
|
+
'force': () => ({ ok: true, mode: 'push', path: 'x' }),
|
|
160
|
+
'workspace/ignore': () => ({
|
|
161
|
+
ok: true, workspaceId: 'ws-1', title: 'proj',
|
|
162
|
+
path: '/home/alice/project/.dsh-chat-sync-ignore', exists: true,
|
|
163
|
+
content: '# 我的规则\ndist/\n',
|
|
164
|
+
}),
|
|
165
|
+
'run': () => ({ ok: true, runId: 'r3' }),
|
|
166
|
+
'workspace/path': () => ({ ok: true }),
|
|
167
|
+
'pending/resolve': (body) => ({ ok: true, remaining: 1, detail: { resolved: (body?.decisions || []).map((d) => ({ id: d.id, choice: d.choice })), both: [] } }),
|
|
168
|
+
'pending/discard': () => ({ ok: true }),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const fetchLog = [];
|
|
172
|
+
globalThis.fetch = async (url, opts) => {
|
|
173
|
+
const u = String(url);
|
|
174
|
+
const key = Object.keys(ROUTES).find((k) => u.includes('/chat-sync/api/' + k));
|
|
175
|
+
fetchLog.push({ url: u, method: (opts && opts.method) || 'GET' });
|
|
176
|
+
const body = opts && opts.body ? JSON.parse(opts.body) : null;
|
|
177
|
+
if (body) fetchLog[fetchLog.length - 1].body = body;
|
|
178
|
+
const data = key ? ROUTES[key](body) : { ok: false, error: 'no route ' + u };
|
|
179
|
+
return { ok: true, json: async () => data };
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
/* ================= 加载 client.js ================= */
|
|
183
|
+
function loadClient() {
|
|
184
|
+
const captured = { def: null };
|
|
185
|
+
const window = {
|
|
186
|
+
__ModuleLoader__: { load(def) { captured.def = def; } },
|
|
187
|
+
prompt: () => null,
|
|
188
|
+
addEventListener() {},
|
|
189
|
+
removeEventListener() {},
|
|
190
|
+
};
|
|
191
|
+
const code = fs.readFileSync(CLIENT, 'utf8');
|
|
192
|
+
const fn = new Function('window', 'document', 'fetch', 'setInterval', 'clearInterval', 'console', code);
|
|
193
|
+
fn(window, { head: { appendChild() {} }, createElement: () => ({ setAttribute() {}, style: {} }) }, globalThis.fetch,
|
|
194
|
+
() => 0, () => {}, console);
|
|
195
|
+
assert.ok(captured.def, 'client.js 没有调用 window.__ModuleLoader__.load');
|
|
196
|
+
assert.equal(captured.def.id, '@charara97/dsh-session-sync');
|
|
197
|
+
const mod = captured.def.factory((name) => {
|
|
198
|
+
if (name === 'react') return React;
|
|
199
|
+
throw new Error('unexpected require: ' + name);
|
|
200
|
+
});
|
|
201
|
+
return mod;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/* ================= 渲染器 ================= */
|
|
205
|
+
function textOf(node) {
|
|
206
|
+
if (node === null || node === undefined || node === false || node === true) return '';
|
|
207
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node);
|
|
208
|
+
if (Array.isArray(node)) return node.map(textOf).join('');
|
|
209
|
+
if (node.$$el) return textOf(node.props.children);
|
|
210
|
+
return '';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function renderTree(node, keyPath, out, labelCtx = '') {
|
|
214
|
+
if (node === null || node === undefined || node === false || node === true) return;
|
|
215
|
+
if (Array.isArray(node)) { node.forEach((n, i) => renderTree(n, keyPath + '.' + i, out, labelCtx)); return; }
|
|
216
|
+
if (typeof node === 'string' || typeof node === 'number') { out.text.push(String(node)); return; }
|
|
217
|
+
if (!node.$$el) return;
|
|
218
|
+
const { type, props } = node;
|
|
219
|
+
if (typeof type === 'function') {
|
|
220
|
+
const inst = getInstance(keyPath + ':' + (type.name || 'Anon'));
|
|
221
|
+
const prev = currentInstance;
|
|
222
|
+
const prevCursor = hookCursor;
|
|
223
|
+
currentInstance = inst;
|
|
224
|
+
hookCursor = 0;
|
|
225
|
+
let rendered;
|
|
226
|
+
try {
|
|
227
|
+
rendered = type(props);
|
|
228
|
+
} finally {
|
|
229
|
+
currentInstance = prev;
|
|
230
|
+
hookCursor = prevCursor;
|
|
231
|
+
}
|
|
232
|
+
renderTree(rendered, keyPath + '/' + (type.name || 'Anon'), out, labelCtx);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
out.tags.push(String(type));
|
|
236
|
+
if (props.className) out.classes.push(String(props.className));
|
|
237
|
+
// <label> 包住的控件用 label 的文本作为可定位标签(复选框就是这样)
|
|
238
|
+
const inner = type === 'label' ? textOf(props.children) : labelCtx;
|
|
239
|
+
if (typeof props.onClick === 'function') {
|
|
240
|
+
out.handlers.push({ className: String(props.className || ''), label: textOf(props.children) || labelCtx, onClick: props.onClick });
|
|
241
|
+
}
|
|
242
|
+
if (typeof props.onChange === 'function') {
|
|
243
|
+
out.handlers.push({ className: String(props.className || ''), label: inner, onChange: props.onChange });
|
|
244
|
+
}
|
|
245
|
+
renderTree(props.children, keyPath + '>' + type, out, inner);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function flush() {
|
|
249
|
+
for (let i = 0; i < 12; i += 1) await Promise.resolve();
|
|
250
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
251
|
+
for (let i = 0; i < 12; i += 1) await Promise.resolve();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function renderComponent(Component, props, { rounds = 8, clickSidebar = false, fresh = false } = {}) {
|
|
255
|
+
if (fresh) instances.clear();
|
|
256
|
+
const seen = [];
|
|
257
|
+
for (let round = 0; round < rounds; round += 1) {
|
|
258
|
+
for (const inst of instances.values()) inst.dirty = false;
|
|
259
|
+
effectQueue.length = 0;
|
|
260
|
+
const out = { tags: [], classes: [], text: [], handlers: [] };
|
|
261
|
+
currentInstance = null;
|
|
262
|
+
hookCursor = 0;
|
|
263
|
+
const tree = React.createElement(Component, props);
|
|
264
|
+
renderTree(tree, 'root', out);
|
|
265
|
+
seen.push(out);
|
|
266
|
+
|
|
267
|
+
if (clickSidebar && round === 0) {
|
|
268
|
+
const btn = out.handlers.find((h) => h.className.includes('dcs-side'));
|
|
269
|
+
if (btn) btn.onClick();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 执行本次收集到的 effects(它们会触发异步 fetch)
|
|
273
|
+
if (effectQueue.length) {
|
|
274
|
+
for (const e of effectQueue) {
|
|
275
|
+
if (e.hook.cleanup) { try { e.hook.cleanup(); } catch { /* ignore */ } }
|
|
276
|
+
try {
|
|
277
|
+
const c = e.fn();
|
|
278
|
+
if (typeof c === 'function') e.hook.cleanup = c;
|
|
279
|
+
} catch (err) {
|
|
280
|
+
throw new Error(`effect 抛异常:${err && err.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// 等 fetch 的 promise 链落地
|
|
284
|
+
for (let i = 0; i < 12; i += 1) await Promise.resolve();
|
|
285
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
286
|
+
for (let i = 0; i < 12; i += 1) await Promise.resolve();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const anyDirty = [...instances.values()].some((i) => i.dirty);
|
|
290
|
+
if (!anyDirty) break;
|
|
291
|
+
}
|
|
292
|
+
return seen;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* ================= 用例 ================= */
|
|
296
|
+
const results = [];
|
|
297
|
+
async function step(name, fn) {
|
|
298
|
+
try {
|
|
299
|
+
await fn();
|
|
300
|
+
results.push(['ok', name]);
|
|
301
|
+
process.stdout.write(` ✓ ${name}\n`);
|
|
302
|
+
} catch (err) {
|
|
303
|
+
results.push(['fail', name, err]);
|
|
304
|
+
process.stdout.write(` ✗ ${name}\n ${err && err.message}\n`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function main() {
|
|
309
|
+
const mod = loadClient();
|
|
310
|
+
const registrations = [];
|
|
311
|
+
const ctx = {
|
|
312
|
+
locale: {
|
|
313
|
+
register: () => () => {},
|
|
314
|
+
bind: () => (k) => ({ title: '会话同步', openPanel: '打开面板', running: '同步中…' }[k] || k),
|
|
315
|
+
},
|
|
316
|
+
slots: {
|
|
317
|
+
// 与真实 Cordis 一致:inject 先登记槽位,再执行回调,回调里调 register。
|
|
318
|
+
inject: (key, cb) => {
|
|
319
|
+
const rec = { key };
|
|
320
|
+
registrations.push(rec);
|
|
321
|
+
rec.effect = cb();
|
|
322
|
+
return () => {};
|
|
323
|
+
},
|
|
324
|
+
register: (opts, Component) => {
|
|
325
|
+
const rec = registrations[registrations.length - 1];
|
|
326
|
+
rec.opts = opts;
|
|
327
|
+
rec.Component = Component;
|
|
328
|
+
return () => {};
|
|
329
|
+
},
|
|
330
|
+
},
|
|
331
|
+
effect: (fn) => { fn(); return () => {}; },
|
|
332
|
+
};
|
|
333
|
+
mod.apply(ctx);
|
|
334
|
+
|
|
335
|
+
const byId = new Map(registrations.filter((r) => r.opts).map((r) => [r.opts.id, r]));
|
|
336
|
+
|
|
337
|
+
await step('三个槽位都注册了,且 id / order / label 合法', () => {
|
|
338
|
+
const ids = [...byId.keys()].sort();
|
|
339
|
+
assert.deepEqual(ids, ['dsh-chat-sync-button', 'dsh-chat-sync-overlay', 'dsh-chat-sync-section']);
|
|
340
|
+
for (const r of byId.values()) {
|
|
341
|
+
assert.equal(typeof r.opts.id, 'string');
|
|
342
|
+
assert.ok(!Number.isNaN(Number(r.opts.order)), 'order 要是数字');
|
|
343
|
+
assert.ok(['sidebar.footer.action', 'shell.overlay', 'settings.section'].includes(r.opts.name));
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
await step('设置页渲染:状态卡 / 两组分段控件 / 复选框 / 开始按钮都在', async () => {
|
|
348
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
349
|
+
const rounds = await renderComponent(section, {});
|
|
350
|
+
const last = rounds[rounds.length - 1];
|
|
351
|
+
assert.ok(last.text.join(' ').includes('会话同步'), '页头没渲染出来');
|
|
352
|
+
assert.ok(last.text.join(' ').includes('推送(本机 → 远端)'), '方向分段控件缺标签');
|
|
353
|
+
assert.ok(last.text.join(' ').includes('全部会话'), '范围分段控件缺标签');
|
|
354
|
+
assert.ok(last.text.join(' ').includes('同时同步工作区里的项目文件'), '工作区复选框缺标签');
|
|
355
|
+
assert.ok(last.text.join(' ').includes('在侧栏左下角显示同步按钮'), '侧栏按钮开关缺标签');
|
|
356
|
+
assert.ok(last.text.join(' ').includes('开始推送'), '开始按钮缺标签');
|
|
357
|
+
assert.ok(last.classes.some((c) => c.includes('dcs-seg-ind')), '分段控件没有指示块');
|
|
358
|
+
assert.ok(last.classes.some((c) => c.includes('dcs-btn-primary')), '没有主按钮');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await step('渲染过程没有抛异常,并且真的读了后端状态', async () => {
|
|
362
|
+
assert.ok(fetchLog.some((f) => f.url.includes('/status')), '没有请求 status');
|
|
363
|
+
assert.ok(fetchLog.filter((f) => f.url.includes('/status')).length <= 3, 'status 被反复请求(轮询失控?)');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
await step('拉到状态后:会话列表 / 工作区 / 冲突卡均可渲染', async () => {
|
|
367
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
368
|
+
const rounds = await renderComponent(section, {});
|
|
369
|
+
const all = rounds.map((r) => r.text.join(' ')).join('\n');
|
|
370
|
+
assert.ok(all.includes('https://github.com/alice/dsh-sync.git'), '远端地址没显示');
|
|
371
|
+
assert.ok(all.includes('领先 0 / 落后 0'), '仓库状态没显示');
|
|
372
|
+
assert.ok(all.includes('待裁决冲突'), '冲突卡没显示');
|
|
373
|
+
assert.ok(all.includes('保留本机') && all.includes('采用远端') && all.includes('两边都留'), '裁决选项缺项');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
await step('勾上工作区后:工作区列表带本机路径与来源说明', async () => {
|
|
377
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
378
|
+
const before = await renderComponent(section, {});
|
|
379
|
+
const box = before.flatMap((r) => r.handlers).find((h) => h.onChange && h.label.includes('同时同步工作区里的项目文件'));
|
|
380
|
+
assert.ok(box, '没有找到工作区复选框');
|
|
381
|
+
box.onChange({ target: { checked: true } });
|
|
382
|
+
const after = await renderComponent(section, {});
|
|
383
|
+
const text = after.map((r) => r.text.join(' ')).join('\n');
|
|
384
|
+
assert.ok(text.includes('按本机主目录改写'), '工作区路径来源说明没显示');
|
|
385
|
+
assert.ok(text.includes('/home/alice/project'), '工作区本机路径没显示');
|
|
386
|
+
assert.ok(text.includes('本机不存在,取回时创建'), '缺失目录提示没显示');
|
|
387
|
+
assert.ok(text.includes('改路径'), '缺少「改路径」按钮');
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
await step('切到「单个会话」后:会话列表、状态标记、工作区行都渲染', async () => {
|
|
391
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
392
|
+
const first = await renderComponent(section, {});
|
|
393
|
+
const tab = first.flatMap((r) => r.handlers).find((h) => h.label === '单个会话');
|
|
394
|
+
assert.ok(tab, '没有找到「单个会话」分段项');
|
|
395
|
+
tab.onClick();
|
|
396
|
+
const after = await renderComponent(section, {});
|
|
397
|
+
const text = after.map((r) => r.text.join(' ')).join('\n');
|
|
398
|
+
assert.ok(text.includes('会话一'), '会话标题没显示');
|
|
399
|
+
assert.ok(text.includes('与远端不同'), '会话状态标记没显示');
|
|
400
|
+
assert.ok(text.includes('仅本机'), '未分组会话的状态标记没显示');
|
|
401
|
+
assert.ok(text.includes('未分组'), '未归属会话应显示「未分组」');
|
|
402
|
+
const classes = after.flatMap((r) => r.classes);
|
|
403
|
+
assert.ok(classes.some((c) => c.includes('dcs-pill')), '状态胶囊的类名不对');
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
await step('运行中:过程日志逐行渲染,级别类名正确', async () => {
|
|
407
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
408
|
+
const rounds = await renderComponent(section, {});
|
|
409
|
+
const last = rounds[rounds.length - 1];
|
|
410
|
+
const text = last.text.join('\n');
|
|
411
|
+
assert.ok(text.includes('开始推送'), '过程日志第一条没渲染');
|
|
412
|
+
assert.ok(text.includes('双方都改动过'), '过程日志的警告行没渲染');
|
|
413
|
+
assert.ok(last.classes.some((c) => c === 'l-warn'), '警告级别类名不对');
|
|
414
|
+
assert.ok(last.classes.some((c) => c === 'l-step'), '阶段级别类名不对');
|
|
415
|
+
assert.ok(last.classes.some((c) => c.includes('dcs-spin')), '进行中应该有转圈');
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
await step('逐单元状态表:显示每个会话/文件相对远端的差异,并且能忽略', async () => {
|
|
419
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
420
|
+
const rounds = await renderComponent(section, { fresh: true });
|
|
421
|
+
const text = rounds.map((r) => r.text.join(' ')).join('\n');
|
|
422
|
+
assert.ok(fetchLog.some((f) => f.url.includes('/units')), '没有请求 units');
|
|
423
|
+
assert.ok(text.includes('条目状态'), '缺少逐单元区块标题');
|
|
424
|
+
assert.ok(text.includes('两边都改了·需裁决'), '缺少单元状态文案');
|
|
425
|
+
assert.ok(text.includes('已忽略'), '缺少已忽略标记');
|
|
426
|
+
assert.ok(text.includes('未同步'), '缺少过滤分段控件');
|
|
427
|
+
assert.ok(text.includes('取消忽略'), '被忽略的单元应当提供「取消忽略」');
|
|
428
|
+
assert.ok(text.includes('本机只会落后、不会分叉'), '缺少「不会分叉」的说明');
|
|
429
|
+
assert.ok(text.includes('重新对齐(快进)'), '缺少重新对齐按钮');
|
|
430
|
+
assert.ok(text.includes('用远端覆盖') && text.includes('用本机覆盖'), '缺少强制覆盖按钮');
|
|
431
|
+
// 有一个单元被忽略 → 应当有忽略开关
|
|
432
|
+
const btns = rounds.flatMap((r) => r.handlers).filter((h) => h.onClick);
|
|
433
|
+
assert.ok(btns.length > 0, '没有可点击的按钮');
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
await step('工作区忽略规则:能打开编辑器、能保存', async () => {
|
|
437
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
438
|
+
const first = await renderComponent(section, { fresh: true });
|
|
439
|
+
// 范围切回「全部会话」:这样工作区列表才会列出全部工作区
|
|
440
|
+
const allTab = first.flatMap((r) => r.handlers).find((h) => h.onClick && h.label === '全部会话');
|
|
441
|
+
if (allTab) allTab.onClick();
|
|
442
|
+
const box = first.flatMap((r) => r.handlers).find((h) => h.onChange && h.label.includes('同时同步工作区里的项目文件'));
|
|
443
|
+
assert.ok(box, '没有找到工作区复选框');
|
|
444
|
+
box.onChange({ target: { checked: true } });
|
|
445
|
+
const withWs = await renderComponent(section, {});
|
|
446
|
+
const labels = withWs.flatMap((r) => r.handlers).map((h) => h.label).filter(Boolean);
|
|
447
|
+
const wt = withWs.map((r) => r.text.join(' ')).join('\n');
|
|
448
|
+
const igBtn = withWs.flatMap((r) => r.handlers).find((h) => h.onClick && h.label === '忽略规则');
|
|
449
|
+
assert.ok(igBtn, '缺少「忽略规则」按钮');
|
|
450
|
+
igBtn.onClick();
|
|
451
|
+
for (let i = 0; i < 12; i += 1) await Promise.resolve();
|
|
452
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
453
|
+
const after = await renderComponent(section, {});
|
|
454
|
+
const text = after.map((r) => r.text.join(' ')).join('\n');
|
|
455
|
+
assert.ok(fetchLog.some((f) => f.url.includes('workspace/ignore')), '没有请求忽略文件');
|
|
456
|
+
assert.ok(text.includes('的忽略规则'), '没有打开忽略规则编辑器');
|
|
457
|
+
assert.ok(text.includes('.dsh-chat-sync-ignore'), '没有显示忽略文件路径');
|
|
458
|
+
assert.ok(text.includes('保存忽略规则'), '缺少保存按钮');
|
|
459
|
+
assert.ok(after.flatMap((r) => r.tags).includes('textarea'), '缺少可编辑的文本域');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
await step('内容类冲突给出三选一', async () => {
|
|
463
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
464
|
+
const rounds = await renderComponent(section, { fresh: true });
|
|
465
|
+
const text = rounds.map((r) => r.text.join(' ')).join('\n');
|
|
466
|
+
// 现有 fixtures 里的冲突原因是 divergent-session-history / text-overlap → 三选一
|
|
467
|
+
assert.ok(text.includes('保留本机') && text.includes('采用远端') && text.includes('两边都留'), '内容冲突应给三选一');
|
|
468
|
+
assert.ok(text.includes('会话双方均有新增内容,无法拼接'), '冲突原因应显示为正式说法');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await step('冲突裁决:选中后立刻有计数反馈,且支持部分应用', async () => {
|
|
472
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
473
|
+
const rounds = await renderComponent(section, { fresh: true });
|
|
474
|
+
const before = rounds[rounds.length - 1];
|
|
475
|
+
const beforeText = before.text.join(' ');
|
|
476
|
+
assert.ok(beforeText.includes('已选 0 / 3'), `未选时应显示「已选 0 / 3」,实际:${beforeText.slice(-200)}`);
|
|
477
|
+
assert.ok(beforeText.includes('应用所选项目'), '未选时按钮应提示先选');
|
|
478
|
+
|
|
479
|
+
// 选第一项的「保留本机」
|
|
480
|
+
const localBtns = rounds.flatMap((r) => r.handlers).filter((h) => h.onClick && h.label === '保留本机');
|
|
481
|
+
assert.ok(localBtns.length >= 1, '没有找到「保留本机」选项');
|
|
482
|
+
localBtns[0].onClick();
|
|
483
|
+
const after = await renderComponent(section, {});
|
|
484
|
+
const afterText = after[after.length - 1].text.join(' ');
|
|
485
|
+
assert.ok(afterText.includes('已选 1 / 3'), '选中后计数应当立刻更新');
|
|
486
|
+
assert.ok(afterText.includes('应用所选 1 项'), '按钮文案应当反映已选数量');
|
|
487
|
+
assert.ok(after.flatMap((r) => r.classes).some((c) => c.includes('dcs-seg-ind')), '选中后分段控件应当显示指示块');
|
|
488
|
+
|
|
489
|
+
// 点应用 → 只提交已选的那一项
|
|
490
|
+
fetchLog.length = 0;
|
|
491
|
+
const applyBtn = after.flatMap((r) => r.handlers).find((h) => h.onClick && /应用所选/.test(h.label));
|
|
492
|
+
assert.ok(applyBtn, '缺少应用按钮');
|
|
493
|
+
applyBtn.onClick();
|
|
494
|
+
await flush();
|
|
495
|
+
const calls = fetchLog.filter((f) => f.url.includes('pending/resolve'));
|
|
496
|
+
assert.equal(calls.length, 1, `只应向一个待裁决记录提交,实际 ${calls.length} 次`);
|
|
497
|
+
assert.equal(calls[0].body.decisions.length, 1, '只应提交已选的那一项');
|
|
498
|
+
|
|
499
|
+
// 反馈必须出现在冲突区里(而不是面板顶部)
|
|
500
|
+
const done = await renderComponent(section, {});
|
|
501
|
+
const doneText = done[done.length - 1].text.join(' ');
|
|
502
|
+
assert.ok(/已应用 \d+ 项/.test(doneText), `冲突区应当就地显示应用结果,实际:${doneText.slice(-260)}`);
|
|
503
|
+
assert.ok(doneText.includes('剩余 1 项待裁决'), '应当显示还剩多少项');
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await step('删除类冲突给出与状态对应的专用说法', async () => {
|
|
507
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
508
|
+
const rounds = await renderComponent(section, { fresh: true });
|
|
509
|
+
const text = rounds.map((r) => r.text.join(' ')).join('\n');
|
|
510
|
+
assert.ok(text.includes('本机已删除,远端未修改'), '应当显示删除类的原因');
|
|
511
|
+
assert.ok(text.includes('同步删除到远端'), 'delete-local 的第一个选项文案不对');
|
|
512
|
+
assert.ok(text.includes('仅本机删除'), 'delete-local 的第二个选项文案不对');
|
|
513
|
+
assert.ok(text.includes('删除类裁决'), '应当提示这是删除类决定');
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
await step('侧栏按钮:运行中显示转圈 +「同步中…」', async () => {
|
|
517
|
+
routeState.pingRunning = true;
|
|
518
|
+
const btn = byId.get('dsh-chat-sync-button').Component;
|
|
519
|
+
const rounds = await renderComponent(btn, { wide: true });
|
|
520
|
+
const last = rounds[rounds.length - 1];
|
|
521
|
+
assert.ok(last.classes.some((c) => c.includes('dcs-side')), '侧栏按钮类名不对');
|
|
522
|
+
assert.ok(last.text.join(' ').includes('同步中…'), '运行中应显示「同步中…」');
|
|
523
|
+
assert.ok(last.classes.some((c) => c.includes('dcs-spin')), '运行中应有转圈');
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
await step('侧栏按钮:空闲时显示图标 + 标题', async () => {
|
|
527
|
+
routeState.pingRunning = false;
|
|
528
|
+
const btn = byId.get('dsh-chat-sync-button').Component;
|
|
529
|
+
const rounds = await renderComponent(btn, { wide: true }, { fresh: true });
|
|
530
|
+
const last = rounds[rounds.length - 1];
|
|
531
|
+
assert.ok(last.tags.includes('svg'), '空闲时缺少图标');
|
|
532
|
+
assert.ok(last.text.join(' ').includes('会话同步'), '空闲时缺少标题');
|
|
533
|
+
routeState.pingRunning = true;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
await step('侧栏按钮可以收起成纯图标(wide=false)', async () => {
|
|
537
|
+
const btn = byId.get('dsh-chat-sync-button').Component;
|
|
538
|
+
const rounds = await renderComponent(btn, { wide: false });
|
|
539
|
+
const last = rounds[rounds.length - 1];
|
|
540
|
+
assert.ok(last.classes.some((c) => c.includes('compact')), '收起态应加 compact');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
await step('点侧栏按钮打开浮层,浮层内面板能渲染', async () => {
|
|
544
|
+
const btn = byId.get('dsh-chat-sync-button').Component;
|
|
545
|
+
await renderComponent(btn, { wide: true }, { clickSidebar: true });
|
|
546
|
+
const overlay = byId.get('dsh-chat-sync-overlay').Component;
|
|
547
|
+
const rounds = await renderComponent(overlay, {});
|
|
548
|
+
const last = rounds[rounds.length - 1];
|
|
549
|
+
assert.ok(last.classes.some((c) => c.includes('dcs-overlay')), '浮层没有渲染');
|
|
550
|
+
assert.ok(last.text.join(' ').includes('关闭'), '浮层缺关闭按钮');
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await step('冲突卡点「查看两侧内容」后渲染出两侧对比', async () => {
|
|
554
|
+
const section = byId.get('dsh-chat-sync-section').Component;
|
|
555
|
+
// 先渲染到稳定态,拿到冲突卡里的按钮
|
|
556
|
+
const rounds = await renderComponent(section, {});
|
|
557
|
+
const previewBtn = rounds.flatMap((r) => r.handlers).find((h) => h.label === '查看两侧内容');
|
|
558
|
+
assert.ok(previewBtn, '没有找到「查看两侧内容」按钮');
|
|
559
|
+
previewBtn.onClick();
|
|
560
|
+
for (let i = 0; i < 12; i += 1) await Promise.resolve();
|
|
561
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
562
|
+
const after = await renderComponent(section, {});
|
|
563
|
+
const text = after.map((r) => r.text.join(' ')).join('\n');
|
|
564
|
+
assert.ok(fetchLog.some((f) => f.url.includes('pending/preview')), '没有请求冲突预览');
|
|
565
|
+
assert.ok(text.includes('本机内容'), '没有渲染本机一侧的内容');
|
|
566
|
+
assert.ok(text.includes('远端内容'), '没有渲染远端一侧的内容');
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
await step('样式约束:两列预览必须可收缩,长串不得撑破容器', () => {
|
|
570
|
+
// 源码断言,不是布局验证。「1fr 的最小尺寸是 auto」是个反复踩到的坑:
|
|
571
|
+
// 内容一长轨道就被撑破,而且两列宽度还会不一致。
|
|
572
|
+
assert.ok(/\.dcs-grid\{[^}]*grid-template-columns:minmax\(0,1fr\) minmax\(0,1fr\)/.test(SRC),
|
|
573
|
+
'.dcs-grid 必须用 minmax(0,1fr):裸 1fr 会被长内容撑破');
|
|
574
|
+
assert.ok(/\.dcs-grid>\*\{min-width:0\}/.test(SRC), '.dcs-grid 的子项必须 min-width:0');
|
|
575
|
+
assert.ok(/\.dcs-pre\{[^}]*overflow-wrap:anywhere/.test(SRC), '.dcs-pre 必须允许长词断行');
|
|
576
|
+
assert.ok(/\.dcs-root\{[^}]*overflow-wrap:anywhere/.test(SRC), '.dcs-root 必须兜底断行');
|
|
577
|
+
assert.ok(!/grid-template-columns:1fr 1fr/.test(SRC), '不应再出现不可收缩的 1fr 1fr');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const failed = results.filter((r) => r[0] === 'fail');
|
|
581
|
+
process.stdout.write(`\n${results.length - failed.length}/${results.length} 通过\n`);
|
|
582
|
+
if (failed.length) {
|
|
583
|
+
for (const [, name, err] of failed) process.stdout.write(`失败:${name}\n${err && err.stack}\n`);
|
|
584
|
+
process.exitCode = 1;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
main().catch((e) => { console.error(e); process.exitCode = 1; });
|