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.
@@ -0,0 +1,415 @@
1
+ // dsh-workspace-mover — 可调策略(环境变量通道)的行为测试。
2
+ //
3
+ // 策略在使用点读取(见 lib/index.js 的 resolvePolicy),因此可以按用例设置环境变量、
4
+ // 走真实 RPC 路径观察效果。这里不测 lib/config.js 的 schema(它依赖宿主 loader 提供的
5
+ // schemastery,本环境不可解析——那正是走守卫式动态解析的原因)。
6
+ import { test, beforeEach, afterEach } from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readdirSync, rmSync, realpathSync, readFileSync } from 'node:fs';
9
+ import { join, dirname } from 'node:path';
10
+ import { tmpdir } from 'node:os';
11
+ import { zstdCompressSync, zstdDecompressSync, constants } from 'node:zlib';
12
+ import { apply, artifactPath, stashBackup, sessionDir, generationLogFilename, Config, POLICY_FIELDS, buildConfig, attachConfig } from '../lib/index.js';
13
+
14
+ let root;
15
+ let A;
16
+ let B;
17
+ let ctx;
18
+ let rpcHandler;
19
+
20
+ const ENV_KEYS = [
21
+ 'DSH_WORKSPACE_MOVER_HISTORY_LIMIT',
22
+ 'DSH_WORKSPACE_MOVER_BACKUP_KEEP',
23
+ 'DSH_WORKSPACE_MOVER_BATCH_LIMIT',
24
+ 'DSH_WORKSPACE_MOVER_REPOINT_LIMIT',
25
+ 'DSH_WORKSPACE_MOVER_SCAN_LIMIT',
26
+ 'DSH_WORKSPACE_MOVER_CLEANUP_DAYS'
27
+ ];
28
+
29
+ /** 单个 zstd 帧 + 一行 JSON 头(与 e2e 夹具同构)。 */
30
+ function makeArtifact(header) {
31
+ return zstdCompressSync(Buffer.from(JSON.stringify(header) + '\n', 'utf8'), {
32
+ params: { [constants.ZSTD_c_checksumFlag]: 1 }
33
+ });
34
+ }
35
+
36
+ function makeRoot() {
37
+ const base = realpathSync(tmpdir());
38
+ let lastErr;
39
+ for (let i = 0; i < 5; i++) {
40
+ try { return mkdtempSync(join(base, 'wsm-policy-')); } catch (err) { lastErr = err; }
41
+ }
42
+ throw lastErr;
43
+ }
44
+
45
+ function makeEntity(id, path, hostRef) {
46
+ const record = { path, title: id, sessionIds: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() };
47
+ return {
48
+ id,
49
+ get path() { return record.path; },
50
+ get title() { return record.title; },
51
+ get record() { return record; },
52
+ get sessionIds() { return [...record.sessionIds].filter((sid) => hostRef.sessionPath(String(sid)) === record.path); },
53
+ async status() { return existsSync(record.path) ? 'ok' : 'missing-dir'; },
54
+ async mutate(fn) { Object.assign(record, fn(record)); },
55
+ async setTitle(next) { record.title = String(next); },
56
+ async attachSession(sid) {
57
+ if (record.sessionIds.includes(sid)) return;
58
+ record.sessionIds.unshift(sid);
59
+ hostRef.onWorkspaceChanged?.(id, record);
60
+ },
61
+ async detachSession(sid) { record.sessionIds = record.sessionIds.filter((x) => x !== sid); }
62
+ };
63
+ }
64
+
65
+ beforeEach(() => {
66
+ for (const k of ENV_KEYS) delete process.env[k];
67
+ root = realpathSync(makeRoot());
68
+ A = join(root, 'proj-alpha');
69
+ B = join(root, 'proj-beta');
70
+ mkdirSync(A, { recursive: true });
71
+ mkdirSync(B, { recursive: true });
72
+ process.env.DSH_HOME = root;
73
+
74
+ const sharedIndex = { headers: new Map(), sessionPaths: new Map(), invalidSessionPaths: new Map() };
75
+ const hostRef = { ...sharedIndex, sessionPath: (id) => sharedIndex.sessionPaths.get(String(id)) };
76
+ const entityA = makeEntity('wid-a', A, hostRef);
77
+ const entityB = makeEntity('wid-b', B, hostRef);
78
+ const registry = {
79
+ ...sharedIndex,
80
+ entities: new Map([['wid-a', entityA], ['wid-b', entityB]]),
81
+ get(id) { return this.entities.get(id); },
82
+ list() { return [...this.entities.values()]; }
83
+ };
84
+ const persistence = {
85
+ root,
86
+ async list() {
87
+ const out = [];
88
+ if (!existsSync(root)) return out;
89
+ for (const proj of readdirSync(root)) {
90
+ if (!(proj.startsWith('--') && proj.endsWith('--'))) continue;
91
+ for (const idDir of readdirSync(join(root, proj))) {
92
+ const dir = join(root, proj, idDir);
93
+ const names = existsSync(dir) ? readdirSync(dir).filter((n) => n === 'session.jsonl.zstd' || /^session\.v[1-9][0-9]*\.jsonl\.zstd$/.test(n)) : [];
94
+ if (!names.length) continue;
95
+ try {
96
+ const names2 = names.sort((a, b) => {
97
+ const va = a === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(a)[1]);
98
+ const vb = b === 'session.jsonl.zstd' ? 0 : Number(/^session\.v([0-9]+)/.exec(b)[1]);
99
+ return vb - va;
100
+ });
101
+ out.push(JSON.parse(zstdDecompressSync(readFileSync(join(dir, names2[0]))).toString('utf8').split('\n')[0]));
102
+ } catch { /* skip */ }
103
+ }
104
+ }
105
+ return out;
106
+ }
107
+ };
108
+ rpcHandler = null;
109
+ ctx = {
110
+ workspaceRegistry: registry,
111
+ sessionPersistence: persistence,
112
+ logger: { info() {}, warn() {} },
113
+ get: () => undefined,
114
+ connection: { rpc: { handle(_ch, h) { rpcHandler = h; return () => {}; } } }
115
+ };
116
+ });
117
+
118
+ afterEach(() => {
119
+ delete process.env.DSH_HOME;
120
+ for (const k of ENV_KEYS) delete process.env[k];
121
+ rmSync(root, { recursive: true, force: true });
122
+ });
123
+
124
+ /** 让出 5ms:备份名以毫秒时间戳排序,用例需要可预测的先后顺序。 */
125
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
126
+
127
+ async function call(endpoint, payload) {
128
+ return await rpcHandler(endpoint, payload ?? {});
129
+ }
130
+
131
+ /** 建一个可迁移的会话(归属 A)。 */
132
+ function seedSession(id) {
133
+ const p = artifactPath(root, A, id);
134
+ mkdirSync(dirname(p), { recursive: true });
135
+ writeFileSync(p, makeArtifact({ type: 'session', id, cwd: A, title: id }));
136
+ ctx.workspaceRegistry.sessionPaths.set(id, A);
137
+ ctx.workspaceRegistry.get('wid-a').record.sessionIds.push(id);
138
+ return id;
139
+ }
140
+
141
+ test('默认策略:单批上限 50(未设环境变量时第 51 个被拒)', async () => {
142
+ apply(ctx);
143
+ const sessions = Array.from({ length: 51 }, (_, i) => ({ sessionId: `s-${i}` }));
144
+ const res = await call('mover.moveMany', { sessions, targetWorkspaceId: 'wid-b' });
145
+ assert.equal(res.ok, false);
146
+ assert.match(res.error.message, /max 50/);
147
+ });
148
+
149
+ test('DSH_WORKSPACE_MOVER_BATCH_LIMIT 收紧单批上限', async () => {
150
+ process.env.DSH_WORKSPACE_MOVER_BATCH_LIMIT = '2';
151
+ apply(ctx);
152
+ const ok = await call('mover.moveMany', {
153
+ sessions: [{ sessionId: seedSession('m-1') }, { sessionId: seedSession('m-2') }],
154
+ targetWorkspaceId: 'wid-b'
155
+ });
156
+ assert.equal(ok.ok, true, JSON.stringify(ok));
157
+
158
+ const tooMany = await call('mover.moveMany', {
159
+ sessions: [{ sessionId: 'x-1' }, { sessionId: 'x-2' }, { sessionId: 'x-3' }],
160
+ targetWorkspaceId: 'wid-b'
161
+ });
162
+ assert.equal(tooMany.ok, false);
163
+ assert.match(tooMany.error.message, /max 2/, `应使用策略值 2: ${tooMany.error.message}`);
164
+ });
165
+
166
+ test('DSH_WORKSPACE_MOVER_BACKUP_KEEP 控制每会话备份保留份数', async () => {
167
+ process.env.DSH_WORKSPACE_MOVER_BACKUP_KEEP = '3';
168
+ apply(ctx);
169
+ const dir = join(root, 'workspace-mover', 'backups');
170
+ // 每次备份之间推进时钟:备份名以毫秒时间戳排序,同一毫秒在 Windows 上会互相覆盖
171
+ //(这正是本用例最初在 Linux CI 上暴露的差异),显式等待让断言与平台无关。
172
+ for (let i = 0; i < 6; i++) {
173
+ stashBackup('session-keep', Buffer.from(`v${i}`));
174
+ await sleep(5);
175
+ }
176
+ const kept = readdirSync(dir).filter((f) => f.startsWith('session-keep.'));
177
+ assert.equal(kept.length, 3, `策略值 3 应只保留 3 份,实际 ${kept.length}`);
178
+ // 保留的必须是**最新**的 3 份
179
+ const newest = readFileSync(join(dir, kept.sort().at(-1))).toString();
180
+ assert.equal(newest, 'v5', `保留的应是最新内容,实际 ${newest}`);
181
+ });
182
+
183
+ test('同一毫秒内的连续备份不得互相覆盖(Linux CI 暴露的差异回归)', async () => {
184
+ apply(ctx);
185
+ const dir = join(root, 'workspace-mover', 'backups');
186
+ // 不推进时钟:模拟 Linux 上更粗的时钟粒度——两次 stash 极可能落在同一毫秒。
187
+ for (let i = 0; i < 5; i++) stashBackup('session-ms', Buffer.from(`m${i}`));
188
+ const files = readdirSync(dir).filter((f) => f.startsWith('session-ms.'));
189
+ const contents = files.map((f) => readFileSync(join(dir, f)).toString());
190
+ // 关键:每次备份都必须留下独立文件,而不是被后一次静默覆盖。
191
+ // 旧实现在同一毫秒会塌缩成 1 份(Windows 时钟粒度粗,掩盖了这个问题;Linux CI 直接挂掉)。
192
+ assert.equal(files.length, 5,
193
+ `同毫秒的 5 次备份应留下 5 份,实际 ${files.length} 份:${JSON.stringify(files)}`);
194
+ assert.deepEqual([...contents].sort(), ['m0', 'm1', 'm2', 'm3', 'm4'],
195
+ `每份备份的内容都必须保留,实际:${JSON.stringify([...contents].sort())}`);
196
+ });
197
+
198
+ test('非法环境变量回退默认值(配置错误不应让功能不可用)', async () => {
199
+ for (const bad of ['0', '-5', 'abc', '999999']) {
200
+ process.env.DSH_WORKSPACE_MOVER_BATCH_LIMIT = bad;
201
+ apply(ctx);
202
+ const res = await call('mover.moveMany', {
203
+ sessions: Array.from({ length: 51 }, (_, i) => ({ sessionId: `s-${i}` })),
204
+ targetWorkspaceId: 'wid-b'
205
+ });
206
+ assert.equal(res.ok, false, `bad=${bad} 应仍然拒绝超限批次`);
207
+ assert.match(res.error.message, /max 50/, `bad=${bad} 应回退默认 50: ${res.error.message}`);
208
+ }
209
+ });
210
+
211
+ test('mover.data.cleanup 在未传 days 时使用策略默认天数', async () => {
212
+ process.env.DSH_WORKSPACE_MOVER_CLEANUP_DAYS = '1';
213
+ apply(ctx);
214
+ // dryRun 只统计:建一个"昨天之前"的回收站条目,策略 1 天内应被计入
215
+ const entryDir = join(root, 'workspace-mover', 'recycle', 'old-entry');
216
+ mkdirSync(join(entryDir, 'session'), { recursive: true });
217
+ writeFileSync(join(entryDir, 'wsm-manifest.json'), JSON.stringify({
218
+ version: 1, sessionId: 'session-old', deletedAt: new Date(Date.now() - 2 * 86400000).toISOString()
219
+ }));
220
+ const res = await call('mover.data.cleanup', { dryRun: true });
221
+ assert.equal(res.ok, true, JSON.stringify(res));
222
+ assert.equal(res.value.recyclePurged, 1, '2 天前的条目在"1 天"策略下应被计入待清理');
223
+ });
224
+
225
+ //#region 守卫式 Config 接入(schemastery 由宿主 loader 提供,插件不主动 import)
226
+
227
+ test('Config 恒为 undefined:插件不静态 import schemastery(否则 boot 有风险)', () => {
228
+ assert.equal(Config, undefined);
229
+ });
230
+
231
+ test('buildConfig:注入 schema 构造器后能构造出全部策略字段', () => {
232
+ const calls = [];
233
+ const makeNode = () => {
234
+ const node = {
235
+ step: (n) => { calls.push(['step', n]); return node; },
236
+ min: (n) => { calls.push(['min', n]); return node; },
237
+ max: (n) => { calls.push(['max', n]); return node; },
238
+ default: (n) => { calls.push(['default', n]); return node; },
239
+ // 故意不提供 volatile:部分版本没有该方法,必须能优雅退化
240
+ };
241
+ return node;
242
+ };
243
+ const z = {
244
+ number: () => makeNode(),
245
+ object: (shape) => ({ shape, kind: 'object' })
246
+ };
247
+ const schema = buildConfig(z);
248
+ assert.ok(schema, '应构造出 schema');
249
+ assert.equal(schema.kind, 'object');
250
+ assert.deepEqual(Object.keys(schema.shape).sort(), POLICY_FIELDS.map((f) => f.key).sort());
251
+ // 每个字段都必须被默认值 + 上下界约束过
252
+ for (const { key, def, max } of POLICY_FIELDS) {
253
+ assert.ok(schema.shape[key], `缺少字段 ${key}`);
254
+ assert.ok(calls.some(([k, v]) => k === 'default' && v === def), `${key} 应设默认值 ${def}`);
255
+ assert.ok(calls.some(([k, v]) => k === 'max' && v === max), `${key} 应设上限 ${max}`);
256
+ }
257
+ });
258
+
259
+ test('buildConfig:构造器不可用时返回 undefined,绝不抛错', () => {
260
+ assert.equal(buildConfig(undefined), undefined);
261
+ assert.equal(buildConfig(null), undefined);
262
+ assert.equal(buildConfig({}), undefined, '缺 z.object / z.number');
263
+ assert.equal(buildConfig({ object: () => ({}), number: 'not-a-function' }), undefined);
264
+ assert.equal(attachConfig(undefined), undefined);
265
+ assert.equal(attachConfig({ default: {} }), undefined);
266
+ });
267
+
268
+ test('buildConfig:字段链式调用中途抛错时整体降级为 undefined', () => {
269
+ const z = {
270
+ number: () => ({ step() { throw new Error('boom'); } }),
271
+ object: (shape) => ({ shape })
272
+ };
273
+ assert.equal(buildConfig(z), undefined, '构造失败必须降级而不是打断启动');
274
+ });
275
+
276
+ test('attachConfig 支持 { default: z } 形态的模块对象', () => {
277
+ const node = { step: () => node, min: () => node, max: () => node, default: () => node };
278
+ const z = { number: () => node, object: (shape) => ({ shape }) };
279
+ const viaModule = attachConfig({ default: z });
280
+ const viaDirect = attachConfig(z);
281
+ assert.ok(viaModule && viaDirect);
282
+ assert.deepEqual(Object.keys(viaModule.shape), Object.keys(viaDirect.shape));
283
+ });
284
+ //#endregion
285
+
286
+ //#region peer 范围的预发布语义(曾导致宿主判定"与 0.2.0-rc.2 不兼容")
287
+ //
288
+ // 这里自带一份最小 semver 判定,而不是 import dsh-market:CI 不安装它。
289
+ // 刻意复刻 npm 的预发布规则——**按 `||` 集合逐条判定**:带预发布标签的版本,只有在
290
+ // 某个集合中「存在一个比较符,其 major.minor.patch 与该版本完全相同**且自身也带
291
+ // 预发布标签**」时,才继续逐条比较;否则该集合直接不匹配。
292
+ // (dsh-market 的 lib/check.js → satisfiesRange 是同一实现,宿主判定用 includePrerelease。)
293
+
294
+ function parseSemver(v) {
295
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(String(v));
296
+ if (!m) return null;
297
+ return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ? m[4].split('.') : [] };
298
+ }
299
+ function comparePre(a, b) {
300
+ for (let i = 0; i < Math.max(a.length, b.length); i++) {
301
+ const x = a[i], y = b[i];
302
+ if (x === undefined) return -1;
303
+ if (y === undefined) return 1;
304
+ if (x === y) continue;
305
+ const xn = /^\d+$/.test(x), yn = /^\d+$/.test(y);
306
+ if (xn && yn) return Number(x) - Number(y) || 0;
307
+ if (xn) return -1;
308
+ if (yn) return 1;
309
+ return x < y ? -1 : 1;
310
+ }
311
+ return 0;
312
+ }
313
+ function cmp(a, b) {
314
+ if (a.major !== b.major) return a.major - b.major;
315
+ if (a.minor !== b.minor) return a.minor - b.minor;
316
+ if (a.patch !== b.patch) return a.patch - b.patch;
317
+ if (a.pre.length === 0 && b.pre.length === 0) return 0;
318
+ if (a.pre.length === 0) return 1;
319
+ if (b.pre.length === 0) return -1;
320
+ return comparePre(a.pre, b.pre);
321
+ }
322
+ function parseComparator(part) {
323
+ const m = /^(>=|<=|>|<|=)?\s*(.+)$/.exec(String(part).trim());
324
+ if (!m) return null;
325
+ const v = parseSemver(m[2]);
326
+ return v ? { op: m[1] ?? '=', v } : null;
327
+ }
328
+ function satisfiesSet(version, parts, includePrerelease) {
329
+ const v = parseSemver(version);
330
+ if (!v) return null;
331
+ const comps = parts.map(parseComparator);
332
+ if (comps.some((c) => c === null)) return null;
333
+ if (v.pre.length > 0 && !includePrerelease) {
334
+ // 预发布准入(npm 默认):集合里必须有一个比较符与该版本 tuple 相同且自带预发布
335
+ const admits = comps.some((c) => c.v.major === v.major && c.v.minor === v.minor
336
+ && c.v.patch === v.patch && c.v.pre.length > 0);
337
+ if (!admits) return false;
338
+ }
339
+ for (const c of comps) {
340
+ const d = cmp(v, c.v);
341
+ if (c.op === '>' && !(d > 0)) return false;
342
+ if (c.op === '>=' && !(d >= 0)) return false;
343
+ if (c.op === '<' && !(d < 0)) return false;
344
+ if (c.op === '<=' && !(d <= 0)) return false;
345
+ if (c.op === '=' && d !== 0) return false;
346
+ }
347
+ return true;
348
+ }
349
+ /**
350
+ * 宿主/发现路径**始终**以 includePrerelease=true 评估(DSH 的每条发布线本身都是预发布)。
351
+ *
352
+ * `*` 在此处按"匹配一切"处理,与 dsh-market 在它自己实际使用的模式(includePrerelease)
353
+ * 下的结果一致。唯一的已知差异:该实现在默认模式下对预发布版本返回 false(它把 `*`
354
+ * 交给比较符解析,于是预发布准入规则把它挡掉)。本插件只关心宿主真正使用的评估路径,
355
+ * 因此这里不复制那个差异——但如实记录在此,避免以后被误读为"与宿主完全同构"。
356
+ */
357
+ function satisfiesRange(version, range, includePrerelease = false) {
358
+ if (String(range).trim() === '*') return true;
359
+ return String(range).split('||')
360
+ .some((alt) => satisfiesSet(version, alt.trim().split(/\s+/).filter(Boolean), includePrerelease));
361
+ }
362
+
363
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
364
+
365
+ test('peer 范围不与任何 DSH 版本判为不兼容(0.2.0-rc.2 事故回归)', () => {
366
+ const HOST = /^@deepseek-ai\/dsh(?:-|$)/;
367
+ const VERSIONS = ['0.1.5-rc.1', '0.1.5-rc.2', '0.1.6-alpha.2', '0.1.7-rc.1', '0.1.7-rc.2',
368
+ '0.2.0-rc.1', '0.2.0-rc.2', '0.2.0', '0.3.0-rc.1', '1.0.0'];
369
+ for (const v of VERSIONS) {
370
+ const decls = [];
371
+ const engine = satisfiesRange(v, pkg.engines.dsh, true);
372
+ if (engine !== null) decls.push(['engines.dsh', engine]);
373
+ for (const [name, range] of Object.entries(pkg.peerDependencies ?? {})) {
374
+ if (!HOST.test(name)) continue; // 与 dsh-market 同一套过滤
375
+ const r = satisfiesRange(v, range, true);
376
+ if (r === null) continue;
377
+ decls.push([name, r]);
378
+ }
379
+ assert.ok(!decls.some(([, r]) => r === false),
380
+ `DSH ${v} 被判为不兼容:${JSON.stringify(decls.filter(([, r]) => r === false))}`);
381
+ }
382
+ });
383
+
384
+ test('peer 范围不得使用 <X.Y.Z-0 这类会吃掉整条预发布线的上界', () => {
385
+ // 锁定事故根因:0.2.0-rc.2 > 0.2.0-0,所以 <0.2.0-0 排除所有 0.2.0 预发布。
386
+ assert.ok(cmp(parseSemver('0.2.0-rc.2'), parseSemver('0.2.0-0')) > 0,
387
+ '-0 哨兵必须小于真实预发布——这正是上界失效的原因');
388
+ // 关键:即使按宿主那套 includePrerelease 评估,旧写法仍然不匹配
389
+ assert.equal(satisfiesRange('0.2.0-rc.2', '>=0.1.0-rc.1 <0.2.0-0', true), false,
390
+ '旧写法在 includePrerelease 下也必须判为不匹配,否则本回归测试失效');
391
+ assert.equal(satisfiesRange('0.2.0-rc.2', '>=0.0.1-rc.1 <0.1.0 || >=0.1.0-rc.1 <0.2.0-0', true), false,
392
+ '这正是被宿主报为不兼容的那个范围');
393
+ assert.equal(satisfiesRange('0.2.0-rc.2', '*', true), true, '现行写法必须匹配');
394
+
395
+ for (const [name, range] of Object.entries(pkg.peerDependencies ?? {})) {
396
+ assert.ok(!/<\d+\.\d+\.\d+-0/.test(range),
397
+ `${name} 的范围 "${range}" 使用了 <X.Y.Z-0 上界,会排除整条预发布线`);
398
+ }
399
+ });
400
+
401
+ test('engines.dsh 仍是真实下限:比下限更老的宿主必须判为不兼容', () => {
402
+ // 宽松不等于没有下限——下限由 engines 承担,必须真的挡住更老的宿主。
403
+ for (const older of ['0.1.4', '0.1.0', '0.0.9', '0.0.1']) {
404
+ assert.equal(satisfiesRange(older, pkg.engines.dsh), false, `${older} 必须低于下限`);
405
+ }
406
+ // 下限本身必须成立。
407
+ assert.equal(satisfiesRange('0.1.5-rc.1', pkg.engines.dsh), true);
408
+ // 注意:npm 默认语义下,跨 tuple 的预发布(0.2.0-rc.2 vs >=0.1.5-rc.1)本来就**不**被
409
+ // 接纳——这正是宿主在发现路径上显式传 includePrerelease 的原因。所以按宿主那套评估,
410
+ // 真实的 0.2.0-rc.2 必须通过(见上一条用例的版本矩阵)。
411
+ assert.equal(satisfiesRange('0.2.0-rc.2', pkg.engines.dsh, true), true,
412
+ 'includePrerelease(宿主用法)下 0.2.0-rc.2 必须满足下限');
413
+ });
414
+ //#endregion
415
+
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { buildBadgeFiles, buildState, mergeHistory, normalizeClonesResponse, normalizePathsResponse, normalizeReferrersResponse, normalizeViewsResponse, parseHistory } from '../.github/scripts/traffic-badge-core.mjs';
3
+ import { buildBadgeFiles, buildState, buildStructuredDaily, mergeHistory, normalizeClonesResponse, normalizePathsResponse, normalizeReferrersResponse, normalizeViewsResponse, parseHistory } from '../.github/scripts/traffic-badge-core.mjs';
4
4
 
5
5
  const snapshot = {
6
6
  count: 12,
@@ -55,12 +55,26 @@ test('schema migration preserves the old value as a legacy estimate only', () =>
55
55
  assert.equal(state.lastWindow.count, 12);
56
56
  });
57
57
 
58
- test('badge exposes only observed GitHub clones and 14d uniques', () => {
58
+ test('badge exposes cumulative clones and cumulative uniques since the observation start', () => {
59
59
  const state = buildState({}, parseHistory('{"date":"2026-09-08","count":5,"uniques":3}\n{"date":"2026-09-09","count":7,"uniques":4}\n'), normalizeClonesResponse(snapshot), '2026-09-10T00:00:00Z');
60
+ assert.equal(state.cumulativeUniques, 7);
60
61
  const files = buildBadgeFiles(state);
61
62
  const total = JSON.parse(files['wsm-clones-total.json'].content);
62
63
  assert.equal(total.label, 'GitHub clones observed');
63
- assert.match(total.message, /12 since 2026-09-08/);
64
- assert.match(total.message, /7 unique/);
64
+ assert.equal(total.message, '12 since 2026-09-08 · 7 unique');
65
65
  assert.equal(files['wsm-clones-14d.json'], undefined);
66
66
  });
67
+
68
+ test('structured daily backfills JSONL-only early days without clobbering views', () => {
69
+ const existing = [{ date: '2026-09-09', clones: { date: '2026-09-09', count: 7, uniques: 4 }, views: { date: '2026-09-09', count: 2, uniques: 1 } }];
70
+ const historyRows = parseHistory('{"date":"2026-09-07","count":1,"uniques":1}\n{"date":"2026-09-08","count":5,"uniques":3}\n{"date":"2026-09-09","count":99,"uniques":99}\n');
71
+ const cloneDays = [{ date: '2026-09-09', count: 7, uniques: 4 }];
72
+ const viewDays = [{ date: '2026-09-09', count: 2, uniques: 1 }];
73
+ const daily = buildStructuredDaily(existing, historyRows, cloneDays, viewDays);
74
+ assert.deepEqual(daily.map((row) => row.date), ['2026-09-07', '2026-09-08', '2026-09-09']);
75
+ assert.deepEqual(daily[0].clones, { date: '2026-09-07', count: 1, uniques: 1 });
76
+ assert.equal(daily[0].views, undefined, 'JSONL 回填行没有 views 数据');
77
+ // JSONL 与快照冲突时快照优先:09-09 保持 7/4,且 views 不丢
78
+ assert.deepEqual(daily[2].clones, { date: '2026-09-09', count: 7, uniques: 4 });
79
+ assert.deepEqual(daily[2].views, { date: '2026-09-09', count: 2, uniques: 1 });
80
+ });