dsh-plugin-toolkit 0.10.0 → 0.12.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/CHANGELOG.md +47 -0
- package/README.md +9 -0
- package/README.zh.md +8 -0
- package/client.js +225 -37
- package/lib/testkit/load.mjs +7 -1
- package/lib/testkit/mount.mjs +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
+
## 0.12.0
|
|
2
|
+
|
|
3
|
+
Five rounds of the panel's own UI, each one driven by looking at the rendered
|
|
4
|
+
thing rather than at the code.
|
|
5
|
+
|
|
6
|
+
- **The tool comes before the guide.** The input, the button and the result now
|
|
7
|
+
sit directly under the header, and the guide moved below a rule into a
|
|
8
|
+
foldable `使用说明`. It used to take a screen and a half of scrolling to reach
|
|
9
|
+
the one control the panel exists for. The docked body is capped at `76ch`, and
|
|
10
|
+
the language switch no longer wraps onto its own line in the sidebar tab.
|
|
11
|
+
- **Results answer first.** A summary bar states the verdict and the counts
|
|
12
|
+
(`3 项里 1 项没过`), the checked folder is shown in monospace, failures come
|
|
13
|
+
first with a tinted row and a left rule, and the passing checks fold into
|
|
14
|
+
`<details>` — closed when something failed, open when everything passed.
|
|
15
|
+
- **No state renders as nothing.** Before the first check the panel says what to
|
|
16
|
+
do; while a check runs the region is `aria-busy`; a request that never reaches
|
|
17
|
+
the host still shows the folder it was asked about.
|
|
18
|
+
- **Keyboard and screen reader.** `Escape` closes the dock from anywhere inside
|
|
19
|
+
it, the toggle carries `aria-controls`, and focus moves to the verdict when a
|
|
20
|
+
check lands, so the answer is announced instead of the input keeping focus.
|
|
21
|
+
- **The outcome survives collapsing.** A chip in the collapsed row reads
|
|
22
|
+
`1 项没过` / `通过`, so the result is visible without opening the panel again.
|
|
23
|
+
- `scripts/preview.mjs` renders the panel — collapsed, open, with a result, and
|
|
24
|
+
the sidebar tab — to one static HTML file, using the same testkit the tests
|
|
25
|
+
use. That is how these five rounds were reviewed; it reads the stylesheet from
|
|
26
|
+
the injected `<style>`, because a regex over the source truncates at the first
|
|
27
|
+
`;` inside a CSS string.
|
|
28
|
+
- Fixed in the testkit: passing both `host` and `fetch` to `mountClient` used to
|
|
29
|
+
ignore the stub, so every request answered 404 and the failure looked like the
|
|
30
|
+
panel's fault.
|
|
31
|
+
|
|
32
|
+
## 0.11.0
|
|
33
|
+
|
|
34
|
+
- **A language switch, in the panel.** 中文 / EN sits in the dock's header row and
|
|
35
|
+
in the sidebar tab, opens in Chinese (the audience this plugin is written for),
|
|
36
|
+
and remembers the choice in `localStorage` under `dsh-plugin-toolkit:lang`. The
|
|
37
|
+
panel no longer follows the host's language silently: the app can be in English
|
|
38
|
+
while the person reading it is not.
|
|
39
|
+
- `useLang()` is a tiny subscription store, so the dock, the tab and the tab
|
|
40
|
+
type's `title()` all switch together.
|
|
41
|
+
- **The testkit hands a bundle the real `window`.** `loadBundle` used to pass a
|
|
42
|
+
stand-in that carried only `__ModuleLoader__`, so `window.localStorage` was
|
|
43
|
+
`undefined` and any preference silently failed to save. That bug was found by
|
|
44
|
+
this feature failing its own persistence assertion.
|
|
45
|
+
- Fixed invalid markup: the switch was nested inside the dock's toggle button.
|
|
46
|
+
React warns and drops the inner button, so it now sits beside it.
|
|
47
|
+
|
|
1
48
|
## 0.10.0
|
|
2
49
|
|
|
3
50
|
- **The checker is docked under the conversation.** It used to live only in the
|
package/README.md
CHANGED
|
@@ -98,6 +98,15 @@ for, how to use it, what each check means, and what it cannot tell you — plus
|
|
|
98
98
|
the folder field and the results. The same panel is also a **Plugin doctor** tab
|
|
99
99
|
in the right sidebar, for when the conversation is busy.
|
|
100
100
|
|
|
101
|
+
The panel speaks both languages and opens in Chinese; the 中文 / EN switch sits
|
|
102
|
+
in the dock's header row (and in the sidebar tab) and the choice is remembered.
|
|
103
|
+
|
|
104
|
+
Look at it without the app:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
node scripts/preview.mjs /tmp/panel.html # collapsed, open, with a result, and the tab
|
|
108
|
+
```
|
|
109
|
+
|
|
101
110
|
## Test a panel without a browser
|
|
102
111
|
|
|
103
112
|
A plugin's client code is one file that calls `window.__ModuleLoader__.load({factory})` — there is no module to import, so the usual React test setup does not apply. `testkit` supplies the missing half: a DOM, the real React, and a host context that records what the panel registers.
|
package/README.zh.md
CHANGED
|
@@ -57,6 +57,14 @@ cd my-panel && npm install && npm test
|
|
|
57
57
|
|
|
58
58
|
检查器固定在**对话栏下方、输入框上方**:默认收起成一行,点开就是完整指南——这是给谁用的、怎么用、每一项检查拦什么、以及它证明不了什么——下面才是文件夹输入框和结果。同一个面板也在右侧栏有一个 **Plugin doctor** 标签页,对话区忙的时候用那个。
|
|
59
59
|
|
|
60
|
+
面板中英双语,默认中文;**中文 / EN** 开关就在对话栏那一行的右侧(右侧栏标签里也有),选过之后会记住。
|
|
61
|
+
|
|
62
|
+
不开应用也能看:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
node scripts/preview.mjs /tmp/panel.html # 收起、展开、有结果、侧栏标签
|
|
66
|
+
```
|
|
67
|
+
|
|
60
68
|
## 不用浏览器测面板
|
|
61
69
|
|
|
62
70
|
```js
|
package/client.js
CHANGED
|
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
|
|
|
3
3
|
factory: (require) => {
|
|
4
4
|
const module = { exports: {} };
|
|
5
5
|
const React = require('react');
|
|
6
|
-
const { useState, useCallback } = React;
|
|
6
|
+
const { useState, useCallback, useEffect, useRef } = React;
|
|
7
7
|
|
|
8
8
|
const NS = 'dsh-plugin-toolkit';
|
|
9
9
|
const KIND = 'toolkit';
|
|
@@ -18,6 +18,14 @@ window.__ModuleLoader__.load({
|
|
|
18
18
|
checking: 'Checking…',
|
|
19
19
|
pass: 'Everything the market checks passes.',
|
|
20
20
|
fail: 'This would not install cleanly.',
|
|
21
|
+
counts: (failed, total) => `${failed} of ${total} checks failed`,
|
|
22
|
+
passedLabel: (n) => `${n} passed`,
|
|
23
|
+
empty: 'Paste the folder that holds package.json, then press Check.',
|
|
24
|
+
guideTitle: 'How to use this',
|
|
25
|
+
checkingNow: 'Checking…',
|
|
26
|
+
chipFail: (n) => `${n} failed`,
|
|
27
|
+
chipPass: 'passes',
|
|
28
|
+
checked: 'Checked',
|
|
21
29
|
noDir: 'Name the folder that holds the plugin\'s package.json.',
|
|
22
30
|
failed: 'Could not run the checks',
|
|
23
31
|
guideDescription: 'Run the install checks on a plugin folder',
|
|
@@ -33,6 +41,14 @@ window.__ModuleLoader__.load({
|
|
|
33
41
|
checking: '检查中…',
|
|
34
42
|
pass: '市场会检查的项都通过了。',
|
|
35
43
|
fail: '这样装上去会出问题。',
|
|
44
|
+
counts: (failed, total) => `${total} 项里 ${failed} 项没过`,
|
|
45
|
+
passedLabel: (n) => `${n} 项通过`,
|
|
46
|
+
empty: '粘贴放着 package.json 的文件夹,然后按「检查」。',
|
|
47
|
+
guideTitle: '使用说明',
|
|
48
|
+
checkingNow: '正在检查…',
|
|
49
|
+
chipFail: (n) => `${n} 项没过`,
|
|
50
|
+
chipPass: '通过',
|
|
51
|
+
checked: '检查的是',
|
|
36
52
|
noDir: '请填写放着 package.json 的插件文件夹。',
|
|
37
53
|
failed: '检查没能跑起来',
|
|
38
54
|
guideDescription: '对任意插件文件夹跑一遍安装前的检查',
|
|
@@ -132,32 +148,119 @@ window.__ModuleLoader__.load({
|
|
|
132
148
|
* type's `title`/`description` are called by the sidebar's guide list, not
|
|
133
149
|
* by this component, and they need the same strings.
|
|
134
150
|
*/
|
|
135
|
-
const translate = (key) => {
|
|
136
|
-
|
|
137
|
-
return COPY[lang][key] ?? key;
|
|
151
|
+
const translate = (key, lang = readLang()) => {
|
|
152
|
+
return (COPY[lang] ?? COPY.en)[key] ?? key;
|
|
138
153
|
};
|
|
139
154
|
|
|
155
|
+
/**
|
|
156
|
+
* The panel's language is the user's choice, not the host's: the app may be
|
|
157
|
+
* in English while the person reading it is not. Chinese is the default,
|
|
158
|
+
* because that is the audience this plugin is written for, and the choice is
|
|
159
|
+
* remembered per browser.
|
|
160
|
+
*/
|
|
161
|
+
const LANG_KEY = 'dsh-plugin-toolkit:lang';
|
|
162
|
+
const LANGUAGES = ['zh', 'en'];
|
|
163
|
+
const langListeners = new Set();
|
|
164
|
+
let langCache = null;
|
|
165
|
+
|
|
166
|
+
function readLang() {
|
|
167
|
+
if (langCache !== null) return langCache;
|
|
168
|
+
try {
|
|
169
|
+
const stored = window.localStorage.getItem(LANG_KEY);
|
|
170
|
+
if (LANGUAGES.includes(stored)) {
|
|
171
|
+
langCache = stored;
|
|
172
|
+
return langCache;
|
|
173
|
+
}
|
|
174
|
+
} catch {
|
|
175
|
+
// No storage (private mode, a sandboxed frame): fall through to the default.
|
|
176
|
+
}
|
|
177
|
+
langCache = 'zh';
|
|
178
|
+
return langCache;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function setLang(next) {
|
|
182
|
+
if (!LANGUAGES.includes(next)) return;
|
|
183
|
+
langCache = next;
|
|
184
|
+
try {
|
|
185
|
+
window.localStorage.setItem(LANG_KEY, next);
|
|
186
|
+
} catch {
|
|
187
|
+
// Remembering the choice is a convenience, never a requirement.
|
|
188
|
+
}
|
|
189
|
+
for (const listener of langListeners) listener();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function useLang() {
|
|
193
|
+
const [lang, set] = useState(readLang);
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
const listener = () => set(readLang());
|
|
196
|
+
langListeners.add(listener);
|
|
197
|
+
return () => langListeners.delete(listener);
|
|
198
|
+
}, []);
|
|
199
|
+
return lang;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function LangSwitch({ lang }) {
|
|
203
|
+
return h('span', { className: 'tk-lang', role: 'group', 'aria-label': 'Language' },
|
|
204
|
+
LANGUAGES.map((code) => h('button', {
|
|
205
|
+
key: code,
|
|
206
|
+
type: 'button',
|
|
207
|
+
className: 'tk-lang-button',
|
|
208
|
+
'data-on': code === lang ? 'true' : 'false',
|
|
209
|
+
'aria-pressed': code === lang ? 'true' : 'false',
|
|
210
|
+
onClick: (event) => {
|
|
211
|
+
// The dock sits inside the composer: a click here must not reach
|
|
212
|
+
// the conversation's own handlers.
|
|
213
|
+
event.stopPropagation();
|
|
214
|
+
setLang(code);
|
|
215
|
+
},
|
|
216
|
+
}, code === 'zh' ? '中文' : 'EN')));
|
|
217
|
+
}
|
|
218
|
+
|
|
140
219
|
const h = React.createElement;
|
|
141
220
|
|
|
142
221
|
const CSS = [
|
|
143
222
|
'.tk-panel{padding:16px;display:flex;flex-direction:column;gap:10px;font-size:13px}',
|
|
144
|
-
'.tk-title{margin:0;font-size:14px;font-weight:600}',
|
|
223
|
+
'.tk-title{margin:0;flex:1;min-width:0;font-size:14px;font-weight:600}',
|
|
145
224
|
'.tk-hint{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
146
225
|
'.tk-form{display:flex;gap:8px}',
|
|
147
226
|
'.tk-input{flex:1;min-width:0;height:28px;padding:0 9px;font:inherit;font-size:12px;',
|
|
148
227
|
'border:0.5px solid var(--dsw-alias-border-l2,#ccc);border-radius:6px;',
|
|
149
228
|
'background:var(--dsw-alias-bg-layer-1,#fff);color:inherit}',
|
|
229
|
+
'.tk-input:hover{border-color:var(--dsw-alias-border-l3,#c9ccd1)}',
|
|
150
230
|
'.tk-input:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
|
|
151
231
|
'.tk-button{height:28px;padding:0 12px;border-radius:6px;border:0;cursor:pointer;font:inherit;',
|
|
152
232
|
'background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
|
|
153
|
-
'.tk-button:
|
|
233
|
+
'.tk-button:hover{filter:brightness(1.06)}',
|
|
234
|
+
'.tk-button:disabled{opacity:.6;cursor:default;filter:none}',
|
|
235
|
+
'.tk-button:focus-visible{outline:2px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:2px}',
|
|
236
|
+
'.tk-chip{flex:none;font-size:11px;font-weight:600;padding:1px 7px;border-radius:999px;',
|
|
237
|
+
'font-variant-numeric:tabular-nums}',
|
|
238
|
+
'.tk-chip[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a);',
|
|
239
|
+
'background:var(--dsw-alias-state-success-bg,#eef8ee)}',
|
|
240
|
+
'.tk-chip[data-ok="false"]{color:var(--dsw-alias-state-error-primary,#c0392b);',
|
|
241
|
+
'background:var(--dsw-alias-state-error-bg,#fdf3f2)}',
|
|
154
242
|
'.tk-failed{margin:0;font-size:12px;color:var(--dsw-alias-state-error-primary,#c0392b)}',
|
|
243
|
+
'.tk-empty{margin:0;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
244
|
+
'.tk-passes{margin:0}',
|
|
245
|
+
'.tk-passes summary{cursor:pointer;font-size:11px;color:var(--dsw-alias-label-tertiary,#888);',
|
|
246
|
+
'padding:2px 0;list-style:none}',
|
|
247
|
+
'.tk-passes summary::-webkit-details-marker{display:none}',
|
|
248
|
+
'.tk-passes summary::before{content:"▸ ";font-size:10px}',
|
|
249
|
+
'.tk-passes[open] summary::before{content:"▾ "}',
|
|
250
|
+
'.tk-passes .tk-rows{margin-top:4px}',
|
|
251
|
+
'.tk-summary{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}',
|
|
252
|
+
'.tk-count{font-size:11px;color:var(--dsw-alias-label-tertiary,#888);font-variant-numeric:tabular-nums}',
|
|
253
|
+
'.tk-checked{margin:0;font-size:11px;color:var(--dsw-alias-label-tertiary,#888);overflow-wrap:anywhere}',
|
|
254
|
+
'.tk-checked code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px}',
|
|
155
255
|
'.tk-verdict{margin:0;font-size:12px;font-weight:600}',
|
|
156
256
|
'.tk-verdict[data-ok="true"]{color:var(--dsw-alias-state-success-primary,#2f855a)}',
|
|
157
257
|
'.tk-verdict[data-ok="false"]{color:var(--dsw-alias-state-warn-primary,#b7791f)}',
|
|
158
258
|
'.tk-rows{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}',
|
|
159
|
-
'.tk-row{display:flex;gap:8px;align-items:baseline;padding:
|
|
160
|
-
'
|
|
259
|
+
'.tk-row{display:flex;gap:8px;align-items:baseline;padding:6px 8px;border-radius:6px;}',
|
|
260
|
+
'.tk-rows .tk-row + .tk-row{margin-top:3px}',
|
|
261
|
+
'.tk-row[data-ok="false"]{background:var(--dsw-alias-state-error-bg,#fdf3f2);',
|
|
262
|
+
'box-shadow:inset 2px 0 0 var(--dsw-alias-state-error-primary,#c0392b)}',
|
|
263
|
+
'.tk-row[data-ok="true"] .tk-message{color:var(--dsw-alias-label-secondary,#555)}',
|
|
161
264
|
'.tk-mark{width:12px;flex:none;font-weight:600}',
|
|
162
265
|
'.tk-row[data-ok="true"] .tk-mark{color:var(--dsw-alias-state-success-primary,#2f855a)}',
|
|
163
266
|
'.tk-row[data-ok="false"] .tk-mark{color:var(--dsw-alias-state-error-primary,#c0392b)}',
|
|
@@ -165,24 +268,41 @@ window.__ModuleLoader__.load({
|
|
|
165
268
|
'.tk-check{font-size:11px;text-transform:uppercase;letter-spacing:.04em;',
|
|
166
269
|
'color:var(--dsw-alias-label-tertiary,#888)}',
|
|
167
270
|
'.tk-message{font-size:12px;overflow-wrap:anywhere}',
|
|
271
|
+
'.tk-panel-head{display:flex;align-items:center;gap:8px}',
|
|
272
|
+
'.tk-lang{display:inline-flex;flex:none;border:0.5px solid var(--dsw-alias-border-l2,#ccc);',
|
|
273
|
+
'border-radius:6px;overflow:hidden}',
|
|
274
|
+
'.tk-lang-button{height:22px;padding:0 8px;border:0;background:none;font:inherit;font-size:11px;',
|
|
275
|
+
'color:var(--dsw-alias-label-secondary,#555);cursor:pointer}',
|
|
276
|
+
'.tk-lang-button[data-on="true"]{background:var(--dsw-alias-state-business-primary,#4176e6);color:#fff}',
|
|
277
|
+
'.tk-lang-button:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:-1px}',
|
|
168
278
|
'.tk-dock{border-top:0.5px solid var(--dsw-alias-border-l2,#e5e5e5);margin:0 12px}',
|
|
169
279
|
'.tk-dock-head{display:flex}',
|
|
170
|
-
'.tk-dock-toggle{display:flex;align-items:center;gap:8px;width:
|
|
280
|
+
'.tk-dock-toggle{display:flex;align-items:center;gap:8px;flex:1;min-width:0;padding:7px 2px;',
|
|
171
281
|
'border:0;background:none;font:inherit;font-size:12px;color:inherit;cursor:pointer;text-align:left}',
|
|
172
282
|
'.tk-dock-toggle:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
|
|
173
283
|
'outline-offset:-1px;border-radius:4px}',
|
|
174
284
|
'.tk-dock-title{font-weight:600}',
|
|
175
285
|
'.tk-dock-sub{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;',
|
|
176
286
|
'color:var(--dsw-alias-label-tertiary,#888)}',
|
|
177
|
-
'.tk-dock-chevron{flex:none;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
178
|
-
'.tk-
|
|
179
|
-
'
|
|
180
|
-
'.tk-
|
|
181
|
-
'
|
|
287
|
+
'.tk-dock-chevron{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
288
|
+
'.tk-summary:focus-visible{outline:1px solid var(--dsw-alias-state-business-primary,#4176e6);',
|
|
289
|
+
'outline-offset:2px;border-radius:4px}',
|
|
290
|
+
'.tk-dock-body{display:flex;flex-direction:column;gap:10px;max-width:76ch;padding:2px 2px 14px;',
|
|
291
|
+
'max-height:60vh;overflow:auto;font-size:12px}',
|
|
292
|
+
'.tk-guide-all{margin-top:2px;padding-top:12px;border-top:0.5px solid var(--dsw-alias-border-l1,#ececec)}',
|
|
293
|
+
'.tk-guide-all[open]{display:flex;flex-direction:column;gap:14px}',
|
|
294
|
+
'.tk-guide-summary{cursor:pointer;font-size:11px;font-weight:600;list-style:none;',
|
|
295
|
+
'color:var(--dsw-alias-label-secondary,#555);padding:2px 0}',
|
|
296
|
+
'.tk-guide-summary::-webkit-details-marker{display:none}',
|
|
297
|
+
'.tk-guide-summary::before{content:"▸ ";font-size:11px;color:var(--dsw-alias-label-tertiary,#888)}',
|
|
298
|
+
'.tk-guide-all[open] .tk-guide-summary::before{content:"▾ ";}',
|
|
299
|
+
'.tk-guide{margin:0;display:flex;flex-direction:column;gap:4px}',
|
|
300
|
+
'.tk-guide h3{margin:0;font-size:12px;font-weight:600;',
|
|
301
|
+
'color:var(--dsw-alias-label-primary,#1f2329)}',
|
|
182
302
|
'.tk-guide p{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
|
|
183
303
|
'.tk-guide ol,.tk-guide ul{margin:0;padding-left:18px;display:flex;flex-direction:column;gap:3px;',
|
|
184
304
|
'color:var(--dsw-alias-label-secondary,#555)}',
|
|
185
|
-
'.tk-guide-rows{display:grid;grid-template-columns:
|
|
305
|
+
'.tk-guide-rows{display:grid;grid-template-columns:132px 1fr;gap:4px 12px;margin:0}',
|
|
186
306
|
'.tk-guide-rows dt{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11px;',
|
|
187
307
|
'color:var(--dsw-alias-label-primary,#222)}',
|
|
188
308
|
'.tk-guide-rows dd{margin:0;color:var(--dsw-alias-label-secondary,#555)}',
|
|
@@ -236,13 +356,15 @@ window.__ModuleLoader__.load({
|
|
|
236
356
|
}
|
|
237
357
|
|
|
238
358
|
function Form({ checker }) {
|
|
359
|
+
const lang = useLang();
|
|
360
|
+
const tr = (key) => translate(key, lang);
|
|
239
361
|
return h('div', { className: 'tk-form' },
|
|
240
362
|
h('input', {
|
|
241
363
|
className: 'tk-input',
|
|
242
364
|
type: 'text',
|
|
243
365
|
value: checker.dir,
|
|
244
|
-
placeholder:
|
|
245
|
-
'aria-label':
|
|
366
|
+
placeholder: tr('placeholder'),
|
|
367
|
+
'aria-label': tr('placeholder'),
|
|
246
368
|
onChange: (event) => checker.setDir(event.target.value),
|
|
247
369
|
onKeyDown: (event) => { if (event.key === 'Enter') checker.check(); },
|
|
248
370
|
}),
|
|
@@ -251,25 +373,65 @@ window.__ModuleLoader__.load({
|
|
|
251
373
|
type: 'button',
|
|
252
374
|
disabled: checker.busy === true,
|
|
253
375
|
onClick: checker.check,
|
|
254
|
-
}, checker.busy === true ?
|
|
376
|
+
}, checker.busy === true ? tr('checking') : tr('check')));
|
|
255
377
|
}
|
|
256
378
|
|
|
257
379
|
function Result({ checker }) {
|
|
258
|
-
|
|
380
|
+
const lang = useLang();
|
|
381
|
+
const tr = (key) => translate(key, lang);
|
|
382
|
+
return h('div', { className: 'tk-result', 'aria-busy': checker.busy === true ? 'true' : 'false' },
|
|
259
383
|
checker.failed !== null ? h('p', { className: 'tk-failed', role: 'status' }, checker.failed) : null,
|
|
384
|
+
checker.failed === null && checker.result === null && checker.busy !== true
|
|
385
|
+
? h('p', { className: 'tk-empty' }, tr('empty'))
|
|
386
|
+
: null,
|
|
260
387
|
checker.result !== null
|
|
261
|
-
? h(
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
388
|
+
? h(ResultBody, { result: checker.result, tr, lang })
|
|
389
|
+
: null);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function ResultBody({ result, tr, lang }) {
|
|
393
|
+
const summary = useRef(null);
|
|
394
|
+
// After a check the answer is the thing to read; leaving focus in the
|
|
395
|
+
// input means a screen reader announces nothing.
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
summary.current?.focus();
|
|
398
|
+
}, [result]);
|
|
399
|
+
const checks = result.checks ?? [];
|
|
400
|
+
const failed = checks.filter((entry) => entry.ok !== true);
|
|
401
|
+
const passed = checks.filter((entry) => entry.ok === true);
|
|
402
|
+
// Failures first: a list that opens with six green lines buries the two
|
|
403
|
+
// that matter below the fold of a docked panel.
|
|
404
|
+
const ordered = [...failed, ...passed];
|
|
405
|
+
return h('div', { className: 'tk-result-body' },
|
|
406
|
+
h('div', { className: 'tk-summary', ref: summary, tabIndex: -1,
|
|
407
|
+
'data-ok': result.ok === true ? 'true' : 'false', role: 'status' },
|
|
408
|
+
h('span', { className: 'tk-verdict' },
|
|
409
|
+
result.ok === true ? tr('pass') : tr('fail')),
|
|
410
|
+
failed.length > 0
|
|
411
|
+
? h('span', { className: 'tk-count' }, COPY[lang].counts(failed.length, checks.length))
|
|
412
|
+
: null),
|
|
413
|
+
typeof result.dir === 'string' && result.dir !== ''
|
|
414
|
+
? h('p', { className: 'tk-checked' },
|
|
415
|
+
h('span', {}, `${tr('checked')} `),
|
|
416
|
+
h('code', {}, result.dir))
|
|
417
|
+
: null,
|
|
418
|
+
h('ul', { className: 'tk-rows' }, failed.map((entry) => h(Row, { key: entry.check, entry }))),
|
|
419
|
+
passed.length > 0
|
|
420
|
+
? h('details', { className: 'tk-passes', open: failed.length === 0 ? true : undefined },
|
|
421
|
+
h('summary', {}, COPY[lang].passedLabel(passed.length)),
|
|
422
|
+
h('ul', { className: 'tk-rows' }, passed.map((entry) => h(Row, { key: entry.check, entry }))))
|
|
265
423
|
: null);
|
|
266
424
|
}
|
|
267
425
|
|
|
268
426
|
function Panel() {
|
|
269
427
|
const checker = useChecker();
|
|
428
|
+
const lang = useLang();
|
|
429
|
+
const tr = (key) => translate(key, lang);
|
|
270
430
|
return h('div', { className: 'tk-panel' },
|
|
271
|
-
h('
|
|
272
|
-
|
|
431
|
+
h('div', { className: 'tk-panel-head' },
|
|
432
|
+
h('h2', { className: 'tk-title' }, tr('title')),
|
|
433
|
+
h(LangSwitch, { lang })),
|
|
434
|
+
h('p', { className: 'tk-hint' }, tr('hint')),
|
|
273
435
|
h(Form, { checker }),
|
|
274
436
|
h(Result, { checker }));
|
|
275
437
|
}
|
|
@@ -282,22 +444,50 @@ window.__ModuleLoader__.load({
|
|
|
282
444
|
function Dock() {
|
|
283
445
|
const [open, setOpen] = useState(false);
|
|
284
446
|
const checker = useChecker();
|
|
285
|
-
const
|
|
447
|
+
const lang = useLang();
|
|
448
|
+
const guide = GUIDE[lang] ?? GUIDE.zh;
|
|
286
449
|
|
|
287
|
-
return h('section', {
|
|
450
|
+
return h('section', {
|
|
451
|
+
className: 'tk-dock',
|
|
452
|
+
'data-open': open === true ? 'true' : 'false',
|
|
453
|
+
// Escape closes it wherever the focus is inside: a docked panel that
|
|
454
|
+
// traps the keyboard is worse than one that is hard to find.
|
|
455
|
+
onKeyDown: (event) => {
|
|
456
|
+
if (event.key === 'Escape' && open === true) setOpen(false);
|
|
457
|
+
},
|
|
458
|
+
},
|
|
288
459
|
h('div', { className: 'tk-dock-head' },
|
|
460
|
+
// The language switch is a sibling of the toggle, never inside it: a
|
|
461
|
+
// button inside a button is invalid and React drops the inner one.
|
|
289
462
|
h('button', {
|
|
290
463
|
className: 'tk-dock-toggle',
|
|
291
464
|
type: 'button',
|
|
292
465
|
'aria-expanded': open === true ? 'true' : 'false',
|
|
466
|
+
'aria-controls': 'dsh-toolkit-dock-body',
|
|
293
467
|
onClick: () => setOpen(open !== true),
|
|
294
468
|
},
|
|
295
|
-
h('span', { className: 'tk-dock-title' }, translate('dockTitle')),
|
|
296
|
-
h('span', { className: 'tk-dock-sub' }, translate('dockSub')),
|
|
297
|
-
h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸'))
|
|
469
|
+
h('span', { className: 'tk-dock-title' }, translate('dockTitle', lang)),
|
|
470
|
+
h('span', { className: 'tk-dock-sub' }, translate('dockSub', lang)),
|
|
471
|
+
h('span', { className: 'tk-dock-chevron', 'aria-hidden': 'true' }, open === true ? '▾' : '▸')),
|
|
472
|
+
open !== true && checker.result !== null
|
|
473
|
+
? h('span', {
|
|
474
|
+
className: 'tk-chip',
|
|
475
|
+
'data-ok': checker.result.ok === true ? 'true' : 'false',
|
|
476
|
+
}, checker.result.ok === true
|
|
477
|
+
? COPY[lang].chipPass
|
|
478
|
+
: COPY[lang].chipFail((checker.result.checks ?? []).filter((entry) => entry.ok !== true).length))
|
|
479
|
+
: null,
|
|
480
|
+
h(LangSwitch, { lang })),
|
|
298
481
|
open === true
|
|
299
|
-
? h('div', { className: 'tk-dock-body' },
|
|
300
|
-
|
|
482
|
+
? h('div', { className: 'tk-dock-body', id: 'dsh-toolkit-dock-body' },
|
|
483
|
+
// The input comes first: the guide is reference material, and making
|
|
484
|
+
// people scroll past a wall of prose to reach the one control they
|
|
485
|
+
// came for is how a docked panel stops being used.
|
|
486
|
+
h(Form, { checker }),
|
|
487
|
+
h(Result, { checker }),
|
|
488
|
+
h('details', { className: 'tk-guide-all', open: true },
|
|
489
|
+
h('summary', { className: 'tk-guide-summary' }, translate('guideTitle', lang)),
|
|
490
|
+
h('div', { className: 'tk-guide' },
|
|
301
491
|
h('h3', {}, guide.who.title),
|
|
302
492
|
h('p', {}, guide.who.body)),
|
|
303
493
|
h('div', { className: 'tk-guide' },
|
|
@@ -313,11 +503,9 @@ window.__ModuleLoader__.load({
|
|
|
313
503
|
h('div', { className: 'tk-guide' },
|
|
314
504
|
h('h3', {}, guide.notes.title),
|
|
315
505
|
h('ul', {}, guide.notes.items.map((item) => h('li', { key: item }, item)))),
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
h(Form, { checker }),
|
|
320
|
-
h(Result, { checker }))
|
|
506
|
+
h('div', { className: 'tk-guide' },
|
|
507
|
+
h('h3', {}, guide.cli.title),
|
|
508
|
+
h('p', {}, h('code', {}, guide.cli.body)))))
|
|
321
509
|
: null);
|
|
322
510
|
}
|
|
323
511
|
|
package/lib/testkit/load.mjs
CHANGED
|
@@ -46,7 +46,13 @@ export function createModuleLoader({ require: provide, onLoad } = {}) {
|
|
|
46
46
|
export function loadBundle(file, options = {}) {
|
|
47
47
|
const source = readBundleSource(file);
|
|
48
48
|
const loader = createModuleLoader(options);
|
|
49
|
-
|
|
49
|
+
// The bundle's `window` must be the real one: a plugin reads
|
|
50
|
+
// `window.localStorage`, `window.matchMedia`, `window.navigator`, and a
|
|
51
|
+
// stand-in that only carries the loader turns every one of those into a
|
|
52
|
+
// silent `undefined`.
|
|
53
|
+
const hostWindow = options.window ?? {};
|
|
54
|
+
hostWindow.__ModuleLoader__ = loader;
|
|
55
|
+
const context = { window: hostWindow, __ModuleLoader__: loader, ...(options.globals ?? {}) };
|
|
50
56
|
// The bundle is an IIFE or a top-level statement list; the browser would run it
|
|
51
57
|
// as a classic script, so a function body is the closest equivalent.
|
|
52
58
|
const run = new Function('window', '__ModuleLoader__', 'require', 'module', 'exports', source);
|
package/lib/testkit/mount.mjs
CHANGED
|
@@ -69,12 +69,16 @@ export async function mountClient(options) {
|
|
|
69
69
|
// host never throws, and "the service threw" is the failure mode that costs
|
|
70
70
|
// the most to debug in the real app.
|
|
71
71
|
const host = options.host ?? createHost({ locale: copy, fetch: fetchImpl, surface });
|
|
72
|
+
// A caller that passes both a host and a fetch stub means the stub for this
|
|
73
|
+
// mount: silently keeping the host's own fetch answers 404 to everything and
|
|
74
|
+
// the test then blames the panel.
|
|
75
|
+
if (options.host !== undefined && fetchImpl !== undefined) host.fetch = fetchImpl;
|
|
72
76
|
// The panel calls the browser's fetch, not a service, so the stub has to be
|
|
73
77
|
// the global one for the duration of the mount.
|
|
74
78
|
const previousFetch = globalThis.fetch;
|
|
75
79
|
globalThis.fetch = host.fetch;
|
|
76
80
|
try {
|
|
77
|
-
const exported = loadBundle(file, { require: moduleShim(modules) });
|
|
81
|
+
const exported = loadBundle(file, { require: moduleShim(modules), window });
|
|
78
82
|
const apply = exported.apply ?? exported.default ?? exported.install ?? exported;
|
|
79
83
|
if (typeof apply !== 'function') {
|
|
80
84
|
throw new Error(`${file} exports no apply(ctx) function, so there is nothing to mount`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-toolkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Preflight, release and test DeepSeek Harness plugins: checks what the host needs, waits for npm to actually serve the version, and keeps the market index honest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|