dsh-panel-control 0.8.3 → 0.9.1

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,425 @@
1
+ // Behaviour gate for custom panel names and the original-name tag.
2
+ //
3
+ // client.js only runs inside the page module loader, so this harness loads the
4
+ // real file in a vm with a tiny DOM and drives the real engine apply(). It
5
+ // covers the parts a static check cannot: mapping renamed rows back to their
6
+ // entries, the tag's placement, the switch, and the restore on dispose.
7
+ import { readFileSync } from 'node:fs';
8
+ import vm from 'node:vm';
9
+
10
+ const SRC = readFileSync(new URL('../client.js', import.meta.url), 'utf8');
11
+
12
+ const stubReact = {
13
+ createElement: (type, props, ...children) => ({ type, props: { ...(props || {}), children } }),
14
+ useState: (value) => [value, () => {}],
15
+ useEffect: () => {},
16
+ useRef: (value) => ({ current: value }),
17
+ useMemo: (fn) => fn(),
18
+ useCallback: (fn) => fn,
19
+ };
20
+
21
+ function makeEl(tag = 'div') {
22
+ const el = {
23
+ tagName: String(tag).toUpperCase(),
24
+ children: [],
25
+ parentElement: null,
26
+ attributes: {},
27
+ style: {},
28
+ className: '',
29
+ textContent: '',
30
+ isConnected: true,
31
+ _width: 260,
32
+ classList: {
33
+ contains: (name) => String(el.className).split(/\s+/).includes(name),
34
+ add() {},
35
+ remove() {},
36
+ },
37
+ setAttribute(key, value) {
38
+ if (key === 'class') el.className = String(value);
39
+ else el.attributes[key] = String(value);
40
+ },
41
+ getAttribute(key) {
42
+ if (key === 'class') return el.className || null;
43
+ return Object.prototype.hasOwnProperty.call(el.attributes, key) ? el.attributes[key] : null;
44
+ },
45
+ removeAttribute(key) {
46
+ delete el.attributes[key];
47
+ },
48
+ hasAttribute(key) {
49
+ return Object.prototype.hasOwnProperty.call(el.attributes, key);
50
+ },
51
+ appendChild(child) {
52
+ child.parentElement = el;
53
+ el.children.push(child);
54
+ return child;
55
+ },
56
+ remove() {
57
+ if (!el.parentElement) return;
58
+ const at = el.parentElement.children.indexOf(el);
59
+ if (at >= 0) el.parentElement.children.splice(at, 1);
60
+ el.parentElement = null;
61
+ },
62
+ addEventListener() {},
63
+ removeEventListener() {},
64
+ getBoundingClientRect() {
65
+ return { width: el._width, height: 40, top: 0, left: 0, right: el._width, bottom: 40 };
66
+ },
67
+ querySelectorAll(selector) {
68
+ return selectAll(el, selector);
69
+ },
70
+ querySelector(selector) {
71
+ return selectAll(el, selector)[0] || null;
72
+ },
73
+ contains(node) {
74
+ if (node === el) return true;
75
+ return el.children.some((child) => child.contains && child.contains(node));
76
+ },
77
+ };
78
+ return el;
79
+ }
80
+
81
+ function descendants(root) {
82
+ const out = [];
83
+ for (const child of root.children) {
84
+ out.push(child);
85
+ out.push(...descendants(child));
86
+ }
87
+ return out;
88
+ }
89
+
90
+ function selectAll(root, selector) {
91
+ const parts = String(selector).split(',').map((part) => part.trim());
92
+ const found = [];
93
+ for (const part of parts) {
94
+ let hits = [];
95
+ if (part === ':scope > button') {
96
+ hits = root.children.filter((child) => child.tagName === 'BUTTON');
97
+ } else if (part === 'button') {
98
+ hits = descendants(root).filter((child) => child.tagName === 'BUTTON');
99
+ } else if (part === '[data-dpc-sep]') {
100
+ hits = descendants(root).filter((child) => child.hasAttribute('data-dpc-sep'));
101
+ } else if (part === '.dpc-nameTag') {
102
+ hits = descendants(root).filter((child) => String(child.className).split(/\s+/).includes('dpc-nameTag'));
103
+ } else if (part === 'button[data-dpc-label]') {
104
+ hits = descendants(root).filter((child) => child.tagName === 'BUTTON' && child.hasAttribute('data-dpc-label'));
105
+ } else {
106
+ const labelled = part.match(/^button\[(data-dpc-label|aria-label)="(.*)"\]$/);
107
+ if (labelled) {
108
+ hits = descendants(root).filter(
109
+ (child) => child.tagName === 'BUTTON' && child.getAttribute(labelled[1]) === labelled[2],
110
+ );
111
+ }
112
+ }
113
+ for (const hit of hits) if (!found.includes(hit)) found.push(hit);
114
+ }
115
+ return found;
116
+ }
117
+
118
+ function makeButton(label, withTitle = true) {
119
+ const button = makeEl('button');
120
+ button.setAttribute('aria-label', label);
121
+ const glyph = makeEl('span');
122
+ glyph.setAttribute('aria-hidden', 'true');
123
+ button.appendChild(glyph);
124
+ if (withTitle) {
125
+ const title = makeEl('span');
126
+ title.className = 'panelTitle';
127
+ title.textContent = label;
128
+ button.appendChild(title);
129
+ }
130
+ return button;
131
+ }
132
+
133
+ function makeList(buttons, width = 260) {
134
+ const list = makeEl('div');
135
+ list.className = 'panelList-test';
136
+ list._width = width;
137
+ const parent = makeEl('div');
138
+ parent.appendChild(list);
139
+ parent.appendChild(makeEl('div'));
140
+ for (const button of buttons) list.appendChild(button);
141
+ return { list, parent };
142
+ }
143
+
144
+ function makeWindow(localPrefs) {
145
+ const memory = new Map();
146
+ if (localPrefs !== undefined) memory.set('dsh.panel-control.v1', JSON.stringify(localPrefs));
147
+ return {
148
+ document: makeDocument(),
149
+ localStorage: {
150
+ getItem: (key) => (memory.has(key) ? memory.get(key) : null),
151
+ setItem: (key, value) => memory.set(key, String(value)),
152
+ removeItem: (key) => memory.delete(key),
153
+ },
154
+ navigator: { language: 'zh-CN' },
155
+ location: { reload() {} },
156
+ CSS: { escape: (value) => String(value) },
157
+ addEventListener() {},
158
+ removeEventListener() {},
159
+ __DSH_BOOT__: {
160
+ rev: 'names-test',
161
+ entries: [],
162
+ ids: ['plugins', 'dsh-market', 'workspace-control', 'panel-control', 'dup-a', 'dup-b'],
163
+ },
164
+ __ModuleLoader__: { def: null, load(def) { this.def = def; } },
165
+ };
166
+ }
167
+
168
+ function makeDocument() {
169
+ const doc = {
170
+ _list: null,
171
+ readyState: 'loading',
172
+ head: makeEl('head'),
173
+ body: makeEl('body'),
174
+ documentElement: makeEl('html'),
175
+ title: '',
176
+ visibilityState: 'visible',
177
+ createElement: (tag) => makeEl(tag),
178
+ createTextNode: () => ({ textContent: '' }),
179
+ createDocumentFragment: () => makeEl('div'),
180
+ querySelectorAll(selector) {
181
+ if (selector === '[class*="panelList"]') return doc._list ? [doc._list] : [];
182
+ if (selector === 'style[data-dpc-style]') return [];
183
+ return doc._list ? selectAll(doc._list, selector) : [];
184
+ },
185
+ querySelector(selector) {
186
+ return doc.querySelectorAll(selector)[0] || null;
187
+ },
188
+ getElementById: () => null,
189
+ addEventListener() {},
190
+ removeEventListener() {},
191
+ };
192
+ return doc;
193
+ }
194
+
195
+ const calls = { selectPanel: [], effects: [] };
196
+ const PACKAGES = {
197
+ plugins: '@deepseek-ai/dsh-client-ui-plugin-manager',
198
+ 'dsh-market': 'dshmarket',
199
+ 'panel-control': 'dsh-panel-control',
200
+ 'dup-a': 'dsh-dup-a',
201
+ 'dup-b': 'dsh-dup-b',
202
+ };
203
+ const layout = {
204
+ panelInfo: { getSnapshot: () => ({ activePanelId: null }) },
205
+ selectPanel(id) {
206
+ calls.selectPanel.push(id);
207
+ },
208
+ };
209
+ const locale = { getLocale: () => ({ active: 'zh-CN' }), register: () => {}, subscribe: () => () => {} };
210
+
211
+ function makeCtx(registries) {
212
+ const makeRegistry = (name) => {
213
+ if (!registries.has(name)) registries.set(name, []);
214
+ return registries.get(name);
215
+ };
216
+ const subscribers = new Map();
217
+ const notify = (name) => {
218
+ for (const callback of subscribers.get(name) || []) Promise.resolve().then(callback);
219
+ };
220
+ const slotService = {
221
+ register(options, component) {
222
+ const record = { options, component, registrant: options.registrant || 'dsh-panel-control' };
223
+ makeRegistry(options.name).push(record);
224
+ notify(options.name);
225
+ return () => {
226
+ const all = makeRegistry(options.name);
227
+ const at = all.indexOf(record);
228
+ if (at >= 0) all.splice(at, 1);
229
+ notify(options.name);
230
+ };
231
+ },
232
+ entriesOfSlot(name) {
233
+ return makeRegistry(name).slice();
234
+ },
235
+ inject(name, callback) {
236
+ return callback();
237
+ },
238
+ subscribe(name, callback) {
239
+ if (!subscribers.has(name)) subscribers.set(name, []);
240
+ subscribers.get(name).push(callback);
241
+ return () => {
242
+ subscribers.set(name, (subscribers.get(name) || []).filter((fn) => fn !== callback));
243
+ };
244
+ },
245
+ renderSlot(name, props, opts) {
246
+ return stubReact.createElement('div', { 'data-slot': name, 'data-only': opts && opts.only });
247
+ },
248
+ };
249
+ return {
250
+ registries,
251
+ ctx: {
252
+ fiber: { name: 'dsh-panel-control' },
253
+ get(name) {
254
+ if (name === 'layout') return layout;
255
+ if (name === 'locale') return locale;
256
+ return undefined;
257
+ },
258
+ effect(fn) {
259
+ const dispose = fn();
260
+ if (typeof dispose === 'function') calls.effects.push(dispose);
261
+ return dispose;
262
+ },
263
+ slots: slotService,
264
+ connection: {
265
+ rpc: {
266
+ async call(_channel, _endpoint, message) {
267
+ if (message && message.method === 'packages') {
268
+ const ids = (message.payload && message.payload.ids) || [];
269
+ const packages = {};
270
+ for (const row of ids) if (PACKAGES[row.id]) packages[row.id] = PACKAGES[row.id];
271
+ return { ok: true, value: { packages, rev: 'names-test' } };
272
+ }
273
+ return { ok: true, value: {} };
274
+ },
275
+ },
276
+ },
277
+ },
278
+ };
279
+ }
280
+
281
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
282
+
283
+ function seed(registries, buttons) {
284
+ const rows = [
285
+ ['plugins', '插件', 0],
286
+ ['dsh-market', '插件市场', 1],
287
+ ['workspace-control', '工作区管理', 20],
288
+ ];
289
+ for (const [id, label, order] of rows) {
290
+ registries.get('sidebar.panellist').push({
291
+ options: { name: 'sidebar.panellist', id, order, label },
292
+ component: () => null,
293
+ registrant: 'tk',
294
+ });
295
+ }
296
+ return rows.map(([, label]) => label);
297
+ }
298
+
299
+ async function run(localPrefs, options = {}) {
300
+ calls.effects.length = 0;
301
+ calls.selectPanel.length = 0;
302
+ const registries = new Map();
303
+ const doc = makeDocument();
304
+ const buttons = (options.labels || ['插件', '插件市场', '工作区管理', '面板管理']).map((label) =>
305
+ makeButton(label, options.withTitle !== false),
306
+ );
307
+ const { list } = makeList(buttons, options.width || 260);
308
+ doc._list = list;
309
+ const win = makeWindow(localPrefs);
310
+ win.document = doc;
311
+ const app = makeCtx(registries);
312
+ registries.set('sidebar.panellist', []);
313
+ seed(registries, buttons);
314
+ if (options.extraEntries) {
315
+ for (const [id, label, order] of options.extraEntries) {
316
+ registries.get('sidebar.panellist').push({
317
+ options: { name: 'sidebar.panellist', id, order, label },
318
+ component: () => null,
319
+ registrant: 'tk',
320
+ });
321
+ }
322
+ }
323
+
324
+ globalThis.window = win;
325
+ globalThis.document = doc;
326
+ globalThis.localStorage = win.localStorage;
327
+ globalThis.CSS = win.CSS;
328
+ win.__ModuleLoader__.def = null;
329
+ vm.runInThisContext(SRC, { filename: 'client.js' });
330
+ const mod = win.__ModuleLoader__.def.factory((name) => {
331
+ if (name === 'react') return stubReact;
332
+ throw new Error('unexpected require: ' + name);
333
+ });
334
+ mod.apply(app.ctx);
335
+ await sleep(options.wait || 420);
336
+ return { buttons, list, calls, ctx: app.ctx };
337
+ }
338
+
339
+ const failures = [];
340
+ const check = (ok, message) => {
341
+ if (!ok) failures.push(message);
342
+ };
343
+ const titleOf = (button) => button.children.find((child) => child.tagName === 'SPAN' && String(child.className) !== 'dpc-nameTag' && child.getAttribute('aria-hidden') !== 'true');
344
+ const tagOf = (button) => button.children.find((child) => String(child.className).split(/\s+/).includes('dpc-nameTag'));
345
+
346
+ // 1. A renamed entry: custom text, original label addressable, one tag.
347
+ const renamed = await run({ version: 2, names: { plugins: '我的插件' }, tags: true });
348
+ const first = renamed.buttons[0];
349
+ check(first.getAttribute('aria-label') === '我的插件', 'the renamed row kept its old aria-label');
350
+ check(first.getAttribute('data-dpc-label') === '插件', 'the original label was not kept for mapping');
351
+ check(titleOf(first) && titleOf(first).textContent === '我的插件', 'the visible title was not renamed');
352
+ const tag = tagOf(first);
353
+ check(Boolean(tag), 'the renamed row has no package tag');
354
+ check(tag && tag.textContent === 'plugin-manager', 'the tag does not contain the package name');
355
+ check(tag && tag.getAttribute('aria-hidden') === 'true', 'the tag is not hidden from assistive tech');
356
+ check(!tagOf(renamed.buttons[1]), 'an unrenamed row grew a tag');
357
+ check(first.style.order !== undefined, 'the engine did not apply the row order');
358
+
359
+ // 2. The switch removes the tag but keeps the custom name.
360
+ const noTags = await run({ version: 2, names: { plugins: '我的插件' }, tags: false });
361
+ check(noTags.buttons[0].getAttribute('aria-label') === '我的插件', 'turning tags off also dropped the name');
362
+ check(!tagOf(noTags.buttons[0]), 'the tag switch did not remove the tag');
363
+
364
+ // 3. Clearing the name restores the host label.
365
+ const cleared = await run({ version: 2, names: {}, tags: true });
366
+ check(cleared.buttons[0].getAttribute('aria-label') === '插件', 'a cleared name did not restore the host label');
367
+ check(titleOf(cleared.buttons[0]).textContent === '插件', 'a cleared name did not restore the title');
368
+ check(!tagOf(cleared.buttons[0]), 'a cleared name left the tag behind');
369
+
370
+ // 4. Icon rail: no title span, no tag, but the accessible name still follows.
371
+ const rail = await run({ version: 2, names: { plugins: '我的插件' }, tags: true }, { width: 40, withTitle: false });
372
+ check(rail.buttons[0].getAttribute('aria-label') === '我的插件', 'the rail row did not get the custom name');
373
+ check(!tagOf(rail.buttons[0]), 'the rail row grew a tag');
374
+
375
+ // 5. Duplicate labels: positional mapping still reaches both rows, and only the
376
+ // renamed one changes.
377
+ const dup = await run(
378
+ { version: 2, names: { 'dup-a': '甲' }, tags: true },
379
+ {
380
+ labels: ['插件', '插件市场', '工作区管理', '面板管理', '相同', '相同'],
381
+ extraEntries: [
382
+ ['dup-a', '相同', 30],
383
+ ['dup-b', '相同', 31],
384
+ ],
385
+ },
386
+ );
387
+ const dupButtons = dup.buttons.slice(-2);
388
+ check(dupButtons[0].getAttribute('aria-label') === '甲', 'the first duplicate-label row was not renamed');
389
+ check(dupButtons[1].getAttribute('aria-label') === '相同', 'the second duplicate-label row was renamed by mistake');
390
+ check(tagOf(dupButtons[0]).textContent === 'dup-a', 'the duplicate row tag did not use its package');
391
+
392
+ // 5b. An id the Host cannot map falls back to the id itself, with `dsh-` gone.
393
+ const unmapped = await run({ version: 2, names: { 'workspace-control': '工作区' }, tags: true });
394
+ check(tagOf(unmapped.buttons[2]).textContent === 'workspace-control', 'an unmapped package did not fall back to its id');
395
+
396
+ // 6. Dispose restores the host DOM.
397
+ const disposed = await run({ version: 2, names: { plugins: '我的插件' }, tags: true });
398
+ for (const dispose of disposed.calls.effects.slice().reverse()) dispose();
399
+ check(disposed.buttons[0].getAttribute('aria-label') === '插件', 'dispose did not restore the host aria-label');
400
+ check(titleOf(disposed.buttons[0]).textContent === '插件', 'dispose did not restore the host title');
401
+ check(!tagOf(disposed.buttons[0]), 'dispose left the original-name tag behind');
402
+ check(!disposed.buttons[0].getAttribute('data-dpc-label'), 'dispose left the original-label marker behind');
403
+
404
+ console.log(
405
+ JSON.stringify(
406
+ {
407
+ renamed: Boolean(tag),
408
+ tag: tag ? tag.textContent : null,
409
+ tagsOff: !tagOf(noTags.buttons[0]),
410
+ restored: cleared.buttons[0].getAttribute('aria-label'),
411
+ railTag: Boolean(tagOf(rail.buttons[0])),
412
+ duplicates: dupButtons.map((button) => button.getAttribute('aria-label')),
413
+ unmappedTag: tagOf(unmapped.buttons[2]) ? tagOf(unmapped.buttons[2]).textContent : null,
414
+ },
415
+ null,
416
+ 2,
417
+ ),
418
+ );
419
+ if (failures.length) {
420
+ console.error('names-smoke: ' + failures.length + ' problem(s)');
421
+ for (const failure of failures) console.error(' - ' + failure);
422
+ process.exit(1);
423
+ }
424
+ console.log('names-smoke: ok');
425
+ process.exit(0);