dsh-workspace-mover 2.4.2 → 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 -327
- package/README.md +389 -381
- package/README_EN.md +376 -368
- package/client/client.js +2792 -2678
- 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 -68
- 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 +36 -5
- package/lib/lifecycle.js +2 -2
- package/package.json +104 -104
- package/test/client-dom.test.mjs +98 -0
- package/test/core.test.mjs +73 -73
- package/test/e2e-sandbox.test.mjs +36 -1
- package/test/live-migration.test.mjs +72 -2
- package/test/release-downloads.test.mjs +102 -102
- package/test/traffic-badge.test.mjs +80 -80
|
@@ -38,9 +38,62 @@ test('live lifecycle: explicit stop, request exclusion, failure recovery and unl
|
|
|
38
38
|
assert.equal(lifecycleFor(ctx, 'a'), null);
|
|
39
39
|
});
|
|
40
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
|
+
|
|
41
73
|
const modules = process.env.DSH_TEST_NODE_MODULES;
|
|
42
74
|
const load = (name) => import(pathToFileURL(join(resolve(modules), '@deepseek-ai', name, 'lib/index.js')).href);
|
|
43
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
|
+
|
|
44
97
|
test('installed client Controller: a Provider reference keeps the removed generation alive until released',
|
|
45
98
|
{ skip: !modules, timeout: 10000 }, async () => {
|
|
46
99
|
const cordis = await load('cordis');
|
|
@@ -70,7 +123,11 @@ test('installed client Controller: a Provider reference keeps the removed genera
|
|
|
70
123
|
} } }, console, AbortController, AbortSignal, queueMicrotask, setTimeout, clearTimeout });
|
|
71
124
|
const root = new cordis.Context();
|
|
72
125
|
try {
|
|
73
|
-
|
|
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
|
+
} });
|
|
74
131
|
const id = 'session-retained-view';
|
|
75
132
|
const summary = { sessionId: id, blank: false, running: false, agentAvailable: true, updatedAt: 1 };
|
|
76
133
|
client.handleSessionAdded(summary);
|
|
@@ -94,6 +151,15 @@ test('installed client Controller: a Provider reference keeps the removed genera
|
|
|
94
151
|
assert.notEqual(fresh.binding, previous);
|
|
95
152
|
assert.equal(fresh.binding.session.getSnapshot().removed, false);
|
|
96
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');
|
|
97
163
|
} finally {
|
|
98
164
|
await root.fiber.dispose();
|
|
99
165
|
}
|
|
@@ -174,7 +240,11 @@ test('installed DSH AgentLoop: dispose writer, physical move, resume same sessio
|
|
|
174
240
|
off();
|
|
175
241
|
const again = lifecycleFor(root, id).handle;
|
|
176
242
|
assert.equal(again.agent.session.header.cwd, a);
|
|
177
|
-
await
|
|
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);
|
|
178
248
|
} finally {
|
|
179
249
|
await root.fiber.dispose();
|
|
180
250
|
if (prior === undefined) delete process.env.DSH_HOME; else process.env.DSH_HOME = prior;
|
|
@@ -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
|
+
});
|