dsh-workspace-mover 2.4.1 → 2.4.3
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/CHANGELOG.md +345 -317
- package/README.md +389 -373
- package/README_EN.md +376 -360
- package/client/client.js +216 -23
- package/docs/ARCHITECTURE.md +224 -224
- package/docs/compatibility/dsh-0.2.0-rc.2-2026-10-13.md +45 -45
- package/docs/compatibility/immediate-migration-2026-10-26.md +68 -0
- package/docs/compatibility/issue-11-pr-12-review-2026-11-02.md +103 -0
- package/docs/compatibility/issue-5-desktop-acceptance-2026-10-22.md +106 -106
- package/docs/compatibility/pr-review-2026-10-22.md +56 -56
- package/lib/index.js +90 -29
- package/lib/lifecycle.js +104 -0
- package/package.json +104 -103
- package/test/client-dom.test.mjs +194 -4
- package/test/core.test.mjs +73 -73
- package/test/e2e-sandbox.test.mjs +82 -2
- package/test/live-migration.test.mjs +253 -0
- package/test/release-downloads.test.mjs +102 -102
- package/test/traffic-badge.test.mjs +80 -80
package/test/client-dom.test.mjs
CHANGED
|
@@ -493,6 +493,104 @@ test('模块导出 apply / inject,且 apply 不抛错', () => {
|
|
|
493
493
|
assert.deepEqual(mod.inject, ['connection', 'slots']);
|
|
494
494
|
});
|
|
495
495
|
|
|
496
|
+
test('locale service wins over browser language, switches live and releases registrations', async () => {
|
|
497
|
+
resetDom();
|
|
498
|
+
let language = 'en';
|
|
499
|
+
let dicts;
|
|
500
|
+
let unregisters = 0;
|
|
501
|
+
const listeners = new Set();
|
|
502
|
+
const locale = {
|
|
503
|
+
register(ns, values) {
|
|
504
|
+
assert.equal(ns, 'workspace-mover'); dicts = values;
|
|
505
|
+
return () => { unregisters++; };
|
|
506
|
+
},
|
|
507
|
+
bind() { return (key) => dicts[language][key] ?? key; },
|
|
508
|
+
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
509
|
+
};
|
|
510
|
+
ctx = makeCtx();
|
|
511
|
+
ctx.get = (key) => key === 'locale' ? locale : undefined;
|
|
512
|
+
mod.apply(ctx);
|
|
513
|
+
const row = sessionRow('Locale test', 'session-aaa');
|
|
514
|
+
const beta = header('proj-beta');
|
|
515
|
+
fixture({ rows: [row] }); attach(beta);
|
|
516
|
+
await dragDrop(row, beta, 'session-aaa', { confirm: false });
|
|
517
|
+
assert.equal(doc._body.querySelector('.wsm-title').textContent, 'Move session across workspaces');
|
|
518
|
+
dispatch({ type: 'click', target: doc._body.querySelector('.wsm-cancel') });
|
|
519
|
+
language = 'zh'; for (const fn of listeners) fn();
|
|
520
|
+
await dragDrop(row, beta, 'session-aaa', { confirm: false });
|
|
521
|
+
assert.equal(doc._body.querySelector('.wsm-title').textContent, '跨工作区移动会话');
|
|
522
|
+
dispatch({ type: 'click', target: doc._body.querySelector('.wsm-cancel') });
|
|
523
|
+
unmount();
|
|
524
|
+
assert.equal(unregisters, 1);
|
|
525
|
+
assert.equal(listeners.size, 0);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('late locale service is disposed even when unavailable at initial mount', () => {
|
|
529
|
+
resetDom();
|
|
530
|
+
ctx = makeCtx();
|
|
531
|
+
let arrive;
|
|
532
|
+
ctx.inject = (keys, callback) => { assert.deepEqual(keys, ['locale']); arrive = callback; };
|
|
533
|
+
mod.apply(ctx);
|
|
534
|
+
let disposed = 0;
|
|
535
|
+
const locale = { register: () => () => disposed++, bind: () => () => 'translated' };
|
|
536
|
+
arrive({ get: () => locale, effect(fn) { effects.push(fn()); } });
|
|
537
|
+
unmount();
|
|
538
|
+
assert.equal(disposed, 1);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
test('deleting selected conversation clears its view without reopening; later selection is preserved', async () => {
|
|
542
|
+
for (const navigate of [false, true]) {
|
|
543
|
+
resetDom();
|
|
544
|
+
ctx = makeCtx();
|
|
545
|
+
let selected = 'session-delete';
|
|
546
|
+
let cleared = 0;
|
|
547
|
+
let rpc;
|
|
548
|
+
const ui = {
|
|
549
|
+
selection: { getSnapshot: () => ({ sessionId: selected }) },
|
|
550
|
+
clearMain() { selected = null; cleared++; },
|
|
551
|
+
openSession() { assert.fail('must not reload deleted conversation'); }
|
|
552
|
+
};
|
|
553
|
+
ctx.get = (key) => key === 'uiWorkspace' ? ui : undefined;
|
|
554
|
+
ctx.slots = {
|
|
555
|
+
inject(_name, fn) { fn(); },
|
|
556
|
+
register(options) { rpc = options.inject().rpcCall; }
|
|
557
|
+
};
|
|
558
|
+
ctx.connection.rpc.call = async () => {
|
|
559
|
+
if (navigate) selected = 'session-other';
|
|
560
|
+
return { ok: true, value: { deleted: true } };
|
|
561
|
+
};
|
|
562
|
+
mod.apply(ctx);
|
|
563
|
+
await rpc('mover.session.delete', { sessionId: 'session-delete' });
|
|
564
|
+
assert.equal(cleared, navigate ? 0 : 1);
|
|
565
|
+
assert.equal(selected, navigate ? 'session-other' : null);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test('restore refreshes the real session baseline once; refresh failure never repeats file restoration', async () => {
|
|
570
|
+
for (const endpoint of ['mover.trash.restore', 'mover.backups.restore', 'mover.unarchive']) {
|
|
571
|
+
for (const brokenRefresh of [false, true]) {
|
|
572
|
+
resetDom();
|
|
573
|
+
ctx = makeCtx();
|
|
574
|
+
let rpc;
|
|
575
|
+
const actions = [];
|
|
576
|
+
ctx.slots = { inject(_name, fn) { fn(); }, register(options) { rpc = options.inject().rpcCall; } };
|
|
577
|
+
ctx.get = (key) => key === 'sessions' ? {
|
|
578
|
+
async refresh() { actions.push('refresh'); if (brokenRefresh) throw new Error('list unavailable'); }
|
|
579
|
+
} : undefined;
|
|
580
|
+
ctx.connection.rpc.call = async (_channel, method) => {
|
|
581
|
+
actions.push(method);
|
|
582
|
+
return { ok: true, value: { restored: true } };
|
|
583
|
+
};
|
|
584
|
+
mod.apply(ctx);
|
|
585
|
+
actions.length = 0; // discard the initial status probe
|
|
586
|
+
const result = await rpc(endpoint, { sessionId: 'session-restored' });
|
|
587
|
+
assert.equal(result.ok, true, 'do not label completed restore as failed');
|
|
588
|
+
assert.deepEqual(actions, [endpoint, 'refresh']);
|
|
589
|
+
if (brokenRefresh) assert.match(doc._body.querySelector('.wsm-toast')?.textContent, /无需重复还原/);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
|
|
496
594
|
test('热重载:卸载再装载不得让 document 监听器净增长(teardown 回归)', () => {
|
|
497
595
|
resetDom();
|
|
498
596
|
mount();
|
|
@@ -566,7 +664,7 @@ test('拖拽跨组移动:经权威 fiber 通道解析出正确 sessionId', asy
|
|
|
566
664
|
assert.equal(calls[0].payload.targetWorkspaceId, 'wid-b');
|
|
567
665
|
});
|
|
568
666
|
|
|
569
|
-
test('
|
|
667
|
+
test('即时迁移使用权威标题,不包含侧边栏时间,不启用排队', async () => {
|
|
570
668
|
resetDom();
|
|
571
669
|
mount();
|
|
572
670
|
const row = sessionRow('测试刚刚', 'session-aaa');
|
|
@@ -575,22 +673,114 @@ test('迁移标题使用权威预览,不包含侧边栏时间;驻留队列
|
|
|
575
673
|
const prior = rpcResponder;
|
|
576
674
|
setResponder((endpoint, payload) => endpoint === 'mover.preview' ? {
|
|
577
675
|
ok: true, value: {
|
|
578
|
-
title: '测试', status: '
|
|
676
|
+
title: '测试', status: 'safe', liveMigration: true, source: { cwd: 'C:/proj-alpha' },
|
|
579
677
|
target: { path: 'C:/proj-beta' }, estimatedBackupBytes: 0,
|
|
580
|
-
warnings: [
|
|
678
|
+
warnings: []
|
|
581
679
|
}
|
|
582
680
|
} : prior(endpoint, payload));
|
|
583
681
|
attach(beta);
|
|
584
682
|
await dragDrop(row, beta, 'session-aaa', { confirm: false });
|
|
585
683
|
const overlay = doc._body.querySelector('.wsm-overlay');
|
|
586
684
|
assert.equal(overlay.querySelector('.wsm-session').textContent, '测试');
|
|
587
|
-
assert.
|
|
685
|
+
assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /可能需重启/);
|
|
588
686
|
assert.doesNotMatch(overlay.querySelector('.wsm-preview').textContent, /进入空闲队列/);
|
|
589
687
|
dispatch({ type: 'click', target: overlay.querySelector('.wsm-ok') });
|
|
590
688
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
591
689
|
assert.equal(moveCalls()[0].payload.sessionTitle, '测试');
|
|
690
|
+
assert.equal(moveCalls()[0].payload.allowQueue, false);
|
|
592
691
|
});
|
|
593
692
|
|
|
693
|
+
for (const outcome of ['success', 'render-release', 'navigation-during-release', 'rollback', 'transport-error', 'navigated', 'healthy', 'unloaded']) {
|
|
694
|
+
test(`migration reconnects the removed current conversation via host navigation: ${outcome}`, async () => {
|
|
695
|
+
resetDom(); mount();
|
|
696
|
+
const row = sessionRow('标题保留测试', 'session-aaa');
|
|
697
|
+
const beta = header('proj-beta');
|
|
698
|
+
fixture({ rows: [row] });
|
|
699
|
+
attach(beta);
|
|
700
|
+
let selected = 'session-aaa';
|
|
701
|
+
let removed = false;
|
|
702
|
+
const releaseListeners = new Set();
|
|
703
|
+
let binding;
|
|
704
|
+
const navigation = [];
|
|
705
|
+
const ui = {
|
|
706
|
+
selection: { getSnapshot: () => ({ sessionId: selected }) },
|
|
707
|
+
clearMain() {
|
|
708
|
+
navigation.push('release'); selected = undefined;
|
|
709
|
+
const release = () => {
|
|
710
|
+
binding = undefined;
|
|
711
|
+
if (outcome === 'navigation-during-release') selected = 'session-other';
|
|
712
|
+
for (const listener of releaseListeners) listener();
|
|
713
|
+
};
|
|
714
|
+
if (outcome === 'render-release' || outcome === 'navigation-during-release') setTimeout(release, 0);
|
|
715
|
+
else release();
|
|
716
|
+
},
|
|
717
|
+
openSession(id) {
|
|
718
|
+
assert.equal(binding, undefined, 'must wait for Provider reference release before retaining again');
|
|
719
|
+
navigation.push(id); selected = id; removed = false; binding = { session };
|
|
720
|
+
}
|
|
721
|
+
};
|
|
722
|
+
const session = {
|
|
723
|
+
getSnapshot: () => ({ removed }),
|
|
724
|
+
async open() { navigation.push('ready'); }
|
|
725
|
+
};
|
|
726
|
+
binding = { session };
|
|
727
|
+
const sessions = { binding: () => binding, retainInfo: () => ({
|
|
728
|
+
subscribe(listener) { releaseListeners.add(listener); return () => releaseListeners.delete(listener); }
|
|
729
|
+
}) };
|
|
730
|
+
ctx.get = (key) => ({ uiWorkspace: ui, sessions })[key];
|
|
731
|
+
const prior = rpcResponder;
|
|
732
|
+
setResponder((endpoint, payload) => {
|
|
733
|
+
if (endpoint !== 'mover.move') return prior(endpoint, payload);
|
|
734
|
+
removed = outcome !== 'healthy';
|
|
735
|
+
if (outcome === 'navigated') selected = 'session-other';
|
|
736
|
+
if (outcome === 'unloaded') unmount();
|
|
737
|
+
if (outcome === 'transport-error') throw new Error('Connection lost after release');
|
|
738
|
+
if (outcome === 'rollback') return { ok: false, error: { code: 'move-failed', message: 'rolled back cleanly' } };
|
|
739
|
+
return prior(endpoint, payload);
|
|
740
|
+
});
|
|
741
|
+
await dragDrop(row, beta, 'session-aaa');
|
|
742
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
743
|
+
assert.equal(moveCalls().length, 1, 'never repeats the physical migration');
|
|
744
|
+
if (['navigated', 'healthy', 'unloaded'].includes(outcome)) {
|
|
745
|
+
assert.deepEqual(navigation, []);
|
|
746
|
+
} else if (outcome === 'navigation-during-release') {
|
|
747
|
+
assert.deepEqual(navigation, ['release']);
|
|
748
|
+
assert.equal(selected, 'session-other');
|
|
749
|
+
} else {
|
|
750
|
+
assert.deepEqual(navigation, ['release', 'session-aaa', 'ready']);
|
|
751
|
+
assert.equal(removed, false, 'new view can accept messages without manually switching');
|
|
752
|
+
}
|
|
753
|
+
assert.equal(releaseListeners.size, 0, 'retention observer is released');
|
|
754
|
+
if (outcome === 'success') {
|
|
755
|
+
const text = doc._body.querySelector('.wsm-toast')?.textContent;
|
|
756
|
+
assert.match(text, /重启应用/);
|
|
757
|
+
assert.doesNotMatch(text, /刷新页面/);
|
|
758
|
+
}
|
|
759
|
+
if (outcome === 'rollback') assert.match(doc._body.querySelector('.wsm-toast')?.textContent, /rolled back cleanly/);
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
for (const approve of [false, true]) {
|
|
764
|
+
test(`运行中拖动会话:${approve ? '明确停止后迁移' : '取消不执行'}`, async () => {
|
|
765
|
+
resetDom(); mount();
|
|
766
|
+
const row = sessionRow('运行中', 'session-aaa');
|
|
767
|
+
const beta = header('proj-beta');
|
|
768
|
+
fixture({ rows: [row] });
|
|
769
|
+
const prior = rpcResponder;
|
|
770
|
+
setResponder((endpoint, payload) => endpoint === 'mover.preview' ? { ok: true, value: {
|
|
771
|
+
status: 'warning', running: true, liveMigration: true, warnings: ['session is running; moving requires explicit stop confirmation']
|
|
772
|
+
} } : prior(endpoint, payload));
|
|
773
|
+
attach(beta);
|
|
774
|
+
await dragDrop(row, beta, 'session-aaa', { confirm: false });
|
|
775
|
+
const overlay = doc._body.querySelector('.wsm-overlay');
|
|
776
|
+
assert.equal(overlay.querySelector('.wsm-ok').textContent, '停止任务并迁移');
|
|
777
|
+
dispatch({ type: 'click', target: overlay.querySelector(approve ? '.wsm-ok' : '.wsm-cancel') });
|
|
778
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
779
|
+
assert.equal(moveCalls().length, approve ? 1 : 0);
|
|
780
|
+
if (approve) assert.equal(moveCalls()[0].payload.stopRunning, true);
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
594
784
|
test('兜底映射错配时不得把错配 id 当成真实会话移动(P2 回归)', async () => {
|
|
595
785
|
resetDom();
|
|
596
786
|
mount();
|
package/test/core.test.mjs
CHANGED
|
@@ -1,55 +1,55 @@
|
|
|
1
|
-
// 核心纯函数单元测试:帧扫描 / 头部读写 / 存储路径编码。
|
|
2
|
-
// 运行:npm test(或 node --test test/)
|
|
3
|
-
import { test } from 'node:test';
|
|
4
|
-
import assert from 'node:assert/strict';
|
|
5
|
-
import { zstdCompressSync, constants } from 'node:zlib';
|
|
6
|
-
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
7
|
-
import { tmpdir } from 'node:os';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { scanFrames, readHeader, rewriteHeaderCwd, generationLogFilename, parseGenerationLogFilename, findHighestArtifact, artifactPath, sessionDir } from '../lib/index.js';
|
|
10
|
-
|
|
11
|
-
const OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
|
|
12
|
-
|
|
13
|
-
/** 构造一个「头 + N 个事件帧」的合成档案。 */
|
|
14
|
-
function makeArtifact(header, events) {
|
|
15
|
-
const head = zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), OPTS);
|
|
16
|
-
const rest = events.map((e) => zstdCompressSync(Buffer.from(JSON.stringify(e) + '\n', 'utf8'), OPTS));
|
|
17
|
-
return Buffer.concat([head, ...rest]);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
test('scanFrames 定位全部帧边界', () => {
|
|
21
|
-
const buf = makeArtifact({ type: 'session', id: 's1', cwd: 'E:\\a' }, [{ seq: 1 }, { seq: 2 }]);
|
|
22
|
-
const frames = scanFrames(buf);
|
|
23
|
-
assert.equal(frames.length, 3);
|
|
24
|
-
for (const [s, e] of frames) assert.ok(e > s && e <= buf.length);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
test('readHeader 解出首行会话头', () => {
|
|
28
|
-
const buf = makeArtifact({ type: 'session', id: 'session-x', cwd: 'C:\\work' }, [{ seq: 1 }]);
|
|
29
|
-
const header = readHeader(buf);
|
|
30
|
-
assert.equal(header.type, 'session');
|
|
31
|
-
assert.equal(header.id, 'session-x');
|
|
32
|
-
assert.equal(header.cwd, 'C:\\work');
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
test('rewriteHeaderCwd 只改头部,其余帧字节不变', () => {
|
|
36
|
-
const ev = [{ seq: 1, text: 'hello 世界' }, { seq: 2 }];
|
|
37
|
-
const original = makeArtifact({ type: 'session', id: 'session-y', cwd: 'E:\\old\\path' }, ev);
|
|
38
|
-
const rewritten = rewriteHeaderCwd(original, 'E:\\new\\home');
|
|
39
|
-
// 头部已更新
|
|
40
|
-
const header = readHeader(rewritten);
|
|
41
|
-
assert.equal(header.cwd, 'E:\\new\\home');
|
|
42
|
-
assert.equal(header.id, 'session-y');
|
|
43
|
-
// 其余事件帧逐字节保留
|
|
44
|
-
const of = scanFrames(original), nf = scanFrames(rewritten);
|
|
45
|
-
assert.equal(nf.length, of.length);
|
|
46
|
-
assert.ok(of.slice(1).every(([, e], i) => original.subarray(of[i + 1][0], e).equals(rewritten.subarray(nf[i + 1][0], nf[i + 1][1]))));
|
|
47
|
-
});
|
|
48
|
-
|
|
1
|
+
// 核心纯函数单元测试:帧扫描 / 头部读写 / 存储路径编码。
|
|
2
|
+
// 运行:npm test(或 node --test test/)
|
|
3
|
+
import { test } from 'node:test';
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { zstdCompressSync, constants } from 'node:zlib';
|
|
6
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { scanFrames, readHeader, rewriteHeaderCwd, generationLogFilename, parseGenerationLogFilename, findHighestArtifact, artifactPath, sessionDir } from '../lib/index.js';
|
|
10
|
+
|
|
11
|
+
const OPTS = { params: { [constants.ZSTD_c_checksumFlag]: 1 } };
|
|
12
|
+
|
|
13
|
+
/** 构造一个「头 + N 个事件帧」的合成档案。 */
|
|
14
|
+
function makeArtifact(header, events) {
|
|
15
|
+
const head = zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), OPTS);
|
|
16
|
+
const rest = events.map((e) => zstdCompressSync(Buffer.from(JSON.stringify(e) + '\n', 'utf8'), OPTS));
|
|
17
|
+
return Buffer.concat([head, ...rest]);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test('scanFrames 定位全部帧边界', () => {
|
|
21
|
+
const buf = makeArtifact({ type: 'session', id: 's1', cwd: 'E:\\a' }, [{ seq: 1 }, { seq: 2 }]);
|
|
22
|
+
const frames = scanFrames(buf);
|
|
23
|
+
assert.equal(frames.length, 3);
|
|
24
|
+
for (const [s, e] of frames) assert.ok(e > s && e <= buf.length);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('readHeader 解出首行会话头', () => {
|
|
28
|
+
const buf = makeArtifact({ type: 'session', id: 'session-x', cwd: 'C:\\work' }, [{ seq: 1 }]);
|
|
29
|
+
const header = readHeader(buf);
|
|
30
|
+
assert.equal(header.type, 'session');
|
|
31
|
+
assert.equal(header.id, 'session-x');
|
|
32
|
+
assert.equal(header.cwd, 'C:\\work');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('rewriteHeaderCwd 只改头部,其余帧字节不变', () => {
|
|
36
|
+
const ev = [{ seq: 1, text: 'hello 世界' }, { seq: 2 }];
|
|
37
|
+
const original = makeArtifact({ type: 'session', id: 'session-y', cwd: 'E:\\old\\path' }, ev);
|
|
38
|
+
const rewritten = rewriteHeaderCwd(original, 'E:\\new\\home');
|
|
39
|
+
// 头部已更新
|
|
40
|
+
const header = readHeader(rewritten);
|
|
41
|
+
assert.equal(header.cwd, 'E:\\new\\home');
|
|
42
|
+
assert.equal(header.id, 'session-y');
|
|
43
|
+
// 其余事件帧逐字节保留
|
|
44
|
+
const of = scanFrames(original), nf = scanFrames(rewritten);
|
|
45
|
+
assert.equal(nf.length, of.length);
|
|
46
|
+
assert.ok(of.slice(1).every(([, e], i) => original.subarray(of[i + 1][0], e).equals(rewritten.subarray(nf[i + 1][0], nf[i + 1][1]))));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
49
|
test('损坏档案被拒绝而不是静默通过', () => {
|
|
50
|
-
const buf = makeArtifact({ type: 'session', id: 's', cwd: 'x' }, []);
|
|
51
|
-
buf.writeUInt32LE(0x12345678, 8); // 破坏第一帧内部
|
|
52
|
-
assert.throws(() => readHeader(buf));
|
|
50
|
+
const buf = makeArtifact({ type: 'session', id: 's', cwd: 'x' }, []);
|
|
51
|
+
buf.writeUInt32LE(0x12345678, 8); // 破坏第一帧内部
|
|
52
|
+
assert.throws(() => readHeader(buf));
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test('readHeader and rewriteHeaderCwd support uncompressed JSONL', () => {
|
|
@@ -67,29 +67,29 @@ test('readHeader and rewriteHeaderCwd support uncompressed JSONL', () => {
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
test('generationLogFilename prefers v3 zstd names', () => {
|
|
70
|
-
assert.equal(generationLogFilename(0, 'zstd'), 'session.jsonl.zstd');
|
|
71
|
-
assert.equal(generationLogFilename(3, 'zstd'), 'session.v3.jsonl.zstd');
|
|
72
|
-
assert.equal(parseGenerationLogFilename('session.jsonl.zstd')?.version, 0);
|
|
73
|
-
assert.equal(parseGenerationLogFilename('session.v3.jsonl.zstd')?.version, 3);
|
|
74
|
-
assert.equal(parseGenerationLogFilename('session.migration.tmp.jsonl.zstd'), null);
|
|
75
|
-
});
|
|
76
|
-
|
|
70
|
+
assert.equal(generationLogFilename(0, 'zstd'), 'session.jsonl.zstd');
|
|
71
|
+
assert.equal(generationLogFilename(3, 'zstd'), 'session.v3.jsonl.zstd');
|
|
72
|
+
assert.equal(parseGenerationLogFilename('session.jsonl.zstd')?.version, 0);
|
|
73
|
+
assert.equal(parseGenerationLogFilename('session.v3.jsonl.zstd')?.version, 3);
|
|
74
|
+
assert.equal(parseGenerationLogFilename('session.migration.tmp.jsonl.zstd'), null);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
77
|
test('findHighestArtifact picks the newest canonical generation', () => {
|
|
78
|
-
const root = mkdtempSync(join(tmpdir(), 'wsm-gen-'));
|
|
79
|
-
try {
|
|
80
|
-
const dir = join(root, 'sess');
|
|
81
|
-
mkdirSync(dir, { recursive: true });
|
|
82
|
-
writeFileSync(join(dir, 'session.jsonl.zstd'), Buffer.from('old'));
|
|
83
|
-
writeFileSync(join(dir, 'session.v2.jsonl.zstd'), Buffer.from('mid'));
|
|
84
|
-
writeFileSync(join(dir, 'session.v3.jsonl.zstd'), Buffer.from('new'));
|
|
85
|
-
writeFileSync(join(dir, 'session.migration.abc.jsonl.zstd'), Buffer.from('tmp'));
|
|
86
|
-
const found = findHighestArtifact(dir);
|
|
87
|
-
assert.equal(found?.version, 3);
|
|
88
|
-
assert.equal(found?.name, 'session.v3.jsonl.zstd');
|
|
89
|
-
assert.equal(artifactPath(root, 'E:\\proj', 'session-x'), join(sessionDir(root, 'E:\\proj', 'session-x'), 'session.v3.jsonl.zstd'));
|
|
90
|
-
} finally {
|
|
91
|
-
rmSync(root, { recursive: true, force: true });
|
|
92
|
-
}
|
|
78
|
+
const root = mkdtempSync(join(tmpdir(), 'wsm-gen-'));
|
|
79
|
+
try {
|
|
80
|
+
const dir = join(root, 'sess');
|
|
81
|
+
mkdirSync(dir, { recursive: true });
|
|
82
|
+
writeFileSync(join(dir, 'session.jsonl.zstd'), Buffer.from('old'));
|
|
83
|
+
writeFileSync(join(dir, 'session.v2.jsonl.zstd'), Buffer.from('mid'));
|
|
84
|
+
writeFileSync(join(dir, 'session.v3.jsonl.zstd'), Buffer.from('new'));
|
|
85
|
+
writeFileSync(join(dir, 'session.migration.abc.jsonl.zstd'), Buffer.from('tmp'));
|
|
86
|
+
const found = findHighestArtifact(dir);
|
|
87
|
+
assert.equal(found?.version, 3);
|
|
88
|
+
assert.equal(found?.name, 'session.v3.jsonl.zstd');
|
|
89
|
+
assert.equal(artifactPath(root, 'E:\\proj', 'session-x'), join(sessionDir(root, 'E:\\proj', 'session-x'), 'session.v3.jsonl.zstd'));
|
|
90
|
+
} finally {
|
|
91
|
+
rmSync(root, { recursive: true, force: true });
|
|
92
|
+
}
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
test('findHighestArtifact supports the official uncompressed encoding', () => {
|
|
@@ -62,13 +62,18 @@ for (const compression of ['none', 'zstd']) {
|
|
|
62
62
|
writeFileSync(src, original);
|
|
63
63
|
const projections = { identity: { cwd: A }, rows: { workingDirectory: { val: A }, title: { val: 'Keep title' } } };
|
|
64
64
|
let updated;
|
|
65
|
-
|
|
65
|
+
let cached = projections;
|
|
66
|
+
ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
|
|
67
|
+
get() { return cached; }, async put(id, value) { cached = updated = value; }
|
|
68
|
+
} } : undefined;
|
|
66
69
|
entityA.record.sessionIds.push('session-aaa');
|
|
67
70
|
entityB.failAttach = true;
|
|
68
71
|
const failure = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
69
72
|
assert.equal(failure.ok, false);
|
|
70
73
|
assert.match(failure.error.message, /rolled back cleanly/);
|
|
71
74
|
assert.deepEqual(readFileSync(src), original);
|
|
75
|
+
assert.equal(cached.identity.cwd, A, 'failed attach restores the title identity');
|
|
76
|
+
assert.deepEqual(cached.rows, projections.rows, 'rollback restores every original projection row');
|
|
72
77
|
entityB.failAttach = false;
|
|
73
78
|
const moved = await call('mover.move', { sessionId: 'session-aaa', targetWorkspaceId: 'wid-b' });
|
|
74
79
|
assert.equal(moved.ok, true, JSON.stringify(moved));
|
|
@@ -926,7 +931,7 @@ test('mover.scan 分类 orphaned / unregistered / ok,并检出幽灵记账', a
|
|
|
926
931
|
assert.equal(byId['session-unreg'].status, 'unregistered');
|
|
927
932
|
assert.equal(byId['session-unreg'].targetWorkspaceId, 'wid-a');
|
|
928
933
|
assert.equal(byId['session-orphan'].status, 'orphaned');
|
|
929
|
-
assert.deepEqual(res.value.ghosts, [{ workspaceId: 'wid-b', sessionId: 'session-ghost' }]);
|
|
934
|
+
assert.deepEqual(res.value.ghosts, [{ workspaceId: 'wid-b', sessionId: 'session-ghost', locations: [], status: 'location-unknown', archived: false }]);
|
|
930
935
|
assert.equal(res.value.scanned, 3);
|
|
931
936
|
});
|
|
932
937
|
|
|
@@ -1437,6 +1442,46 @@ test('mover.archived 列出归档会话:归属取自原始记账,cwd 挂错
|
|
|
1437
1442
|
assert.equal(bbb.suggestedTitle, 'wid-b');
|
|
1438
1443
|
});
|
|
1439
1444
|
|
|
1445
|
+
test('current per-record and live titles override legacy titles across repair, archive, preview and backups', async () => {
|
|
1446
|
+
apply(ctx);
|
|
1447
|
+
const id = 'session-aaa';
|
|
1448
|
+
writeFileSync(artifactPath(root, A, id), makeArtifact({ type: 'session', id, cwd: A }));
|
|
1449
|
+
const storage = join(root, 'storages');
|
|
1450
|
+
const checkpoint = join(storage, 'session_projcache', 'sessions', `${id}.json`);
|
|
1451
|
+
mkdirSync(dirname(checkpoint), { recursive: true });
|
|
1452
|
+
writeFileSync(join(storage, 'session_projcache.json'), JSON.stringify({ tables: {
|
|
1453
|
+
sessions: { [id]: { rows: { title: { val: 'Outdated title' } } } }
|
|
1454
|
+
} }), 'utf8');
|
|
1455
|
+
writeFileSync(checkpoint, JSON.stringify({ version: 7, record: {
|
|
1456
|
+
identity: { cwd: A }, rows: { title: { val: '标题保留测试' } }
|
|
1457
|
+
} }), 'utf8');
|
|
1458
|
+
entityA.record.sessionIds.push(id);
|
|
1459
|
+
sharedIndex.sessionPaths.set(id, A);
|
|
1460
|
+
ctx.workspaceRegistry.archivedSessionIds = [id];
|
|
1461
|
+
stashBackup(id, readFileSync(artifactPath(root, A, id)));
|
|
1462
|
+
const titles = async () => {
|
|
1463
|
+
const scan = await call('mover.scan');
|
|
1464
|
+
const archived = await call('mover.archived');
|
|
1465
|
+
const preview = await call('mover.preview', { sessionId: id, targetWorkspaceId: 'wid-b' });
|
|
1466
|
+
const backups = await call('mover.backups.list');
|
|
1467
|
+
for (const result of [scan, archived, preview, backups]) assert.equal(result.ok, true, JSON.stringify(result));
|
|
1468
|
+
return [scan.value.items.find((item) => item.sessionId === id).title,
|
|
1469
|
+
archived.value.items.find((item) => item.sessionId === id).title,
|
|
1470
|
+
preview.value.title, backups.value.items.find((item) => item.sessionId === id).title];
|
|
1471
|
+
};
|
|
1472
|
+
assert.deepEqual(await titles(), Array(4).fill('标题保留测试'));
|
|
1473
|
+
ctx.get = (key) => key === 'sessionProjectionCache' ? { table: {
|
|
1474
|
+
get: () => ({ rows: { title: { val: 'Latest live title' } } })
|
|
1475
|
+
} } : undefined;
|
|
1476
|
+
assert.deepEqual(await titles(), Array(4).fill('Latest live title'));
|
|
1477
|
+
ctx.get = () => undefined;
|
|
1478
|
+
writeFileSync(checkpoint, '{broken', 'utf8');
|
|
1479
|
+
assert.deepEqual(await titles(), Array(4).fill('Outdated title'));
|
|
1480
|
+
rmSync(join(storage, 'session_projcache.json'));
|
|
1481
|
+
const scan = await call('mover.scan');
|
|
1482
|
+
assert.equal(scan.value.items.find((item) => item.sessionId === id).title, '未命名会话');
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1440
1485
|
test('mover.unarchive 取消归档:归档集移除、账本不动、无迁移', async () => {
|
|
1441
1486
|
apply(ctx);
|
|
1442
1487
|
entityA.record.sessionIds.push('session-aaa');
|
|
@@ -1890,6 +1935,40 @@ test('mover.session.delete:manifest 写失败时原样搬回,状态零变化
|
|
|
1890
1935
|
assert.ok(!existsSync(recycle) || readdirSync(recycle).length === 0, 'no invisible recycle entries');
|
|
1891
1936
|
});
|
|
1892
1937
|
|
|
1938
|
+
test('delete failed rollback preserves relocated archive for recovery instead of removing it', async () => {
|
|
1939
|
+
apply(ctx);
|
|
1940
|
+
entityA.record.sessionIds.push('session-aaa');
|
|
1941
|
+
const source = artifactPath(root, A, 'session-aaa');
|
|
1942
|
+
const bytes = readFileSync(source);
|
|
1943
|
+
// A successful relocation followed by a new destination file blocks move-back.
|
|
1944
|
+
await assert.rejects(deleteSessionToTrash(ctx, { sessionId: 'session-aaa' }, {
|
|
1945
|
+
writeManifestImpl() {
|
|
1946
|
+
mkdirSync(dirname(source), { recursive: true });
|
|
1947
|
+
writeFileSync(source, 'concurrent destination');
|
|
1948
|
+
throw new Error('manifest disk failure');
|
|
1949
|
+
}
|
|
1950
|
+
}), /rollback failed; session files preserved/);
|
|
1951
|
+
const recycle = join(root, 'workspace-mover', 'recycle');
|
|
1952
|
+
const entry = readdirSync(recycle)[0];
|
|
1953
|
+
assert.deepEqual(readFileSync(join(recycle, entry, 'session', source.split(/[\\/]/).at(-1))), bytes);
|
|
1954
|
+
assert.deepEqual(entityA.record.sessionIds, ['session-aaa']);
|
|
1955
|
+
});
|
|
1956
|
+
|
|
1957
|
+
test('scan distinguishes misplaced membership from unknown archive location without detaching', async () => {
|
|
1958
|
+
apply(ctx);
|
|
1959
|
+
entityB.record.sessionIds.push('session-aaa', 'session-missing');
|
|
1960
|
+
ctx.workspaceRegistry.archivedSessionIds = ['session-missing'];
|
|
1961
|
+
const res = await call('mover.scan');
|
|
1962
|
+
assert.equal(res.ok, true);
|
|
1963
|
+
const found = res.value.ghosts.find((g) => g.sessionId === 'session-aaa');
|
|
1964
|
+
assert.equal(found.status, 'found-elsewhere');
|
|
1965
|
+
assert.deepEqual(found.locations, [artifactPath(root, A, 'session-aaa')]);
|
|
1966
|
+
const unknown = res.value.ghosts.find((g) => g.sessionId === 'session-missing');
|
|
1967
|
+
assert.equal(unknown.status, 'location-unknown');
|
|
1968
|
+
assert.equal(unknown.archived, true);
|
|
1969
|
+
assert.deepEqual(entityB.record.sessionIds, ['session-aaa', 'session-missing']);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1893
1972
|
test('mover.session.delete:归档会话删除时同步移出归档集', async () => {
|
|
1894
1973
|
apply(ctx);
|
|
1895
1974
|
sharedIndex.sessionPaths.set('session-aaa', A);
|
|
@@ -1917,6 +1996,7 @@ test('mover.trash.restore:还原到原路径并回写记账、投影标题、
|
|
|
1917
1996
|
const res = await call('mover.trash.restore', { sessionId: 'session-aaa' });
|
|
1918
1997
|
assert.equal(res.ok, true, JSON.stringify(res));
|
|
1919
1998
|
assert.equal(res.value.workspaceId, 'wid-a');
|
|
1999
|
+
assert.equal(res.value.archived, true, 'recycle restore explicitly reports preserved archived visibility');
|
|
1920
2000
|
assert.ok(existsSync(artifactPath(root, A, 'session-aaa')), 'artifact back at A');
|
|
1921
2001
|
assert.equal(readHeader(readFileSync(artifactPath(root, A, 'session-aaa'))).cwd, A);
|
|
1922
2002
|
assert.deepEqual(entityA.record.sessionIds, ['session-aaa'], 're-attached to A');
|