@dsh-workbench/module-tools 1.1.13 → 1.1.14-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -23
- package/bin/dsh-wb-module.mjs +18 -4
- package/package.json +4 -4
- package/src/arch.mjs +36 -25
- package/src/check.mjs +50 -3
- package/src/checks/arch.mjs +2 -2
- package/src/checks/contracts.mjs +88 -41
- package/src/checks/kit.mjs +3 -2
- package/src/checks/lint.mjs +2 -2
- package/src/checks/primitives.mjs +2 -2
- package/src/discover.mjs +186 -16
- package/src/init.mjs +7 -8
- package/src/lint.mjs +4 -16
- package/src/market-index.mjs +1 -1
- package/src/migrate.mjs +69 -0
- package/src/release-journal.mjs +3 -1
- package/src/release-target.mjs +1 -1
- package/src/release-util.mjs +7 -1
- package/template-diy/AGENTS.md.tpl +14 -22
- package/template-diy/README.md.tpl +61 -65
- package/template-diy/design.json.tpl +19 -21
- package/template-diy/package.json.tpl +6 -2
- package/template-diy/scripts/smoke.assert.mjs.tpl +28 -0
- package/template-diy/scripts/smoke.mjs.tpl +150 -30
- package/template-diy/src/__tests__/plugin.test.ts.tpl +453 -0
- package/template-diy/src/components/NotesSection.tsx.tpl +74 -0
- package/template-diy/src/components/OverviewBody.module.css.tpl +5 -0
- package/template-diy/src/components/OverviewBody.tsx.tpl +64 -0
- package/template-diy/src/components/OverviewCard.module.css.tpl +9 -1
- package/template-diy/src/components/OverviewCard.tsx.tpl +108 -67
- package/template-diy/src/components/PrefsSection.tsx.tpl +36 -0
- package/template-diy/src/components/RunSection.tsx.tpl +69 -0
- package/template-diy/src/components/{OverviewCard.test.ts.tpl → __tests__/OverviewCard.test.ts.tpl} +51 -1
- package/template-diy/src/declared-tools.ts.tpl +6 -0
- package/template-diy/src/host/notes.ts.tpl +92 -0
- package/template-diy/src/host/overview.ts.tpl +57 -0
- package/template-diy/src/host/sample.ts.tpl +151 -0
- package/template-diy/src/host.ts.tpl +88 -66
- package/template-diy/src/messages.ts.tpl +28 -0
- package/template-diy/src/model/__tests__/flow.test.ts.tpl +36 -0
- package/template-diy/src/model/__tests__/notes.test.ts.tpl +49 -0
- package/template-diy/src/model/{overview.test.ts.tpl → __tests__/overview.test.ts.tpl} +1 -1
- package/template-diy/src/model/__tests__/prefs.test.ts.tpl +59 -0
- package/template-diy/src/model/flow.ts.tpl +47 -0
- package/template-diy/src/model/notes.ts.tpl +52 -0
- package/template-diy/src/model/overview.ts.tpl +3 -1
- package/template-diy/src/model/prefs.ts.tpl +61 -0
- package/template/AGENTS.md.tpl +0 -33
- package/template/CHANGELOG.md.tpl +0 -7
- package/template/README.md.tpl +0 -30
- package/template/arch.config.mjs +0 -2
- package/template/cordis.patch.yml.tpl +0 -3
- package/template/eslint.config.mjs +0 -2
- package/template/package.json.tpl +0 -50
- package/template/scripts/smoke.mjs.tpl +0 -192
- package/template/src/client.tsx.tpl +0 -22
- package/template/src/components/HelloCard.tsx.tpl +0 -28
- package/template/src/host.ts.tpl +0 -21
- package/template/src/messages.ts.tpl +0 -12
- package/template/src/plugin.test.ts.tpl +0 -56
- package/template/tsconfig.json +0 -20
- package/template/types/globals.d.ts +0 -14
- package/template/vitest.config.ts +0 -14
- package/template-diy/src/plugin.test.ts.tpl +0 -159
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { afterAll, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import type { DshToolDefinition } from '@dsh-workbench/core/contract';
|
|
6
|
+
import { createFlowHost, createLongTaskHost, type LongTaskBase } from '@dsh-workbench/shared/host';
|
|
7
|
+
|
|
8
|
+
vi.mock('@dsh-workbench/core/client', () => ({
|
|
9
|
+
kit: {
|
|
10
|
+
hooks: {
|
|
11
|
+
useApiResource: () => ({ phase: 'loading' }),
|
|
12
|
+
useHostWrite: () => ({
|
|
13
|
+
busy: false,
|
|
14
|
+
failed: null,
|
|
15
|
+
detail: null,
|
|
16
|
+
send: async () => ({ ok: true, value: {} }),
|
|
17
|
+
fail: () => {},
|
|
18
|
+
clear: () => {},
|
|
19
|
+
}),
|
|
20
|
+
useLongTask: () => ({
|
|
21
|
+
runs: [],
|
|
22
|
+
stop: async () => ({ ok: true }),
|
|
23
|
+
stopping: null,
|
|
24
|
+
stopFailed: null,
|
|
25
|
+
refresh: () => {},
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
components: {
|
|
29
|
+
Card: () => null,
|
|
30
|
+
CenteredState: () => null,
|
|
31
|
+
EmptyState: () => null,
|
|
32
|
+
StateBlock: () => null,
|
|
33
|
+
IconPanelScreen: () => null,
|
|
34
|
+
Rows: () => null,
|
|
35
|
+
Row: () => null,
|
|
36
|
+
LongTaskRow: () => null,
|
|
37
|
+
Note: () => null,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
vi.mock('@deepseek-ai/dsh-client-ui-primitives', () => ({
|
|
43
|
+
Button: () => null,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { apply, inject, name, OVERVIEW_ROUTE, readOverview } from '../host';
|
|
47
|
+
import { apply as applyClient, inject as injectClient } from '../client';
|
|
48
|
+
import { DECLARED_TOOLS } from '../declared-tools';
|
|
49
|
+
import { RUN_RESOURCE } from '../model/flow';
|
|
50
|
+
import { NOTES_ROUTE, NOTE_RESOURCE } from '../model/notes';
|
|
51
|
+
import { PREFS_RESOURCE, PREFS_ROUTE } from '../model/prefs';
|
|
52
|
+
|
|
53
|
+
interface Mounted {
|
|
54
|
+
readonly routes: Record<string, (search: URLSearchParams) => Promise<unknown>>;
|
|
55
|
+
registerTools(): Array<() => void>;
|
|
56
|
+
readonly longTasks?: {
|
|
57
|
+
snapshot(id: string): LongTaskBase | null;
|
|
58
|
+
stop(id: string): boolean;
|
|
59
|
+
};
|
|
60
|
+
readonly actions?: readonly { readonly resource: string; handle(payload: unknown): unknown }[];
|
|
61
|
+
readonly prefs?: { readonly resource: string; handle(payload: unknown): unknown };
|
|
62
|
+
needsApproval?(call: { readonly name?: string; readonly arguments?: unknown }): unknown;
|
|
63
|
+
approvalDetail?(call: { readonly name?: string; readonly arguments?: unknown }): unknown;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface HostFace {
|
|
67
|
+
readonly unit: string;
|
|
68
|
+
mount(deps: unknown): Mounted;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ClientFace {
|
|
72
|
+
readonly unit: string;
|
|
73
|
+
readonly card: (props: {
|
|
74
|
+
readonly world: { readonly current: { readonly workspace?: { readonly path: string } | null } };
|
|
75
|
+
readonly refreshToken: number;
|
|
76
|
+
readonly onRefresh: () => void;
|
|
77
|
+
readonly t: (key: string) => string;
|
|
78
|
+
}) => unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface OverviewAnswer {
|
|
82
|
+
readonly ok: boolean;
|
|
83
|
+
readonly path: string;
|
|
84
|
+
readonly files: number;
|
|
85
|
+
readonly dirs: number;
|
|
86
|
+
readonly bytes: number;
|
|
87
|
+
readonly largest: readonly { readonly name: string; readonly bytes: number; readonly mtime: string }[];
|
|
88
|
+
readonly error: { readonly code: string; readonly message: string };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface Harness {
|
|
92
|
+
readonly mounted: Mounted;
|
|
93
|
+
readonly tools: DshToolDefinition[];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const scratch = mkdtempSync(join(tmpdir(), 'dsh-wb-diy-'));
|
|
97
|
+
mkdirSync(join(scratch, 'sample', 'nested'), { recursive: true });
|
|
98
|
+
writeFileSync(join(scratch, 'sample', 'small.txt'), 'x'.repeat(10));
|
|
99
|
+
writeFileSync(join(scratch, 'sample', 'mid.log'), 'y'.repeat(50));
|
|
100
|
+
writeFileSync(join(scratch, 'sample', 'big.bin'), 'z'.repeat(100));
|
|
101
|
+
|
|
102
|
+
afterAll(() => {
|
|
103
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
function harness(): Harness {
|
|
107
|
+
const tools: DshToolDefinition[] = [];
|
|
108
|
+
const longTasks = createLongTaskHost({ scope: 'plugin-test', unit: '__UNIT__', bump: () => {} });
|
|
109
|
+
const files = new Map<string, Map<string, unknown>>();
|
|
110
|
+
const storeFor = (workspace: string | null) => {
|
|
111
|
+
const key = workspace ?? '';
|
|
112
|
+
const at = files.get(key) ?? new Map<string, unknown>();
|
|
113
|
+
files.set(key, at);
|
|
114
|
+
return {
|
|
115
|
+
read: (name: string) => at.get(name) ?? null,
|
|
116
|
+
write: (name: string, value: unknown) => {
|
|
117
|
+
at.set(name, value);
|
|
118
|
+
},
|
|
119
|
+
remove: (name: string) => {
|
|
120
|
+
at.delete(name);
|
|
121
|
+
},
|
|
122
|
+
list: () => [...at.keys()],
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
const ctx = {
|
|
126
|
+
get: (target: string) =>
|
|
127
|
+
target === 'tools'
|
|
128
|
+
? {
|
|
129
|
+
register(tool: DshToolDefinition) {
|
|
130
|
+
tools.push(tool);
|
|
131
|
+
return () => {};
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
: undefined,
|
|
135
|
+
effect: (effect: () => void | (() => void)) => {
|
|
136
|
+
effect();
|
|
137
|
+
return () => {};
|
|
138
|
+
},
|
|
139
|
+
logger: { warn: () => {} },
|
|
140
|
+
};
|
|
141
|
+
const deps = {
|
|
142
|
+
ctx,
|
|
143
|
+
logger: { warn: () => {} },
|
|
144
|
+
flow: createFlowHost(),
|
|
145
|
+
longTasks: longTasks.host,
|
|
146
|
+
store: { for: (_unit: string, workspace: string | null) => storeFor(workspace), machine: () => storeFor(null), scan: () => [] },
|
|
147
|
+
sessionIdOf: () => 'session-1',
|
|
148
|
+
sessionWorkspace: () => join(scratch, 'sample'),
|
|
149
|
+
};
|
|
150
|
+
return { mounted: hostFace().mount(deps), tools };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function toolOf(h: Harness, name: string): DshToolDefinition | undefined {
|
|
154
|
+
return h.tools.find((tool) => tool.name === name);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hostFace(): HostFace {
|
|
158
|
+
const faces: unknown[] = [];
|
|
159
|
+
const ctx = {
|
|
160
|
+
get: () => ({
|
|
161
|
+
register: (face: unknown) => {
|
|
162
|
+
faces.push(face);
|
|
163
|
+
return () => {};
|
|
164
|
+
},
|
|
165
|
+
}),
|
|
166
|
+
effect: (effect: () => void | (() => void)) => {
|
|
167
|
+
effect();
|
|
168
|
+
return () => {};
|
|
169
|
+
},
|
|
170
|
+
logger: { warn: () => {} },
|
|
171
|
+
};
|
|
172
|
+
apply(ctx as never);
|
|
173
|
+
return faces[0] as unknown as HostFace;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function clientFace(): ClientFace {
|
|
177
|
+
const faces: unknown[] = [];
|
|
178
|
+
const ctx = {
|
|
179
|
+
get: () => ({
|
|
180
|
+
registerUnit: (face: unknown) => {
|
|
181
|
+
faces.push(face);
|
|
182
|
+
return () => {};
|
|
183
|
+
},
|
|
184
|
+
}),
|
|
185
|
+
effect: (effect: () => void | (() => void)) => {
|
|
186
|
+
effect();
|
|
187
|
+
return () => {};
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
applyClient(ctx as never);
|
|
191
|
+
return faces[0] as ClientFace;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function overview(search: Record<string, string>): Promise<unknown> {
|
|
195
|
+
return harness().mounted.routes[OVERVIEW_ROUTE](new URLSearchParams(search));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function settled(h: Harness, id: string): Promise<LongTaskBase | null> {
|
|
199
|
+
for (let step = 0; step < 100; step += 1) {
|
|
200
|
+
const snapshot = h.mounted.longTasks?.snapshot(id) ?? null;
|
|
201
|
+
if (snapshot === null || snapshot.status !== 'running') return snapshot;
|
|
202
|
+
await new Promise((resolve) => {
|
|
203
|
+
setTimeout(resolve, 10);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return h.mounted.longTasks?.snapshot(id) ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const execContext = {
|
|
210
|
+
agent: { session: { id: 'session-1', header: { cwd: join(scratch, 'sample') } } },
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
describe('__NAME__ 的面', () => {
|
|
214
|
+
it('插件名就是包名', () => {
|
|
215
|
+
expect(name).toBe('__NAME__');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('两侧都只注入外壳交出来的那个服务', () => {
|
|
219
|
+
expect(inject).toEqual(['workbenchUnits']);
|
|
220
|
+
expect(injectClient).toEqual(['workbenchUnits']);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('宿主半侧与浏览器半侧都把自己的面注册进外壳', () => {
|
|
224
|
+
expect(hostFace().unit).toBe('__UNIT__');
|
|
225
|
+
expect(clientFace().unit).toBe('__UNIT__');
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('宿主侧的只读路由', () => {
|
|
230
|
+
it('三条路由都住在自己那一格的命名空间里(键以 <unit>/ 开头),且都是 handler', () => {
|
|
231
|
+
const routes = harness().mounted.routes;
|
|
232
|
+
expect(Object.keys(routes)).toEqual([OVERVIEW_ROUTE, NOTES_ROUTE, PREFS_ROUTE]);
|
|
233
|
+
expect(typeof routes[OVERVIEW_ROUTE]).toBe('function');
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('真读一个目录:文件数 / 目录数 / 总字节 / 最大的几个文件(名字 + 字节 + mtime)', async () => {
|
|
237
|
+
const answer = (await overview({ path: join(scratch, 'sample'), limit: '2' })) as OverviewAnswer;
|
|
238
|
+
expect(answer.ok).toBe(true);
|
|
239
|
+
expect(answer.path).toBe(join(scratch, 'sample'));
|
|
240
|
+
expect(answer.files).toBe(3);
|
|
241
|
+
expect(answer.dirs).toBe(1);
|
|
242
|
+
expect(answer.bytes).toBe(160);
|
|
243
|
+
expect(answer.largest.map((file) => file.name)).toEqual(['big.bin', 'mid.log']);
|
|
244
|
+
expect(answer.largest[0]?.bytes).toBe(100);
|
|
245
|
+
expect(answer.largest[0]?.mtime).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('limit 由查询参数管,越界会夹住', async () => {
|
|
249
|
+
const answer = (await overview({ path: join(scratch, 'sample'), limit: '0' })) as OverviewAnswer;
|
|
250
|
+
expect(answer.largest).toHaveLength(1);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('path 缺省时回 ok:false 的 no-path,而不是偷偷读宿主进程的工作目录', async () => {
|
|
254
|
+
const answer = (await overview({})) as OverviewAnswer;
|
|
255
|
+
expect(answer.ok).toBe(false);
|
|
256
|
+
expect(answer.error.code).toBe('no-path');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('读不到时交回 ok:false 的 error,而不是抛出去', async () => {
|
|
260
|
+
const answer = (await overview({ path: join(scratch, 'nope') })) as OverviewAnswer;
|
|
261
|
+
expect(answer.ok).toBe(false);
|
|
262
|
+
expect(answer.error.code).toBe('overview-unreadable');
|
|
263
|
+
expect(answer.error.message.length).toBeGreaterThan(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('readOverview 与路由返回的是同一份读数', () => {
|
|
267
|
+
expect(readOverview(join(scratch, 'sample'), 5).bytes).toBe(160);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('宿主侧的长任务与工具', () => {
|
|
272
|
+
it('registerTools() 调用时注册两把、返回 disposers,工具名与清单对得上', () => {
|
|
273
|
+
const h = harness();
|
|
274
|
+
const disposers = h.mounted.registerTools();
|
|
275
|
+
expect(h.tools.map((tool) => tool.name)).toEqual([DECLARED_TOOLS.run.name, DECLARED_TOOLS.addNote.name]);
|
|
276
|
+
expect(disposers).toHaveLength(2);
|
|
277
|
+
expect(disposers.every((one) => typeof one === 'function')).toBe(true);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('只读工具能起一趟流程,长任务最后落到 ok,步骤是真的', async () => {
|
|
281
|
+
const h = harness();
|
|
282
|
+
h.mounted.registerTools();
|
|
283
|
+
const tool = toolOf(h, DECLARED_TOOLS.run.name);
|
|
284
|
+
const started = (await tool?.execute({}, execContext)) as { ok: boolean; runId: string };
|
|
285
|
+
expect(started.ok).toBe(true);
|
|
286
|
+
const snapshot = await settled(h, started.runId);
|
|
287
|
+
expect(snapshot?.status).toBe('ok');
|
|
288
|
+
expect(snapshot?.steps.length).toBeGreaterThan(0);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('按 runId 再看时回同一趟,不新起', async () => {
|
|
292
|
+
const h = harness();
|
|
293
|
+
h.mounted.registerTools();
|
|
294
|
+
const tool = toolOf(h, DECLARED_TOOLS.run.name);
|
|
295
|
+
const started = (await tool?.execute({}, execContext)) as { ok: boolean; runId: string };
|
|
296
|
+
await settled(h, started.runId);
|
|
297
|
+
const again = (await tool?.execute({ runId: started.runId }, {})) as { ok: boolean; runId: string };
|
|
298
|
+
expect(again.runId).toBe(started.runId);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('动作通道也能起同一趟(卡片那颗钮走的就是它)', async () => {
|
|
302
|
+
const h = harness();
|
|
303
|
+
const action = h.mounted.actions?.find((one) => one.resource === RUN_RESOURCE);
|
|
304
|
+
const started = action?.handle({ path: join(scratch, 'sample') }) as { ok: boolean; runId: string };
|
|
305
|
+
expect(started.ok).toBe(true);
|
|
306
|
+
expect((await settled(h, started.runId))?.status).toBe('ok');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('没有工作区时工具说清楚,不抛', async () => {
|
|
310
|
+
const h = harness();
|
|
311
|
+
h.mounted.registerTools();
|
|
312
|
+
const answer = (await toolOf(h, DECLARED_TOOLS.run.name)?.execute({}, {})) as { ok: boolean; text: string };
|
|
313
|
+
expect(answer.ok).toBe(false);
|
|
314
|
+
expect(answer.text).toContain('工作区');
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('写通道:笔记 + 审批', () => {
|
|
319
|
+
it('笔记默认是空的;动作通道写一条后读得到', () => {
|
|
320
|
+
const h = harness();
|
|
321
|
+
const path = join(scratch, 'sample');
|
|
322
|
+
const readNotes = (): { notes: { text: string }[] } =>
|
|
323
|
+
h.mounted.routes[NOTES_ROUTE](new URLSearchParams({ path })) as unknown as { notes: { text: string }[] };
|
|
324
|
+
expect(readNotes().notes).toEqual([]);
|
|
325
|
+
const action = h.mounted.actions?.find((one) => one.resource === NOTE_RESOURCE);
|
|
326
|
+
expect(action?.handle({ path, text: ' 第一条 ' })).toMatchObject({ ok: true });
|
|
327
|
+
const notes = readNotes().notes;
|
|
328
|
+
expect(notes).toHaveLength(1);
|
|
329
|
+
expect(notes[0]?.text).toBe('第一条');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('工具写同一份笔记;空文本回 ok:false,不写', async () => {
|
|
333
|
+
const h = harness();
|
|
334
|
+
h.mounted.registerTools();
|
|
335
|
+
const tool = toolOf(h, DECLARED_TOOLS.addNote.name);
|
|
336
|
+
const answer = (await tool?.execute({ text: 'agent 记的' }, execContext)) as { ok: boolean };
|
|
337
|
+
expect(answer.ok).toBe(true);
|
|
338
|
+
const bad = (await tool?.execute({ text: ' ' }, execContext)) as { ok: boolean };
|
|
339
|
+
expect(bad.ok).toBe(false);
|
|
340
|
+
const notes = h.mounted.routes[NOTES_ROUTE](
|
|
341
|
+
new URLSearchParams({ path: join(scratch, 'sample') }),
|
|
342
|
+
) as unknown as { notes: { text: string }[] };
|
|
343
|
+
expect(notes.notes.map((one) => one.text)).toContain('agent 记的');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('写工具要点头:needsApproval 回 ask,approvalDetail 说清写什么', () => {
|
|
347
|
+
const h = harness();
|
|
348
|
+
const verdict = h.mounted.needsApproval?.({
|
|
349
|
+
name: DECLARED_TOOLS.addNote.name,
|
|
350
|
+
arguments: { text: 'x' },
|
|
351
|
+
}) as { kind: string; question?: { zh?: string; en?: string } };
|
|
352
|
+
expect(verdict?.kind).toBe('ask');
|
|
353
|
+
expect(verdict?.question?.zh).toBeTruthy();
|
|
354
|
+
expect(verdict?.question?.en).toBeTruthy();
|
|
355
|
+
expect(
|
|
356
|
+
h.mounted.approvalDetail?.({ name: DECLARED_TOOLS.addNote.name, arguments: { text: 'x' } }),
|
|
357
|
+
).toMatchObject({ zh: expect.any(String), en: expect.any(String) });
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('只读工具不被问(kind: read)', () => {
|
|
361
|
+
const h = harness();
|
|
362
|
+
expect(h.mounted.needsApproval?.({ name: DECLARED_TOOLS.run.name, arguments: {} })).toEqual({
|
|
363
|
+
kind: 'read',
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('没有工作区时写动作说清楚,不抛', () => {
|
|
368
|
+
const h = harness();
|
|
369
|
+
const action = h.mounted.actions?.find((one) => one.resource === NOTE_RESOURCE);
|
|
370
|
+
expect(action?.handle({ text: 'x' })).toMatchObject({ ok: false });
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('配置:按工作区存 limit', () => {
|
|
375
|
+
it('默认 5;写出后读回同一个值,另一个工作区不受影响', () => {
|
|
376
|
+
const h = harness();
|
|
377
|
+
const a = join(scratch, 'sample');
|
|
378
|
+
const b = scratch;
|
|
379
|
+
const limitAt = (path: string): number =>
|
|
380
|
+
(
|
|
381
|
+
h.mounted.routes[PREFS_ROUTE](new URLSearchParams({ path })) as unknown as {
|
|
382
|
+
prefs: { limit: number };
|
|
383
|
+
}
|
|
384
|
+
).prefs.limit;
|
|
385
|
+
expect(limitAt(a)).toBe(5);
|
|
386
|
+
expect(h.mounted.prefs?.handle({ workspace: a, limit: 20 })).toMatchObject({ prefs: { limit: 20 } });
|
|
387
|
+
expect(limitAt(a)).toBe(20);
|
|
388
|
+
expect(limitAt(b)).toBe(5);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('越界偏好会抛,原值不变', () => {
|
|
392
|
+
const h = harness();
|
|
393
|
+
const a = join(scratch, 'sample');
|
|
394
|
+
expect(() => h.mounted.prefs?.handle({ workspace: a, limit: 999 })).toThrow('只认');
|
|
395
|
+
const limit = (
|
|
396
|
+
h.mounted.routes[PREFS_ROUTE](new URLSearchParams({ path: a })) as unknown as {
|
|
397
|
+
prefs: { limit: number };
|
|
398
|
+
}
|
|
399
|
+
).prefs.limit;
|
|
400
|
+
expect(limit).toBe(5);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe('写完之后去哪、细节能不能上屏', () => {
|
|
404
|
+
it('factsOf 认得工具与动作资源;只读工具不连带刷目录', () => {
|
|
405
|
+
const h = harness();
|
|
406
|
+
const factsOf = (h.mounted as unknown as { factsOf?: (call: { source: string }) => readonly string[] | undefined }).factsOf;
|
|
407
|
+
expect(factsOf?.({ source: DECLARED_TOOLS.addNote.name })).toEqual([NOTES_ROUTE]);
|
|
408
|
+
expect(factsOf?.({ source: NOTE_RESOURCE })).toEqual([NOTES_ROUTE]);
|
|
409
|
+
expect(factsOf?.({ source: PREFS_RESOURCE })).toEqual([PREFS_ROUTE]);
|
|
410
|
+
expect(factsOf?.({ source: DECLARED_TOOLS.run.name })).toEqual([]);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it('只读工具的 approvalDetail 不给明细', () => {
|
|
414
|
+
const h = harness();
|
|
415
|
+
expect(h.mounted.approvalDetail?.({ name: DECLARED_TOOLS.run.name, arguments: {} })).toBeUndefined();
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('配置写的返回契约', () => {
|
|
420
|
+
it('写成功回 ok:true(浏览器侧按顶层 ok 判成功)', () => {
|
|
421
|
+
const h = harness();
|
|
422
|
+
const answer = h.mounted.prefs?.handle({ workspace: join(scratch, 'sample'), limit: 10 }) as {
|
|
423
|
+
ok: boolean;
|
|
424
|
+
prefs: { limit: number };
|
|
425
|
+
};
|
|
426
|
+
expect(answer.ok).toBe(true);
|
|
427
|
+
expect(answer.prefs.limit).toBe(10);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('没带工作区:清楚拒绝,不落进内存 store', () => {
|
|
431
|
+
const h = harness();
|
|
432
|
+
expect(h.mounted.prefs?.handle({ limit: 10 })).toMatchObject({ ok: false });
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
describe('笔记 id 在满额后仍然唯一', () => {
|
|
437
|
+
it('连写 21 条:id 不重复,只留最新 20 条', () => {
|
|
438
|
+
const h = harness();
|
|
439
|
+
const path = join(scratch, 'sample');
|
|
440
|
+
const action = h.mounted.actions?.find((one) => one.resource === NOTE_RESOURCE);
|
|
441
|
+
for (let index = 0; index < 21; index += 1) {
|
|
442
|
+
action?.handle({ path, text: `第 ${String(index)} 条` });
|
|
443
|
+
}
|
|
444
|
+
const notes = h.mounted.routes[NOTES_ROUTE](new URLSearchParams({ path })) as unknown as {
|
|
445
|
+
notes: { id: string; text: string }[];
|
|
446
|
+
};
|
|
447
|
+
expect(notes.notes).toHaveLength(20);
|
|
448
|
+
expect(new Set(notes.notes.map((one) => one.id)).size).toBe(20);
|
|
449
|
+
expect(notes.notes[0]?.text).toBe('第 20 条');
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { createElement as h, type ChangeEvent } from 'react';
|
|
2
|
+
import { Button } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
3
|
+
import { kit } from '@dsh-workbench/core/client';
|
|
4
|
+
import { NOTE_TEXT_MAX, type Note as NoteRow } from '../model/notes';
|
|
5
|
+
|
|
6
|
+
const { Field, Note, Row, Rows, TextField } = kit.components;
|
|
7
|
+
const UNIT_ID = '__UNIT__';
|
|
8
|
+
|
|
9
|
+
export interface NotesSectionProps {
|
|
10
|
+
readonly path: string;
|
|
11
|
+
readonly className: string;
|
|
12
|
+
readonly lineClassName: string;
|
|
13
|
+
readonly t: (key: string) => string;
|
|
14
|
+
readonly notes: readonly NoteRow[];
|
|
15
|
+
readonly draft: string;
|
|
16
|
+
readonly onDraft: (value: string) => void;
|
|
17
|
+
readonly write: {
|
|
18
|
+
readonly busy: boolean;
|
|
19
|
+
readonly failed: string | null;
|
|
20
|
+
send(text: string): Promise<{ readonly ok: boolean }>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function NotesSection({
|
|
25
|
+
path,
|
|
26
|
+
className,
|
|
27
|
+
lineClassName,
|
|
28
|
+
t,
|
|
29
|
+
notes,
|
|
30
|
+
draft,
|
|
31
|
+
onDraft,
|
|
32
|
+
write,
|
|
33
|
+
}: NotesSectionProps): ReturnType<typeof h> {
|
|
34
|
+
return h(
|
|
35
|
+
'div',
|
|
36
|
+
{ className },
|
|
37
|
+
h(
|
|
38
|
+
Field,
|
|
39
|
+
{ label: t(`${UNIT_ID}.notesLabel`), hint: t(`${UNIT_ID}.notesHint`) },
|
|
40
|
+
h(
|
|
41
|
+
'div',
|
|
42
|
+
{ className: lineClassName },
|
|
43
|
+
h(TextField, {
|
|
44
|
+
value: draft,
|
|
45
|
+
placeholder: t(`${UNIT_ID}.notePlaceholder`),
|
|
46
|
+
maxLength: NOTE_TEXT_MAX,
|
|
47
|
+
onChange: (event: ChangeEvent<HTMLInputElement>) => {
|
|
48
|
+
onDraft(event.target.value);
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
h(
|
|
52
|
+
Button,
|
|
53
|
+
{
|
|
54
|
+
variant: 'ghost',
|
|
55
|
+
size: 'sm',
|
|
56
|
+
disabled: path === '' || draft.trim() === '' || write.busy,
|
|
57
|
+
onClick: () => {
|
|
58
|
+
void write.send(draft);
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
t(`${UNIT_ID}.noteSave`),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
),
|
|
65
|
+
write.failed === null ? null : h(Note, null, write.failed),
|
|
66
|
+
notes.length === 0
|
|
67
|
+
? h(Note, null, t(`${UNIT_ID}.notesEmpty`))
|
|
68
|
+
: h(
|
|
69
|
+
Rows,
|
|
70
|
+
null,
|
|
71
|
+
...notes.map((one) => h(Row, { key: one.id, label: one.text, value: one.at })),
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createElement as h } from 'react';
|
|
2
|
+
import { kit } from '@dsh-workbench/core/client';
|
|
3
|
+
import { formatBytes, type OverviewState } from '../model/overview';
|
|
4
|
+
import styles from './OverviewBody.module.css';
|
|
5
|
+
|
|
6
|
+
const { CenteredState, EmptyState, IconPanelScreen, Row, Rows, StateBlock } = kit.components;
|
|
7
|
+
const UNIT_ID = '__UNIT__';
|
|
8
|
+
|
|
9
|
+
export interface OverviewBodyProps {
|
|
10
|
+
readonly state: OverviewState;
|
|
11
|
+
readonly t: (key: string) => string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function OverviewBody({ state, t }: OverviewBodyProps): ReturnType<typeof h> {
|
|
15
|
+
if (state.kind === 'idle') {
|
|
16
|
+
return h(CenteredState, { icon: null, title: t(`${UNIT_ID}.needProject`), compact: true });
|
|
17
|
+
}
|
|
18
|
+
if (state.kind === 'loading') {
|
|
19
|
+
return h(CenteredState, {
|
|
20
|
+
icon: null,
|
|
21
|
+
title: t(`${UNIT_ID}.loading`),
|
|
22
|
+
hint: t(`${UNIT_ID}.loadingHint`),
|
|
23
|
+
compact: true,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (state.kind === 'error') {
|
|
27
|
+
return h(CenteredState, {
|
|
28
|
+
icon: null,
|
|
29
|
+
title: t(`${UNIT_ID}.errorTitle`),
|
|
30
|
+
detail: state.detail,
|
|
31
|
+
hint: t(`${UNIT_ID}.errorHint`),
|
|
32
|
+
compact: true,
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
if (state.kind === 'empty') {
|
|
36
|
+
return h(EmptyState, { text: t(`${UNIT_ID}.empty`), icon: h(IconPanelScreen, { size: 20 }), compact: true });
|
|
37
|
+
}
|
|
38
|
+
const payload = state.payload;
|
|
39
|
+
return h(
|
|
40
|
+
StateBlock,
|
|
41
|
+
{
|
|
42
|
+
title: payload.path,
|
|
43
|
+
hint:
|
|
44
|
+
`${String(payload.files)}${t(`${UNIT_ID}.fileUnit`)} · ` +
|
|
45
|
+
`${String(payload.dirs)}${t(`${UNIT_ID}.dirUnit`)} · ${formatBytes(payload.bytes)}`,
|
|
46
|
+
resolving: t(`${UNIT_ID}.largest`),
|
|
47
|
+
},
|
|
48
|
+
h(
|
|
49
|
+
'div',
|
|
50
|
+
{ className: styles.largest },
|
|
51
|
+
h(
|
|
52
|
+
Rows,
|
|
53
|
+
null,
|
|
54
|
+
...payload.largest.map((file) =>
|
|
55
|
+
h(Row, {
|
|
56
|
+
key: file.name,
|
|
57
|
+
label: file.name,
|
|
58
|
+
value: `${formatBytes(file.bytes)} · ${file.mtime}`,
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
);
|
|
64
|
+
}
|