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.
@@ -0,0 +1,253 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
4
+ import { runInNewContext } from 'node:vm';
5
+ import { tmpdir } from 'node:os';
6
+ import { join, resolve } from 'node:path';
7
+ import { pathToFileURL } from 'node:url';
8
+ import * as mover from '../lib/index.js';
9
+ import { installLifecycle, lifecycleFor, withReleasedSession } from '../lib/lifecycle.js';
10
+
11
+ test('live lifecycle: explicit stop, request exclusion, failure recovery and unload', async () => {
12
+ const entries = new Map();
13
+ let teardown;
14
+ const agents = {
15
+ get: (id) => entries.get(id),
16
+ async resume({ resumeSessionId: id }) {
17
+ const agent = { id, status: 'running', session: { header: {} }, send() { return 'sent'; } };
18
+ entries.set(id, agent);
19
+ return { agent, async dispose() { await teardown?.(); entries.delete(id); } };
20
+ }
21
+ };
22
+ let off;
23
+ const ctx = { agents, get: (key) => key === 'agents' ? agents : null };
24
+ installLifecycle(ctx, { agents, effect(fn) { off = fn(); } });
25
+ const { agent } = await agents.resume({ resumeSessionId: 'a' });
26
+ await assert.rejects(withReleasedSession(ctx, 'a', false, () => assert.fail()), { code: 'stop-required' });
27
+ assert.equal(agent.send(), 'sent');
28
+ teardown = async () => {
29
+ assert.throws(() => agent.send(), { code: 'migration-in-progress' });
30
+ await assert.rejects(agents.resume({ resumeSessionId: 'a' }), { code: 'migration-in-progress' });
31
+ };
32
+ await assert.rejects(withReleasedSession(ctx, 'a', true, () => { throw new Error('disk failure'); }), /disk failure/);
33
+ assert.equal(lifecycleFor(ctx, 'a'), null);
34
+ assert.throws(() => agent.send(), { code: 'migration-in-progress' });
35
+ const next = await agents.resume({ resumeSessionId: 'a' });
36
+ assert.equal(next.agent.send(), 'sent');
37
+ off();
38
+ assert.equal(lifecycleFor(ctx, 'a'), null);
39
+ });
40
+
41
+ test('delete lifecycle: no reload on success, restore after failure only when archive survives', async () => {
42
+ const entries = new Map();
43
+ let reloads = 0;
44
+ let teardown;
45
+ const agents = {
46
+ get: (id) => entries.get(id),
47
+ async resume({ resumeSessionId: id }) {
48
+ const agent = { id, status: 'idle', session: { header: {} }, send() {} };
49
+ entries.set(id, agent);
50
+ return { agent, async dispose() { entries.delete(id); } };
51
+ }
52
+ };
53
+ const ctx = { get: (key) => key === 'agents' ? agents : key === 'sessionController' ? {
54
+ async resolveAgent(id) { reloads++; return agents.resume({ resumeSessionId: id }); }
55
+ } : null };
56
+ installLifecycle(ctx, { agents, effect(fn) { teardown = fn(); } });
57
+ try {
58
+ await agents.resume({ resumeSessionId: 'delete' });
59
+ await withReleasedSession(ctx, 'delete', false, () => 'deleted', { reloadOnSuccess: false });
60
+ assert.equal(entries.has('delete'), false);
61
+ assert.equal(reloads, 0, 'never recreate the deleted conversation');
62
+ await agents.resume({ resumeSessionId: 'rollback' });
63
+ await assert.rejects(withReleasedSession(ctx, 'rollback', false, () => { throw new Error('rollback complete'); },
64
+ { reloadOnSuccess: false, canReload: () => true }), /rollback complete/);
65
+ assert.equal(reloads, 1);
66
+ assert.ok(entries.has('rollback'));
67
+ await assert.rejects(withReleasedSession(ctx, 'rollback', false, () => { throw new Error('archive unavailable'); },
68
+ { reloadOnSuccess: false, canReload: () => false }), /archive unavailable/);
69
+ assert.equal(reloads, 1, 'failed physical rollback must not create a blank session');
70
+ } finally { teardown(); }
71
+ });
72
+
73
+ const modules = process.env.DSH_TEST_NODE_MODULES;
74
+ const load = (name) => import(pathToFileURL(join(resolve(modules), '@deepseek-ai', name, 'lib/index.js')).href);
75
+
76
+ test('installed LocaleRuntime: bind follows locale changes and disposers unregister dictionaries',
77
+ { skip: !modules }, () => {
78
+ const file = join(resolve(modules), '@deepseek-ai/dsh-client-locale/lib/client.js');
79
+ let exported;
80
+ runInNewContext(readFileSync(file, 'utf8'), {
81
+ window: { __ModuleLoader__: { load({ factory }) { exported = factory(() => ({})); } } },
82
+ navigator: { language: 'zh-CN', languages: ['zh-CN'] }, console
83
+ });
84
+ const locale = new exported.LocaleRuntime({ emit() {} }, undefined, { languages: ['en'], preference: 'en' });
85
+ const stop = locale.register('test-mover', { en: { title: 'Move {n}' }, zh: { title: '移动 {n}' } });
86
+ const t = locale.bind('test-mover');
87
+ assert.equal(t('title', { n: 2 }), 'Move 2');
88
+ let changed = 0;
89
+ const unsubscribe = locale.subscribe(() => changed++);
90
+ locale.setLocale('zh');
91
+ assert.equal(t('title', { n: 2 }), '移动 2');
92
+ assert.equal(changed, 1);
93
+ unsubscribe(); stop();
94
+ assert.equal(t('title'), 'title');
95
+ });
96
+
97
+ test('installed client Controller: a Provider reference keeps the removed generation alive until released',
98
+ { skip: !modules, timeout: 10000 }, async () => {
99
+ const cordis = await load('cordis');
100
+ // Execute installed client logic, exposing the otherwise bundle-local class
101
+ // only inside this test. Renderer stores/transport are inert fixtures; actual
102
+ // Controller retention, scope retirement and Session flags are unmodified.
103
+ const file = join(resolve(modules), '@deepseek-ai/dsh-api-session-controller/lib/client.js');
104
+ const source = readFileSync(file, 'utf8').replace('exports.apply = apply;', 'exports.__ClientSessions = ClientSessions; exports.apply = apply;');
105
+ let exports;
106
+ const store = {
107
+ notifySubscribers(listeners) { for (const listener of [...listeners]) listener(); },
108
+ createSnapshotStore(initial) {
109
+ let value = initial;
110
+ const listeners = new Set();
111
+ return { getSnapshot: () => value,
112
+ subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
113
+ set(next) { value = next; store.notifySubscribers(listeners); } };
114
+ }
115
+ };
116
+ runInNewContext(source, { window: { __ModuleLoader__: { load({ factory }) {
117
+ exports = factory((name) => {
118
+ if (name === '@deepseek-ai/cordis') return cordis;
119
+ if (name === '@deepseek-ai/dsh-client-store') return store;
120
+ if (name === '@deepseek-ai/dsh-api-gateway/client') return { RemoteJournalStream: class {} };
121
+ throw new Error(`Unexpected client dependency: ${name}`);
122
+ });
123
+ } } }, console, AbortController, AbortSignal, queueMicrotask, setTimeout, clearTimeout });
124
+ const root = new cordis.Context();
125
+ try {
126
+ let listed = [];
127
+ let listCalls = 0;
128
+ const client = new exports.__ClientSessions(root, { session: {
129
+ async list() { listCalls++; return { ok: true, value: { items: listed } }; }
130
+ } });
131
+ const id = 'session-retained-view';
132
+ const summary = { sessionId: id, blank: false, running: false, agentAvailable: true, updatedAt: 1 };
133
+ client.handleSessionAdded(summary);
134
+ const main = client.retainAgentScope(id);
135
+ const provider = client.retainAgentScope(id);
136
+ const previous = main.binding;
137
+ client.handleSessionRemoved(id);
138
+ client.handleSessionAdded(summary);
139
+ assert.equal(previous.session.getSnapshot().removed, true, 'host resume does not clear the flag');
140
+ main.release();
141
+ const premature = client.retainAgentScope(id);
142
+ assert.equal(premature.binding, previous, 'immediate reopen reuses the removed instance');
143
+ premature.release();
144
+ await new Promise((resolve) => {
145
+ const stop = client.retainInfo(id).subscribe(() => {
146
+ if (client.binding(id) !== previous) { stop(); resolve(); }
147
+ });
148
+ setTimeout(() => provider.release(), 0);
149
+ });
150
+ const fresh = client.retainAgentScope(id);
151
+ assert.notEqual(fresh.binding, previous);
152
+ assert.equal(fresh.binding.session.getSnapshot().removed, false);
153
+ fresh.release();
154
+ client.handleSessionRemoved(id);
155
+ await new Promise((resolve) => setTimeout(resolve, 0));
156
+ assert.equal(client.list.getSnapshot().ids.includes(id), false);
157
+ listed = [summary];
158
+ await client.refresh();
159
+ await new Promise((resolve) => setTimeout(resolve, 0));
160
+ assert.equal(listCalls, 1);
161
+ assert.equal(client.list.getSnapshot().ids.includes(id), true,
162
+ 'public refresh restores cold archive metadata without an Agent-created event or restart');
163
+ } finally {
164
+ await root.fiber.dispose();
165
+ }
166
+ });
167
+
168
+ test('installed DSH AgentLoop: dispose writer, physical move, resume same session and append',
169
+ { skip: !modules, timeout: 30000 }, async () => {
170
+ const { Context } = await load('cordis');
171
+ const { default: Agents } = await load('dsh-agent');
172
+ const { default: Sessions } = await load('dsh-session');
173
+ const { default: Projections } = await load('dsh-session-projection');
174
+ const { default: Loop } = await load('dsh-agent-loop');
175
+ const persistence = await load('dsh-session-persistence-jsonl');
176
+ const home = mkdtempSync(join(tmpdir(), 'wsm-live-'));
177
+ const prior = process.env.DSH_HOME;
178
+ process.env.DSH_HOME = home;
179
+ const root = new Context();
180
+ const a = join(home, 'a'), b = join(home, 'b');
181
+ mkdirSync(a); mkdirSync(b);
182
+ const groups = ['a', 'b'].map((id, i) => ({ id, path: [a,b][i], record: { sessionIds: [] },
183
+ async attachSession(id) { this.record.sessionIds.push(id); },
184
+ async detachSession(id) { this.record.sessionIds = this.record.sessionIds.filter((x) => x !== id); } }));
185
+ try {
186
+ root.provide('workspaceRegistry', { list: () => groups, get: (id) => groups.find((x) => x.id === id) });
187
+ root.provide('llm', {});
188
+ root.provide('tools', {});
189
+ root.provide('systemPrompt', { variable() {}, assemble() { return []; } });
190
+ for (const plugin of [Sessions, Agents, Projections]) await root.plugin(plugin).await();
191
+ await root.plugin(persistence, { root: join(home, 'sessions'), compression: 'zstd' }).await();
192
+ await root.plugin({ name: 'lifecycle-observer', inject: ['agents'], apply(ctx) { installLifecycle(ctx, ctx); } }).await();
193
+ await root.plugin(Loop, { agents: [], maxParallelToolCalls: 1 }).await();
194
+ const agents = root.get('agents');
195
+ root.provide('sessionController', { async resolveAgent(id) {
196
+ return { agent: agents.get(id) ?? (await agents.resume({ resumeSessionId: id, agentOptions: {} })).agent };
197
+ } });
198
+ const id = 'session-live-test';
199
+ const first = await agents.create({ sessionId: id, meta: { cwd: a }, agentOptions: {} });
200
+ first.agent.session.append('working-directory/change', { cwd: a });
201
+ groups[0].record.sessionIds.push(id);
202
+ assert.ok(lifecycleFor(root, id), 'real public lifecycle handle was captured');
203
+ const moved = await mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'b' });
204
+ assert.equal(moved.verified, true);
205
+ assert.equal(root.get('sessions').get(id).header.cwd, b, 'controller restores from the actual target');
206
+ const resumed = lifecycleFor(root, id).handle;
207
+ assert.equal(resumed.agent.session.header.cwd, b);
208
+ resumed.agent.session.append('working-directory/change', { cwd: b });
209
+ const attach = groups[0].attachSession;
210
+ groups[0].attachSession = async () => { throw new Error('injected attach failure'); };
211
+ await assert.rejects(mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'a' }), /rolled back cleanly/);
212
+ assert.equal(agents.get(id).session.header.cwd, b, 'failed move reloads the original workspace');
213
+ groups[0].attachSession = attach;
214
+ const recovered = lifecycleFor(root, id).handle;
215
+ recovered.agent.session.append('working-directory/change', { cwd: b });
216
+ await recovered.dispose();
217
+ const read = await root.get('sessionPersistence').open(id, 'read');
218
+ assert.equal((await read.read()).events.at(-1).data.cwd, b);
219
+ await read.close();
220
+ // Exercise cancellation of a real running driver without a model call.
221
+ const active = await agents.resume({ resumeSessionId: id, agentOptions: {} });
222
+ let entered;
223
+ const started = new Promise((resolve) => { entered = resolve; });
224
+ const off = root.on('agent/pre-step', async ({ signal }) => {
225
+ entered();
226
+ await new Promise((resolve) => {
227
+ if (signal.aborted) resolve();
228
+ else signal.addEventListener('abort', resolve, { once: true });
229
+ });
230
+ return { kind: 'reject' };
231
+ });
232
+ active.agent.followup({ role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'test' }] });
233
+ await started;
234
+ assert.equal(active.agent.status, 'running');
235
+ await assert.rejects(mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'a' }), { code: 'stop-required' });
236
+ assert.equal(active.agent.status, 'running', 'refusal does not stop the task');
237
+ const back = await mover.moveSessionEntry(root, { sessionId: id, targetWorkspaceId: 'a', stopRunning: true });
238
+ assert.equal(back.verified, true);
239
+ assert.equal(agents.get(id).session.header.cwd, a);
240
+ off();
241
+ const again = lifecycleFor(root, id).handle;
242
+ assert.equal(again.agent.session.header.cwd, a);
243
+ const deleted = await mover.deleteSessionToTrash(root, { sessionId: id });
244
+ assert.equal(deleted.deleted, true);
245
+ assert.equal(agents.get(id), undefined, 'deleted Agent is not reloaded');
246
+ assert.equal(root.get('sessions').get(id), undefined, 'real Session writer is released');
247
+ assert.equal(lifecycleFor(root, id), null);
248
+ } finally {
249
+ await root.fiber.dispose();
250
+ if (prior === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = prior;
251
+ rmSync(home, { recursive: true, force: true, maxRetries: 3 });
252
+ }
253
+ });
@@ -1,102 +1,102 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { execFileSync } from 'node:child_process';
4
- import { fileURLToPath } from 'node:url';
5
- import { applyReleaseBadgeToMarkdown, buildReleaseBadgeFiles, RELEASE_BADGE_END, RELEASE_BADGE_START, sanitizeGistPatchFiles, summarizeReleaseAssets } from '../.github/scripts/release-downloads-core.mjs';
6
-
7
- const gistId = 'c14345658550a4a308570acfbaf9d170';
8
-
9
- test('counts prefixed tgz/zip assets and hides the badge at 10 or fewer downloads', () => {
10
- const summary = summarizeReleaseAssets([
11
- {
12
- tag_name: 'v1.4.2',
13
- assets: [
14
- { name: 'dsh-workspace-mover-1.4.2.tgz', download_count: 6 },
15
- { name: 'dsh-workspace-mover-1.4.2.zip', download_count: 4 },
16
- { name: 'source.tar.gz', download_count: 99 }
17
- ]
18
- }
19
- ]);
20
- assert.equal(summary.total, 10);
21
- assert.equal(summary.visible, false);
22
- assert.equal(summary.assets.length, 2);
23
- });
24
-
25
- test('shows a shields.io endpoint badge only after the total exceeds 10', () => {
26
- const summary = summarizeReleaseAssets([
27
- { tag_name: 'v2.0.0', assets: [{ name: 'dsh-workspace-mover-2.0.0.tgz', download_count: 11 }] }
28
- ]);
29
- assert.equal(summary.visible, true);
30
- const files = buildReleaseBadgeFiles({ total: summary.total, assets: summary.assets, updatedAt: '2026-09-10T00:00:00Z', visible: summary.visible });
31
- const badge = JSON.parse(files['wsm-release-downloads.json'].content);
32
- assert.equal(badge.label, 'Release downloads');
33
- assert.equal(badge.message, '11');
34
- assert.equal(badge.color, 'blue');
35
- assert.equal(files['wsm-release-downloads.svg'], null);
36
- });
37
-
38
- test('hidden state deletes gist SVG and shields files so GitHub cannot render a broken alt-text link', () => {
39
- const files = buildReleaseBadgeFiles({ total: 3, assets: [], updatedAt: '2026-09-10T00:00:00Z', visible: false });
40
- assert.equal(files['wsm-release-downloads.json'], null);
41
- assert.equal(files['wsm-release-downloads.svg'], null);
42
- const data = JSON.parse(files['wsm-release-downloads-data.json'].content);
43
- assert.equal(data.total, 3);
44
- assert.equal(data.visible, false);
45
- });
46
-
47
- test('sanitizeGistPatchFiles skips deletes for files that are not on the gist', () => {
48
- const patch = buildReleaseBadgeFiles({ total: 3, assets: [], updatedAt: '2026-09-10T00:00:00Z', visible: false });
49
- const sanitized = sanitizeGistPatchFiles({ 'wsm-release-downloads-data.json': { content: '{}' } }, patch);
50
- assert.deepEqual(Object.keys(sanitized).sort(), ['wsm-release-downloads-data.json']);
51
- assert.equal(sanitized['wsm-release-downloads.json'], undefined);
52
- assert.equal(sanitized['wsm-release-downloads.svg'], undefined);
53
-
54
- const withExisting = sanitizeGistPatchFiles(
55
- {
56
- 'wsm-release-downloads-data.json': { content: '{}' },
57
- 'wsm-release-downloads.json': { content: '{}' },
58
- 'wsm-clones-14d.json': { content: 'x' }
59
- },
60
- { ...patch, 'wsm-clones-14d.json': null }
61
- );
62
- assert.equal(withExisting['wsm-release-downloads.json'], null);
63
- assert.equal(withExisting['wsm-release-downloads.svg'], undefined);
64
- assert.equal(withExisting['wsm-clones-14d.json'], null);
65
- assert.equal(typeof withExisting['wsm-release-downloads-data.json'].content, 'string');
66
- });
67
-
68
- test('sanitizeGistPatchFiles turns empty content into a delete only when the file exists', () => {
69
- const empty = sanitizeGistPatchFiles({}, { 'a.json': { content: '' } });
70
- assert.deepEqual(empty, {});
71
- const present = sanitizeGistPatchFiles({ 'a.json': { content: 'x' } }, { 'a.json': { content: '' } });
72
- assert.deepEqual(present, { 'a.json': null });
73
- });
74
-
75
- test('README keeps an invisible marker below 10 and a shields graphic above 10', () => {
76
- const source = [
77
- '<p>',
78
- ' <img alt="GitHub clones observed" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FPianoPrince%2Fc14345658550a4a308570acfbaf9d170%2Fraw%2Fwsm-clones-total.json" style="height:20px; margin:0 2px;" />',
79
- ' <img alt="Release downloads (shown after 10 downloads)" src="https://gist.githubusercontent.com/PianoPrince/c14345658550a4a308570acfbaf9d170/raw/wsm-release-downloads.svg" style="height:20px; margin:0 2px;" />',
80
- '</p>'
81
- ].join('\n');
82
- const hidden = applyReleaseBadgeToMarkdown(source, { visible: false, gistId });
83
- assert.equal(hidden.includes('wsm-release-downloads.svg'), false);
84
- assert.equal(hidden.includes('Release downloads (shown after 10 downloads)'), false);
85
- assert.equal(hidden.includes('<img alt="Release downloads"'), false);
86
- assert.equal(hidden.includes(RELEASE_BADGE_START), true);
87
- assert.equal(hidden.includes(RELEASE_BADGE_END), true);
88
- const shown = applyReleaseBadgeToMarkdown(hidden, { visible: true, gistId });
89
- assert.equal(shown.includes('img.shields.io/endpoint?url='), true);
90
- assert.equal(shown.includes('wsm-release-downloads.json'), true);
91
- assert.equal(shown.includes('wsm-release-downloads.svg'), false);
92
- assert.equal(shown.includes('<img alt="Release downloads"'), true);
93
- });
94
-
95
- test('extract-changelog accepts both current and historical heading styles', () => {
96
- const script = fileURLToPath(new URL('../.github/scripts/extract-changelog.mjs', import.meta.url));
97
- const current = execFileSync(process.execPath, [script, 'v2.0.1'], { encoding: 'utf8' });
98
- const historical = execFileSync(process.execPath, [script, 'v1.4.2'], { encoding: 'utf8' });
99
- assert.match(current, /Compatible with DeepSeek Harness/);
100
- assert.match(historical, /GitHub Release tags now publish/);
101
- assert.doesNotMatch(historical, /Compatible with DeepSeek Harness/);
102
- });
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { applyReleaseBadgeToMarkdown, buildReleaseBadgeFiles, RELEASE_BADGE_END, RELEASE_BADGE_START, sanitizeGistPatchFiles, summarizeReleaseAssets } from '../.github/scripts/release-downloads-core.mjs';
6
+
7
+ const gistId = 'c14345658550a4a308570acfbaf9d170';
8
+
9
+ test('counts prefixed tgz/zip assets and hides the badge at 10 or fewer downloads', () => {
10
+ const summary = summarizeReleaseAssets([
11
+ {
12
+ tag_name: 'v1.4.2',
13
+ assets: [
14
+ { name: 'dsh-workspace-mover-1.4.2.tgz', download_count: 6 },
15
+ { name: 'dsh-workspace-mover-1.4.2.zip', download_count: 4 },
16
+ { name: 'source.tar.gz', download_count: 99 }
17
+ ]
18
+ }
19
+ ]);
20
+ assert.equal(summary.total, 10);
21
+ assert.equal(summary.visible, false);
22
+ assert.equal(summary.assets.length, 2);
23
+ });
24
+
25
+ test('shows a shields.io endpoint badge only after the total exceeds 10', () => {
26
+ const summary = summarizeReleaseAssets([
27
+ { tag_name: 'v2.0.0', assets: [{ name: 'dsh-workspace-mover-2.0.0.tgz', download_count: 11 }] }
28
+ ]);
29
+ assert.equal(summary.visible, true);
30
+ const files = buildReleaseBadgeFiles({ total: summary.total, assets: summary.assets, updatedAt: '2026-09-10T00:00:00Z', visible: summary.visible });
31
+ const badge = JSON.parse(files['wsm-release-downloads.json'].content);
32
+ assert.equal(badge.label, 'Release downloads');
33
+ assert.equal(badge.message, '11');
34
+ assert.equal(badge.color, 'blue');
35
+ assert.equal(files['wsm-release-downloads.svg'], null);
36
+ });
37
+
38
+ test('hidden state deletes gist SVG and shields files so GitHub cannot render a broken alt-text link', () => {
39
+ const files = buildReleaseBadgeFiles({ total: 3, assets: [], updatedAt: '2026-09-10T00:00:00Z', visible: false });
40
+ assert.equal(files['wsm-release-downloads.json'], null);
41
+ assert.equal(files['wsm-release-downloads.svg'], null);
42
+ const data = JSON.parse(files['wsm-release-downloads-data.json'].content);
43
+ assert.equal(data.total, 3);
44
+ assert.equal(data.visible, false);
45
+ });
46
+
47
+ test('sanitizeGistPatchFiles skips deletes for files that are not on the gist', () => {
48
+ const patch = buildReleaseBadgeFiles({ total: 3, assets: [], updatedAt: '2026-09-10T00:00:00Z', visible: false });
49
+ const sanitized = sanitizeGistPatchFiles({ 'wsm-release-downloads-data.json': { content: '{}' } }, patch);
50
+ assert.deepEqual(Object.keys(sanitized).sort(), ['wsm-release-downloads-data.json']);
51
+ assert.equal(sanitized['wsm-release-downloads.json'], undefined);
52
+ assert.equal(sanitized['wsm-release-downloads.svg'], undefined);
53
+
54
+ const withExisting = sanitizeGistPatchFiles(
55
+ {
56
+ 'wsm-release-downloads-data.json': { content: '{}' },
57
+ 'wsm-release-downloads.json': { content: '{}' },
58
+ 'wsm-clones-14d.json': { content: 'x' }
59
+ },
60
+ { ...patch, 'wsm-clones-14d.json': null }
61
+ );
62
+ assert.equal(withExisting['wsm-release-downloads.json'], null);
63
+ assert.equal(withExisting['wsm-release-downloads.svg'], undefined);
64
+ assert.equal(withExisting['wsm-clones-14d.json'], null);
65
+ assert.equal(typeof withExisting['wsm-release-downloads-data.json'].content, 'string');
66
+ });
67
+
68
+ test('sanitizeGistPatchFiles turns empty content into a delete only when the file exists', () => {
69
+ const empty = sanitizeGistPatchFiles({}, { 'a.json': { content: '' } });
70
+ assert.deepEqual(empty, {});
71
+ const present = sanitizeGistPatchFiles({ 'a.json': { content: 'x' } }, { 'a.json': { content: '' } });
72
+ assert.deepEqual(present, { 'a.json': null });
73
+ });
74
+
75
+ test('README keeps an invisible marker below 10 and a shields graphic above 10', () => {
76
+ const source = [
77
+ '<p>',
78
+ ' <img alt="GitHub clones observed" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fgist.githubusercontent.com%2FPianoPrince%2Fc14345658550a4a308570acfbaf9d170%2Fraw%2Fwsm-clones-total.json" style="height:20px; margin:0 2px;" />',
79
+ ' <img alt="Release downloads (shown after 10 downloads)" src="https://gist.githubusercontent.com/PianoPrince/c14345658550a4a308570acfbaf9d170/raw/wsm-release-downloads.svg" style="height:20px; margin:0 2px;" />',
80
+ '</p>'
81
+ ].join('\n');
82
+ const hidden = applyReleaseBadgeToMarkdown(source, { visible: false, gistId });
83
+ assert.equal(hidden.includes('wsm-release-downloads.svg'), false);
84
+ assert.equal(hidden.includes('Release downloads (shown after 10 downloads)'), false);
85
+ assert.equal(hidden.includes('<img alt="Release downloads"'), false);
86
+ assert.equal(hidden.includes(RELEASE_BADGE_START), true);
87
+ assert.equal(hidden.includes(RELEASE_BADGE_END), true);
88
+ const shown = applyReleaseBadgeToMarkdown(hidden, { visible: true, gistId });
89
+ assert.equal(shown.includes('img.shields.io/endpoint?url='), true);
90
+ assert.equal(shown.includes('wsm-release-downloads.json'), true);
91
+ assert.equal(shown.includes('wsm-release-downloads.svg'), false);
92
+ assert.equal(shown.includes('<img alt="Release downloads"'), true);
93
+ });
94
+
95
+ test('extract-changelog accepts both current and historical heading styles', () => {
96
+ const script = fileURLToPath(new URL('../.github/scripts/extract-changelog.mjs', import.meta.url));
97
+ const current = execFileSync(process.execPath, [script, 'v2.0.1'], { encoding: 'utf8' });
98
+ const historical = execFileSync(process.execPath, [script, 'v1.4.2'], { encoding: 'utf8' });
99
+ assert.match(current, /Compatible with DeepSeek Harness/);
100
+ assert.match(historical, /GitHub Release tags now publish/);
101
+ assert.doesNotMatch(historical, /Compatible with DeepSeek Harness/);
102
+ });
@@ -1,80 +1,80 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import { buildBadgeFiles, buildState, buildStructuredDaily, mergeHistory, normalizeClonesResponse, normalizePathsResponse, normalizeReferrersResponse, normalizeViewsResponse, parseHistory } from '../.github/scripts/traffic-badge-core.mjs';
4
-
5
- const snapshot = {
6
- count: 12,
7
- uniques: 7,
8
- clones: [
9
- { timestamp: '2026-09-08T00:00:00Z', count: 5, uniques: 3 },
10
- { timestamp: '2026-09-09T00:00:00Z', count: 7, uniques: 4 }
11
- ]
12
- };
13
-
14
- test('traffic response uses GitHub clones[] rows and rejects the old days[] shape', () => {
15
- const normalized = normalizeClonesResponse(snapshot);
16
- assert.deepEqual(normalized.days, [
17
- { date: '2026-09-08', count: 5, uniques: 3 },
18
- { date: '2026-09-09', count: 7, uniques: 4 }
19
- ]);
20
- assert.throws(() => normalizeClonesResponse({ count: 12, uniques: 7, days: snapshot.clones }), /no clones array/);
21
- });
22
-
23
- test('traffic normalizes views, referrers, and popular paths', () => {
24
- assert.deepEqual(normalizeViewsResponse({ count: 9, uniques: 4, views: [{ timestamp: '2026-09-09T00:00:00Z', count: 9, uniques: 4 }] }).days, [
25
- { date: '2026-09-09', count: 9, uniques: 4 }
26
- ]);
27
- assert.deepEqual(normalizeReferrersResponse([{ referrer: 'github.com', count: 3, uniques: 2 }]), [
28
- { referrer: 'github.com', count: 3, uniques: 2 }
29
- ]);
30
- assert.deepEqual(normalizePathsResponse([{ path: '/README.md', title: 'README', count: 5, uniques: 3 }]), [
31
- { path: '/README.md', title: 'README', count: 5, uniques: 3 }
32
- ]);
33
- });
34
-
35
- test('history upsert replaces corrected dates without double counting', () => {
36
- const old = parseHistory('{"date":"2026-09-08","count":4,"uniques":2}\n');
37
- const merged = mergeHistory(old, [
38
- { date: '2026-09-08', count: 5, uniques: 3 },
39
- { date: '2026-09-09', count: 7, uniques: 4 }
40
- ]);
41
- assert.deepEqual(merged, [
42
- { date: '2026-09-08', count: 5, uniques: 3 },
43
- { date: '2026-09-09', count: 7, uniques: 4 }
44
- ]);
45
- const state = buildState({}, merged, normalizeClonesResponse(snapshot), '2026-09-10T00:00:00Z');
46
- assert.equal(state.cumulativeClones, 12);
47
- assert.equal(state.observedSince, '2026-09-08');
48
- });
49
-
50
- test('schema migration preserves the old value as a legacy estimate only', () => {
51
- const state = buildState({ cumulative: 193 }, [], { ...normalizeClonesResponse(snapshot), days: [] }, '2026-09-10T00:00:00Z');
52
- assert.equal(state.schemaVersion, 2);
53
- assert.equal(state.cumulativeClones, 0);
54
- assert.equal(state.legacyEstimate, 193);
55
- assert.equal(state.lastWindow.count, 12);
56
- });
57
-
58
- test('badge exposes cumulative clones and cumulative uniques since the observation start', () => {
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);
61
- const files = buildBadgeFiles(state);
62
- const total = JSON.parse(files['wsm-clones-total.json'].content);
63
- assert.equal(total.label, 'GitHub clones observed');
64
- assert.equal(total.message, '12 since 2026-09-08 · 7 unique');
65
- assert.equal(files['wsm-clones-14d.json'], undefined);
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
- });
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildBadgeFiles, buildState, buildStructuredDaily, mergeHistory, normalizeClonesResponse, normalizePathsResponse, normalizeReferrersResponse, normalizeViewsResponse, parseHistory } from '../.github/scripts/traffic-badge-core.mjs';
4
+
5
+ const snapshot = {
6
+ count: 12,
7
+ uniques: 7,
8
+ clones: [
9
+ { timestamp: '2026-09-08T00:00:00Z', count: 5, uniques: 3 },
10
+ { timestamp: '2026-09-09T00:00:00Z', count: 7, uniques: 4 }
11
+ ]
12
+ };
13
+
14
+ test('traffic response uses GitHub clones[] rows and rejects the old days[] shape', () => {
15
+ const normalized = normalizeClonesResponse(snapshot);
16
+ assert.deepEqual(normalized.days, [
17
+ { date: '2026-09-08', count: 5, uniques: 3 },
18
+ { date: '2026-09-09', count: 7, uniques: 4 }
19
+ ]);
20
+ assert.throws(() => normalizeClonesResponse({ count: 12, uniques: 7, days: snapshot.clones }), /no clones array/);
21
+ });
22
+
23
+ test('traffic normalizes views, referrers, and popular paths', () => {
24
+ assert.deepEqual(normalizeViewsResponse({ count: 9, uniques: 4, views: [{ timestamp: '2026-09-09T00:00:00Z', count: 9, uniques: 4 }] }).days, [
25
+ { date: '2026-09-09', count: 9, uniques: 4 }
26
+ ]);
27
+ assert.deepEqual(normalizeReferrersResponse([{ referrer: 'github.com', count: 3, uniques: 2 }]), [
28
+ { referrer: 'github.com', count: 3, uniques: 2 }
29
+ ]);
30
+ assert.deepEqual(normalizePathsResponse([{ path: '/README.md', title: 'README', count: 5, uniques: 3 }]), [
31
+ { path: '/README.md', title: 'README', count: 5, uniques: 3 }
32
+ ]);
33
+ });
34
+
35
+ test('history upsert replaces corrected dates without double counting', () => {
36
+ const old = parseHistory('{"date":"2026-09-08","count":4,"uniques":2}\n');
37
+ const merged = mergeHistory(old, [
38
+ { date: '2026-09-08', count: 5, uniques: 3 },
39
+ { date: '2026-09-09', count: 7, uniques: 4 }
40
+ ]);
41
+ assert.deepEqual(merged, [
42
+ { date: '2026-09-08', count: 5, uniques: 3 },
43
+ { date: '2026-09-09', count: 7, uniques: 4 }
44
+ ]);
45
+ const state = buildState({}, merged, normalizeClonesResponse(snapshot), '2026-09-10T00:00:00Z');
46
+ assert.equal(state.cumulativeClones, 12);
47
+ assert.equal(state.observedSince, '2026-09-08');
48
+ });
49
+
50
+ test('schema migration preserves the old value as a legacy estimate only', () => {
51
+ const state = buildState({ cumulative: 193 }, [], { ...normalizeClonesResponse(snapshot), days: [] }, '2026-09-10T00:00:00Z');
52
+ assert.equal(state.schemaVersion, 2);
53
+ assert.equal(state.cumulativeClones, 0);
54
+ assert.equal(state.legacyEstimate, 193);
55
+ assert.equal(state.lastWindow.count, 12);
56
+ });
57
+
58
+ test('badge exposes cumulative clones and cumulative uniques since the observation start', () => {
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);
61
+ const files = buildBadgeFiles(state);
62
+ const total = JSON.parse(files['wsm-clones-total.json'].content);
63
+ assert.equal(total.label, 'GitHub clones observed');
64
+ assert.equal(total.message, '12 since 2026-09-08 · 7 unique');
65
+ assert.equal(files['wsm-clones-14d.json'], undefined);
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
+ });