dsh-workspace-mover 2.1.0 → 2.2.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/README.md +325 -267
- package/client/client.js +318 -68
- package/docs/ARCHITECTURE.md +68 -17
- package/lib/config.js +74 -0
- package/lib/index.js +423 -70
- package/package.json +99 -63
- package/test/client-dom.test.mjs +667 -0
- package/test/e2e-sandbox.test.mjs +289 -10
- package/test/policy.test.mjs +415 -0
- package/test/traffic-badge.test.mjs +18 -4
|
@@ -55,6 +55,11 @@ function makeEntity(id, path, hostRef) {
|
|
|
55
55
|
detached: [],
|
|
56
56
|
failAttach: false,
|
|
57
57
|
async status() { return existsSync(record.path) ? 'ok' : 'missing-dir'; },
|
|
58
|
+
// 官方文档化的改名接口:只换标题,不碰 path / 成员名单。
|
|
59
|
+
async setTitle(next) {
|
|
60
|
+
record.title = String(next);
|
|
61
|
+
record.updatedAt = new Date().toISOString();
|
|
62
|
+
},
|
|
58
63
|
async mutate(fn) {
|
|
59
64
|
// 官方语义:整体换新快照(本插件借此同步改写 path/title),随后按索引剪枝成员
|
|
60
65
|
const changed = fn(record);
|
|
@@ -200,6 +205,34 @@ test('mover.status 就绪', async () => {
|
|
|
200
205
|
assert.equal(res.value.ready, true);
|
|
201
206
|
});
|
|
202
207
|
|
|
208
|
+
test('v2.2 mover.status 能力位:官方接口缺失时如实标记为降级(可观测,不静默)', async () => {
|
|
209
|
+
apply(ctx);
|
|
210
|
+
// 夹具的实体带官方 setTitle(与真实 Workspace 接口一致),但不带 unarchiveSession。
|
|
211
|
+
const res = await call('mover.status');
|
|
212
|
+
assert.equal(res.ok, true);
|
|
213
|
+
assert.equal(res.value.capabilities.setTitleApi, true, '实体带 setTitle 应被识别');
|
|
214
|
+
assert.equal(res.value.capabilities.unarchiveApi, false, '夹具无官方取消归档接口');
|
|
215
|
+
assert.ok(res.value.degradedFeatures.includes('unarchive-official-api'),
|
|
216
|
+
`缺失官方取消归档接口应列入降级: ${JSON.stringify(res.value.degradedFeatures)}`);
|
|
217
|
+
assert.ok(!res.value.degradedFeatures.includes('set-title-official-api'), '有 setTitle 不应报降级');
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test('v2.2 mover.status 能力位:补上官方接口后不再报降级', async () => {
|
|
221
|
+
apply(ctx);
|
|
222
|
+
ctx.workspaceRegistry.unarchiveSession = async () => {};
|
|
223
|
+
const res = await call('mover.status');
|
|
224
|
+
assert.equal(res.value.capabilities.unarchiveApi, true);
|
|
225
|
+
assert.ok(!res.value.degradedFeatures.includes('unarchive-official-api'));
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('v2.2 mover.status 能力位:setTitle 缺失时也如实标记', async () => {
|
|
229
|
+
apply(ctx);
|
|
230
|
+
delete entityA.setTitle;
|
|
231
|
+
const res = await call('mover.status');
|
|
232
|
+
assert.equal(res.value.capabilities.setTitleApi, false);
|
|
233
|
+
assert.ok(res.value.degradedFeatures.includes('set-title-official-api'));
|
|
234
|
+
});
|
|
235
|
+
|
|
203
236
|
test('正常迁移:文件物理搬移 + 头部改写 + 双向记账 + 有备份', async () => {
|
|
204
237
|
apply(ctx);
|
|
205
238
|
entityA.record.sessionIds.push('session-aaa');
|
|
@@ -841,6 +874,22 @@ test('标题同步:默认名(=旧文件夹名)跟随改名,自定义标
|
|
|
841
874
|
assert.equal(entityA.record.title, '我的项目', '自定义标题保留');
|
|
842
875
|
});
|
|
843
876
|
|
|
877
|
+
test('v2.2 标题同步失败不得影响已完成的 path 重定向(setTitle 与 mutate 解耦)', async () => {
|
|
878
|
+
apply(ctx);
|
|
879
|
+
setupMovedFolder2();
|
|
880
|
+
const ent = ctx.workspaceRegistry.entities.get('wid-t2');
|
|
881
|
+
const oldPath = ent.path;
|
|
882
|
+
const moved = oldPath + '-moved';
|
|
883
|
+
ent.record.title = oldPath.split(/[\\/]/).pop(); // 仍是旧文件夹名 → 会尝试同步
|
|
884
|
+
ent.setTitle = async () => { throw new Error('simulated setTitle failure'); };
|
|
885
|
+
|
|
886
|
+
const res = await call('mover.repoint', { workspaceId: 'wid-t2', newPath: moved });
|
|
887
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
888
|
+
assert.equal(res.value.pathUpdated, true, 'path 重定向必须完成');
|
|
889
|
+
assert.equal(realpathSync(ent.path), realpathSync(moved), '工作区已指向新路径');
|
|
890
|
+
assert.notEqual(ent.record.title, moved.split(/[\\/]/).pop(), '标题同步失败 → 保留原标题');
|
|
891
|
+
});
|
|
892
|
+
|
|
844
893
|
/** 独立夹具:避免用例间复用同一目录名导致的前置污染。 */
|
|
845
894
|
function setupMovedFolder2() {
|
|
846
895
|
const dir = join(root, 'ws-t2');
|
|
@@ -1005,13 +1054,51 @@ test('mover.unarchive 拒绝:未归档的会话;registry 缺状态写通道'
|
|
|
1005
1054
|
assert.equal(miss.ok, false);
|
|
1006
1055
|
assert.match(miss.error.message, /not archived/);
|
|
1007
1056
|
|
|
1008
|
-
// 归档集里有它,但 registry
|
|
1057
|
+
// 归档集里有它,但 registry 既无官方接口也无状态写通道(老版本宿主容错)
|
|
1009
1058
|
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1010
1059
|
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1011
1060
|
assert.equal(res.ok, false);
|
|
1012
1061
|
assert.match(res.error.message, /no state mutation API/);
|
|
1013
1062
|
});
|
|
1014
1063
|
|
|
1064
|
+
test('v2.2 mover.unarchive 优先走官方 registry.unarchiveSession', async () => {
|
|
1065
|
+
apply(ctx);
|
|
1066
|
+
stubRegistryState();
|
|
1067
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1068
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1069
|
+
let officialCalls = 0;
|
|
1070
|
+
let fallbackCalls = 0;
|
|
1071
|
+
const enqueue = ctx.workspaceRegistry.enqueueOperation;
|
|
1072
|
+
ctx.workspaceRegistry.unarchiveSession = async (id) => {
|
|
1073
|
+
officialCalls++;
|
|
1074
|
+
ctx.workspaceRegistry.archivedSessionIds = ctx.workspaceRegistry.archivedSessionIds.filter((x) => String(x) !== String(id));
|
|
1075
|
+
};
|
|
1076
|
+
ctx.workspaceRegistry.enqueueOperation = (...args) => { fallbackCalls++; return enqueue(...args); };
|
|
1077
|
+
|
|
1078
|
+
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1079
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1080
|
+
assert.equal(officialCalls, 1, '必须调用官方接口');
|
|
1081
|
+
assert.equal(fallbackCalls, 0, '有官方接口时不得走回退通道');
|
|
1082
|
+
assert.equal(ctx.workspaceRegistry.archivedSessionIds.includes('session-aaa'), false);
|
|
1083
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], '记账槽不得被取消归档改动');
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
test('v2.2 mover.unarchive 官方接口缺失时回退到状态写通道', async () => {
|
|
1087
|
+
apply(ctx);
|
|
1088
|
+
stubRegistryState();
|
|
1089
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1090
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-aaa'];
|
|
1091
|
+
let fallbackCalls = 0;
|
|
1092
|
+
const enqueue = ctx.workspaceRegistry.enqueueOperation;
|
|
1093
|
+
ctx.workspaceRegistry.enqueueOperation = (...args) => { fallbackCalls++; return enqueue(...args); };
|
|
1094
|
+
assert.equal(typeof ctx.workspaceRegistry.unarchiveSession, 'undefined', '夹具默认无官方接口');
|
|
1095
|
+
|
|
1096
|
+
const res = await call('mover.unarchive', { sessionId: 'session-aaa' });
|
|
1097
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1098
|
+
assert.equal(fallbackCalls, 1, '必须走回退通道');
|
|
1099
|
+
assert.equal(ctx.workspaceRegistry.archivedSessionIds.includes('session-aaa'), false);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1015
1102
|
test('mover.openFolder 只允许已注册工作区路径;openInFileManager 按平台拼装命令', async () => {
|
|
1016
1103
|
apply(ctx);
|
|
1017
1104
|
// 未注册路径拒绝(不触发任何 spawn)
|
|
@@ -1136,7 +1223,7 @@ test('迁移流程级:残留目标目录给出明确错误,清理后同一
|
|
|
1136
1223
|
assert.ok(!existsSync(artifactPath(root, A, 'session-aaa')));
|
|
1137
1224
|
});
|
|
1138
1225
|
|
|
1139
|
-
test('stashBackup:相邻前缀 id
|
|
1226
|
+
test('stashBackup:相邻前缀 id 互不误删;同一毫秒的连续备份互不覆盖', () => {
|
|
1140
1227
|
const a = 'session-aaa';
|
|
1141
1228
|
const b = 'session-aaa-1'; // a 的前缀超集;'-' < '.' 使其排序在 a 的所有备份之前(最旧)
|
|
1142
1229
|
const dir = join(root, 'workspace-mover', 'backups');
|
|
@@ -1147,13 +1234,22 @@ test('stashBackup:相邻前缀 id 的备份互不误删(前缀收紧)', ()
|
|
|
1147
1234
|
const files = readdirSync(dir);
|
|
1148
1235
|
assert.equal(files.filter((f) => f.startsWith(`${b}.`)).length, 1, '相邻前缀 id 的备份不被误删');
|
|
1149
1236
|
assert.equal(files.filter((f) => f.startsWith(`${a}.`)).length, 20, 'a 恰好保留最近 20 份');
|
|
1150
|
-
//
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
const
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
assert.equal(
|
|
1237
|
+
// 同一毫秒的连续 stash:必须各自留下独立文件(曾因文件名相撞而静默覆盖,
|
|
1238
|
+
// 使 backupKeep 失效——Linux CI 就是这样挂掉的)。
|
|
1239
|
+
const msA = 'session-ms';
|
|
1240
|
+
const countMs = () => readdirSync(dir).filter((f) => f.startsWith(`${msA}.`)).length;
|
|
1241
|
+
stashBackup(msA, Buffer.from('ms-1'));
|
|
1242
|
+
stashBackup(msA, Buffer.from('ms-2'));
|
|
1243
|
+
assert.equal(countMs(), 2,
|
|
1244
|
+
`同一毫秒的两次备份必须留下 2 份而不是互相覆盖,实际 ${countMs()} 份:${JSON.stringify(readdirSync(dir).filter((f) => f.startsWith(`${msA}.`)))}`);
|
|
1245
|
+
const msContents = readdirSync(dir).filter((f) => f.startsWith(`${msA}.`)).map((f) => readFileSync(join(dir, f)).toString()).sort();
|
|
1246
|
+
assert.deepEqual(msContents, ['ms-1', 'ms-2'], `两份备份的内容都必须保留,实际 ${JSON.stringify(msContents)}`);
|
|
1247
|
+
|
|
1248
|
+
// 裁剪计数仍受 backupKeep 约束:把 a 撑回 21 份后应回落到 20。
|
|
1249
|
+
for (let i = 2000; i < 2021; i++) writeFileSync(join(dir, `${a}.${i}.zstd`), Buffer.from('v' + i));
|
|
1250
|
+
stashBackup(a, Buffer.from('after-prune'));
|
|
1251
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith(`${a}.`)).length, 20, 'a 仍恰好保留 20 份');
|
|
1252
|
+
assert.equal(readdirSync(dir).filter((f) => f.startsWith(`${b}.`)).length, 1, '相邻前缀 id 仍不受影响');
|
|
1157
1253
|
});
|
|
1158
1254
|
|
|
1159
1255
|
//#region v0.9:回收站 + 备份管理
|
|
@@ -1223,6 +1319,65 @@ test('mover.session.delete:移入回收站并完成四件套清理', async ()
|
|
|
1223
1319
|
assert.equal(list.value.items[0].ownerWorkspaceId, 'wid-a');
|
|
1224
1320
|
});
|
|
1225
1321
|
|
|
1322
|
+
test('v2.2 备份 GC:删除会话默认保留备份,purgeBackups=true 才清理,且不误删相邻 id', async () => {
|
|
1323
|
+
apply(ctx);
|
|
1324
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1325
|
+
sharedIndex.sessionPaths.set('session-aaa-1', A);
|
|
1326
|
+
entityA.record.sessionIds.push('session-aaa', 'session-aaa-1');
|
|
1327
|
+
// 三个会话各留一份备份;session-aaa-1 是 session-aaa 的前缀超集,必须不被误删
|
|
1328
|
+
stashBackup('session-aaa', Buffer.from('aaa-1'));
|
|
1329
|
+
stashBackup('session-aaa-1', Buffer.from('neighbour'));
|
|
1330
|
+
const dir = join(root, 'workspace-mover', 'backups');
|
|
1331
|
+
const filesFor = (id) => readdirSync(dir).filter((f) => f.startsWith(`${id}.`));
|
|
1332
|
+
|
|
1333
|
+
// ① 默认:删除会话但保留备份
|
|
1334
|
+
const del = await call('mover.session.delete', { sessionId: 'session-aaa' });
|
|
1335
|
+
assert.equal(del.ok, true, JSON.stringify(del));
|
|
1336
|
+
assert.equal(del.value.backupsDeleted, 0, '默认不得删除备份');
|
|
1337
|
+
assert.equal(filesFor('session-aaa').length, 1, '备份仍在(回收站还原后可能还要用)');
|
|
1338
|
+
assert.equal(filesFor('session-aaa-1').length, 1);
|
|
1339
|
+
|
|
1340
|
+
// ② 现在 session-aaa 档案已不存在 → 它的备份组成为孤儿
|
|
1341
|
+
const listed = await call('mover.backups.list');
|
|
1342
|
+
assert.equal(listed.ok, true);
|
|
1343
|
+
const aaaGroup = listed.value.items.find((it) => it.sessionId === 'session-aaa');
|
|
1344
|
+
const neighbourGroup = listed.value.items.find((it) => it.sessionId === 'session-aaa-1');
|
|
1345
|
+
assert.equal(aaaGroup.orphan, true, '会话已删除 → 备份组标记为孤儿');
|
|
1346
|
+
assert.equal(neighbourGroup.orphan, false, '仍存在的会话不得被误标为孤儿');
|
|
1347
|
+
assert.equal(listed.value.orphanGroups, 1);
|
|
1348
|
+
assert.ok(listed.value.orphanBytes > 0);
|
|
1349
|
+
|
|
1350
|
+
// ③ 彻底删除 + purgeBackups:连带清掉该会话备份,且相邻前缀 id 毫发无损
|
|
1351
|
+
const purged = await call('mover.trash.purge', { sessionId: 'session-aaa', purgeBackups: true });
|
|
1352
|
+
assert.equal(purged.ok, true, JSON.stringify(purged));
|
|
1353
|
+
assert.equal(purged.value.purged, 1);
|
|
1354
|
+
assert.equal(purged.value.backupsDeleted, 1);
|
|
1355
|
+
assert.equal(filesFor('session-aaa').length, 0, '孤儿备份必须被清掉');
|
|
1356
|
+
assert.equal(filesFor('session-aaa-1').length, 1, '相邻前缀 id 的备份绝不能被误删');
|
|
1357
|
+
assert.equal(readFileSync(join(dir, filesFor('session-aaa-1')[0])).toString(), 'neighbour');
|
|
1358
|
+
});
|
|
1359
|
+
|
|
1360
|
+
test('v2.2 备份 GC:doctor 汇总孤儿备份组数与占用', async () => {
|
|
1361
|
+
apply(ctx);
|
|
1362
|
+
// 只留一份"会话已不存在"的备份
|
|
1363
|
+
stashBackup('session-ghost', Buffer.alloc(64, 7));
|
|
1364
|
+
const doc = await call('mover.doctor');
|
|
1365
|
+
assert.equal(doc.ok, true);
|
|
1366
|
+
const backupsCheck = doc.value.checks.find((c) => c.id === 'data-backups');
|
|
1367
|
+
assert.equal(backupsCheck.state, 'pass');
|
|
1368
|
+
assert.match(backupsCheck.detail, /orphan group/, `应汇报孤儿组: ${backupsCheck.detail}`);
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
test('v2.2 备份 GC:moveMany 历史聚合的会话备份仍被视为存活', async () => {
|
|
1372
|
+
apply(ctx);
|
|
1373
|
+
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
1374
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1375
|
+
stashBackup('session-aaa', Buffer.from('alive'));
|
|
1376
|
+
const listed = await call('mover.backups.list');
|
|
1377
|
+
const group = listed.value.items.find((it) => it.sessionId === 'session-aaa');
|
|
1378
|
+
assert.equal(group.orphan, false, '注册表仍记账的会话不算孤儿');
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1226
1381
|
test('mover.session.delete:常驻内存的会话拒绝删除', async () => {
|
|
1227
1382
|
apply(ctx);
|
|
1228
1383
|
entityA.record.sessionIds.push('session-aaa');
|
|
@@ -1760,6 +1915,43 @@ test('v1.4 scan 超限截断:先按 mtime 排序,保留最新 400 条', asyn
|
|
|
1760
1915
|
assert.ok(ids.has('session-gen-409'), '最新的生成会话必须保留');
|
|
1761
1916
|
assert.equal(ids.has('session-gen-000'), false, '最旧的生成会话必须被截掉');
|
|
1762
1917
|
assert.ok(ids.has('session-aaa'), '整体最新的夹具会话保留');
|
|
1918
|
+
assert.equal(res.value.scannedParsed, CAP, 'scannedParsed 报告实际解析条数');
|
|
1919
|
+
});
|
|
1920
|
+
|
|
1921
|
+
test('v2.2 归档列表透传截断信息:大库上"不是全部"必须可说明', async () => {
|
|
1922
|
+
apply(ctx);
|
|
1923
|
+
const CAP = SCAN_MAX_ITEMS;
|
|
1924
|
+
const EXTRA = 5;
|
|
1925
|
+
const base = Date.now() - 1_000_000;
|
|
1926
|
+
// 造一个很旧、且被归档的会话:它会被 mtime 截断挡在扫描窗口之外。
|
|
1927
|
+
const oldId = 'session-archived-old';
|
|
1928
|
+
const oldPath = artifactPath(root, A, oldId);
|
|
1929
|
+
mkdirSync(dirname(oldPath), { recursive: true });
|
|
1930
|
+
writeFileSync(oldPath, makeArtifact({ type: 'session', id: oldId, cwd: A, title: 'Ancient archived' }));
|
|
1931
|
+
const oldTime = new Date(base - 500_000);
|
|
1932
|
+
utimesSync(oldPath, oldTime, oldTime);
|
|
1933
|
+
|
|
1934
|
+
for (let i = 0; i < CAP + EXTRA; i++) {
|
|
1935
|
+
const id = `session-arch-${String(i).padStart(3, '0')}`;
|
|
1936
|
+
const p = artifactPath(root, A, id);
|
|
1937
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
1938
|
+
writeFileSync(p, makeArtifact({ type: 'session', id, cwd: A, title: `Arch ${i}` }));
|
|
1939
|
+
const t = new Date(base + i * 1000);
|
|
1940
|
+
utimesSync(p, t, t);
|
|
1941
|
+
}
|
|
1942
|
+
// 官方归档集:把最旧的那个也标为归档
|
|
1943
|
+
ctx.workspaceRegistry.archivedSessionIds = [oldId];
|
|
1944
|
+
|
|
1945
|
+
const res = await call('mover.archived');
|
|
1946
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1947
|
+
assert.equal(res.value.truncated, true, '本次扫描必然被截断');
|
|
1948
|
+
assert.equal(res.value.archivedTotal, 1);
|
|
1949
|
+
assert.ok(res.value.scannedTotal > res.value.scannedParsed,
|
|
1950
|
+
`必须同时给出"共多少"与"解析了多少": ${JSON.stringify({ total: res.value.scannedTotal, parsed: res.value.scannedParsed })}`);
|
|
1951
|
+
// 关键:被截断挡住的归档会话确实不在列表里——所以 UI 必须能说明这件事
|
|
1952
|
+
assert.equal(res.value.items.some((it) => it.sessionId === oldId), false,
|
|
1953
|
+
'该归档会话落在扫描窗口之外(正是需要提示用户的场景)');
|
|
1954
|
+
assert.equal(res.value.items.length, 0);
|
|
1763
1955
|
});
|
|
1764
1956
|
|
|
1765
1957
|
test('v1.4 回滚也失败 → 写 recovery 记录,scan 报告 recoveryCount', async () => {
|
|
@@ -1825,7 +2017,7 @@ const FAKE_EXEC = () => ({ agent: { session: {} }, signal: undefined });
|
|
|
1825
2017
|
test('v2.1 apply 在带 dsh-tools 的宿主上注册三个 agent 工具,status 汇报能力位', async () => {
|
|
1826
2018
|
const { registered } = mountToolStubs();
|
|
1827
2019
|
apply(ctx);
|
|
1828
|
-
assert.deepEqual(registered.map((t) => t.name), ['mover_list_sessions', 'mover_move_session', 'mover_repair_sessions']);
|
|
2020
|
+
assert.deepEqual(registered.map((t) => t.name), ['mover_list_sessions', 'mover_move_session', 'mover_repair_sessions', 'mover_doctor']);
|
|
1829
2021
|
for (const def of registered) {
|
|
1830
2022
|
assert.equal(typeof def.execute, 'function', `${def.name}.execute`);
|
|
1831
2023
|
assert.equal(typeof def.output?.render, 'function', `${def.name}.output.render`);
|
|
@@ -1919,3 +2111,90 @@ test('v2.1 mover_repair_sessions 走审批并修复未记账夹具会话', async
|
|
|
1919
2111
|
assert.equal(approvalCalls.length, 1);
|
|
1920
2112
|
assert.equal(approvalCalls[0].toolName, 'mover_repair_sessions');
|
|
1921
2113
|
});
|
|
2114
|
+
|
|
2115
|
+
// ===== v2.2.0 mover.doctor 自检 =====
|
|
2116
|
+
|
|
2117
|
+
test('v2.2 mover.doctor 沙箱全绿:服务/数据/工作区全部 pass', async () => {
|
|
2118
|
+
apply(ctx);
|
|
2119
|
+
const res = await call('mover.doctor');
|
|
2120
|
+
assert.equal(res.ok, true, JSON.stringify(res));
|
|
2121
|
+
const { checks, summary } = res.value;
|
|
2122
|
+
assert.ok(checks.length >= 12, `至少 12 项检查,实际 ${checks.length}`);
|
|
2123
|
+
for (const c of checks) assert.ok(['pass', 'warn', 'fail'].includes(c.state), `state 合法: ${c.id}`);
|
|
2124
|
+
assert.equal(summary.fail, 0, `不应有 fail: ${JSON.stringify(checks.filter((c) => c.state === 'fail'))}`);
|
|
2125
|
+
const ids = new Set(checks.map((c) => c.id));
|
|
2126
|
+
for (const expected of ['host-registry', 'host-persistence', 'host-projection', 'data-recycle', 'data-backups', 'recovery', 'workspaces']) {
|
|
2127
|
+
assert.ok(ids.has(expected), `缺少检查项 ${expected}`);
|
|
2128
|
+
}
|
|
2129
|
+
assert.equal(typeof res.value.generatedAt, 'string');
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
test('v2.2 mover.doctor 降级:摘除注册表后 fail + warn,但不抛错', async () => {
|
|
2133
|
+
apply(ctx);
|
|
2134
|
+
ctx.workspaceRegistry = undefined;
|
|
2135
|
+
const res = await call('mover.doctor');
|
|
2136
|
+
assert.equal(res.ok, true, 'doctor 本身永远 ok');
|
|
2137
|
+
const registryCheck = res.value.checks.find((c) => c.id === 'host-registry');
|
|
2138
|
+
assert.equal(registryCheck.state, 'fail');
|
|
2139
|
+
const workspacesCheck = res.value.checks.find((c) => c.id === 'workspaces');
|
|
2140
|
+
assert.equal(workspacesCheck.state, 'warn');
|
|
2141
|
+
assert.equal(res.value.summary.fail >= 1, true);
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
test('v2.2 mover.doctor 恢复记录告警:recovery.json 存在时 warn', async () => {
|
|
2145
|
+
apply(ctx);
|
|
2146
|
+
const dir = join(root, 'workspace-mover');
|
|
2147
|
+
mkdirSync(dir, { recursive: true });
|
|
2148
|
+
writeFileSync(join(dir, 'recovery.json'), JSON.stringify({ version: 1, records: [{ id: 'rec-x', kind: 'delete', phase: 'test', createdAt: new Date().toISOString() }] }));
|
|
2149
|
+
const res = await call('mover.doctor');
|
|
2150
|
+
assert.equal(res.ok, true);
|
|
2151
|
+
const recovery = res.value.checks.find((c) => c.id === 'recovery');
|
|
2152
|
+
assert.equal(recovery.state, 'warn');
|
|
2153
|
+
assert.match(recovery.detail, /1 record/);
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
//#region v2.2 存储布局自证(插件复刻了 session-persistence-jsonl 的目录编码,
|
|
2157
|
+
// 且 persistence.root 并非文档化接口——宿主改布局时必须可检测而非静默失效)
|
|
2158
|
+
|
|
2159
|
+
test('mover.status 报告 storageLayout=ok,且 doctor 有 data-layout 通过项', async () => {
|
|
2160
|
+
apply(ctx);
|
|
2161
|
+
const status = await call('mover.status');
|
|
2162
|
+
assert.equal(status.ok, true);
|
|
2163
|
+
assert.equal(status.value.capabilities.storageLayout, 'ok',
|
|
2164
|
+
`沙箱夹具的布局必须自证通过: ${JSON.stringify(status.value.storageLayout)}`);
|
|
2165
|
+
assert.ok(!status.value.degradedFeatures.includes('storage-layout'));
|
|
2166
|
+
|
|
2167
|
+
const doc = await call('mover.doctor');
|
|
2168
|
+
const layoutCheck = doc.value.checks.find((c) => c.id === 'data-layout');
|
|
2169
|
+
assert.ok(layoutCheck, 'doctor 必须包含 data-layout 检查项');
|
|
2170
|
+
assert.equal(layoutCheck.state, 'pass');
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
test('宿主布局漂移时自证失败:storageLayout=degraded 且列为降级特性', async () => {
|
|
2174
|
+
apply(ctx);
|
|
2175
|
+
// 把持久化根指向别处:插件按自己的编码规则反推出的目录在那边不存在。
|
|
2176
|
+
ctx.sessionPersistence.root = join(root, 'somewhere-else');
|
|
2177
|
+
const status = await call('mover.status');
|
|
2178
|
+
assert.equal(status.ok, true, 'status 本身永远 ok');
|
|
2179
|
+
assert.equal(status.value.capabilities.storageLayout, 'degraded');
|
|
2180
|
+
assert.ok(status.value.degradedFeatures.includes('storage-layout'),
|
|
2181
|
+
`降级特性应包含 storage-layout: ${JSON.stringify(status.value.degradedFeatures)}`);
|
|
2182
|
+
assert.equal(typeof status.value.storageLayout.reason, 'string');
|
|
2183
|
+
|
|
2184
|
+
const doc = await call('mover.doctor');
|
|
2185
|
+
assert.equal(doc.ok, true, 'doctor 不因布局异常而抛错');
|
|
2186
|
+
const layoutCheck = doc.value.checks.find((c) => c.id === 'data-layout');
|
|
2187
|
+
assert.equal(layoutCheck.state, 'warn');
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
test('没有会话可校验时 storageLayout=empty(全新安装不误报降级)', async () => {
|
|
2191
|
+
apply(ctx);
|
|
2192
|
+
// 清空持久化根:list() 动态扫描,返回空即无样本可校验。
|
|
2193
|
+
rmSync(root, { recursive: true, force: true });
|
|
2194
|
+
const status = await call('mover.status');
|
|
2195
|
+
assert.equal(status.ok, true);
|
|
2196
|
+
assert.equal(status.value.capabilities.storageLayout, 'empty');
|
|
2197
|
+
assert.ok(!status.value.degradedFeatures.includes('storage-layout'), '无样本不应报降级');
|
|
2198
|
+
});
|
|
2199
|
+
//#endregion
|
|
2200
|
+
|