dsh-model-input-toggle 0.0.0-stage → 1.0.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.
@@ -0,0 +1,954 @@
1
+ /**
2
+ * dsh-model-input-toggle — client-half render test.
3
+ *
4
+ * WHAT THIS PROVES
5
+ * ----------------
6
+ * The client bundle is plain CJS wrapped in `window.__ModuleLoader__.load({...})`
7
+ * and is never transpiled, so a syntax-valid file can still be dead on arrival
8
+ * (a bad `require` target, JSX left in, an import statement, a slot key the
9
+ * owner never dispatches). This harness supplies a fake `__ModuleLoader__`, a
10
+ * minimal React implementation with a working hook dispatcher, and a fake slot
11
+ * service, then renders the REAL registration tree and drives the checkbox.
12
+ *
13
+ * React itself is not installed alongside a renderer here, so the "React" below
14
+ * is a small stand-in implementing exactly the API surface the bundle uses
15
+ * (createElement, useState, useEffect, useMemo-free). Hook ORDER and the effect
16
+ * lifecycle are honoured, which is what makes the optimistic-update and
17
+ * rollback paths testable.
18
+ *
19
+ * Run: node tools/test-client.mjs
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { fileURLToPath } from "node:url";
24
+ import { dirname, join } from "node:path";
25
+
26
+ const here = dirname(fileURLToPath(import.meta.url));
27
+
28
+ let passed = 0;
29
+ let failed = 0;
30
+
31
+ async function test(name, fn) {
32
+ try {
33
+ await fn();
34
+ passed += 1;
35
+ process.stdout.write(` ok ${name}\n`);
36
+ } catch (error) {
37
+ failed += 1;
38
+ process.stdout.write(` FAIL ${name}\n ${String(error?.message ?? error).replace(/\n/g, "\n ")}\n`);
39
+ }
40
+ }
41
+
42
+ function assert(condition, message) {
43
+ if (!condition) throw new Error(`assertion failed: ${message}`);
44
+ }
45
+
46
+ function assertEqual(actual, expected, label) {
47
+ if (actual !== expected) throw new Error(`${label}: got ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`);
48
+ }
49
+
50
+ // ── a minimal React with real hook semantics ───────────────────────────────
51
+
52
+ /**
53
+ * Create a React stand-in. `render` walks the element tree, maintaining a hook
54
+ * cursor per component instance so useState/useEffect behave as they do in the
55
+ * real thing (state persists across re-renders, effects run after commit).
56
+ */
57
+ function createReact() {
58
+ let current = null; // { hooks, cursor }
59
+ const pendingEffects = [];
60
+
61
+ function createElement(type, props, ...children) {
62
+ const flat = [];
63
+ const push = (child) => {
64
+ if (child === null || child === undefined || child === false || child === true) return;
65
+ if (Array.isArray(child)) {
66
+ child.forEach(push);
67
+ return;
68
+ }
69
+ flat.push(child);
70
+ };
71
+ children.forEach(push);
72
+ return { type, props: { ...(props ?? {}), children: flat.length === 1 ? flat[0] : flat } };
73
+ }
74
+
75
+ function useHook(initial) {
76
+ const instance = current;
77
+ const index = instance.cursor;
78
+ instance.cursor += 1;
79
+ if (instance.hooks.length <= index) {
80
+ instance.hooks[index] = { value: typeof initial === "function" ? initial() : initial };
81
+ }
82
+ return instance.hooks[index];
83
+ }
84
+
85
+ function useState(initial) {
86
+ const hook = useHook(initial);
87
+ const setter = (next) => {
88
+ const resolved = typeof next === "function" ? next(hook.value) : next;
89
+ if (Object.is(resolved, hook.value)) return;
90
+ hook.value = resolved;
91
+ hook.dirty = true;
92
+ };
93
+ return [hook.value, setter];
94
+ }
95
+
96
+ function useEffect(effect, deps) {
97
+ const hook = useHook(undefined);
98
+ const previous = hook.deps;
99
+ const changed = previous === undefined || deps === undefined
100
+ || deps.length !== previous.length
101
+ || deps.some((value, index) => !Object.is(value, previous[index]));
102
+ if (!changed) return;
103
+ hook.deps = deps;
104
+ if (typeof hook.cleanup === "function") hook.cleanup();
105
+ hook.cleanup = undefined;
106
+ hook.pending = effect;
107
+ pendingEffects.push(hook);
108
+ }
109
+ return {
110
+ createElement,
111
+ useState,
112
+ useEffect,
113
+ /** Test hook: set the component instance whose hooks are being read. */
114
+ __setCurrent(instance) {
115
+ current = instance;
116
+ },
117
+ __createInstance() {
118
+ return { hooks: [], cursor: 0 };
119
+ },
120
+ /** Run effects scheduled during the last render pass. */
121
+ __flushEffects() {
122
+ while (pendingEffects.length > 0) {
123
+ const hook = pendingEffects.shift();
124
+ const effect = hook.pending;
125
+ hook.pending = undefined;
126
+ if (effect !== undefined) hook.cleanup = effect() ?? undefined;
127
+ }
128
+ }
129
+ };
130
+ }
131
+
132
+ // ── the loader + slot harness ──────────────────────────────────────────────
133
+
134
+ const REACT = createReact();
135
+
136
+ /**
137
+ * Load lib/client.js into a fake browser-ish global and return everything the
138
+ * test needs to inspect and drive the registration.
139
+ *
140
+ * `options.setTimeout` overrides the timer the bundle captures, so a test can
141
+ * fire the bundle's own 10s request timeout deterministically.
142
+ */
143
+ function loadClient(options = {}) {
144
+ const source = readFileSync(join(here, "..", "lib", "client.js"), "utf8");
145
+ const loaded = [];
146
+ const listeners = new Set();
147
+ const styles = [];
148
+ const registered = [];
149
+ const injected = [];
150
+
151
+ const previousWindow = globalThis.window;
152
+ const previousDocument = globalThis.document;
153
+ const previousSetTimeout = globalThis.setTimeout;
154
+
155
+ const fakeDocument = {
156
+ head: {
157
+ appendChild(node) {
158
+ styles.push(node);
159
+ }
160
+ },
161
+ createElement(tag) {
162
+ return {
163
+ tagName: tag,
164
+ attributes: {},
165
+ setAttribute(name, value) {
166
+ this.attributes[name] = value;
167
+ },
168
+ parentNode: null
169
+ };
170
+ },
171
+ addEventListener() {},
172
+ removeEventListener() {}
173
+ };
174
+
175
+ // window.localStorage is intentionally absent here so the "storage blocked"
176
+ // path is exercised; a test can install one.
177
+ const fakeWindow = {
178
+ __ModuleLoader__: {
179
+ load(definition) {
180
+ loaded.push(definition);
181
+ }
182
+ },
183
+ // Tests install a localStorage here BEFORE the bundle reads it.
184
+ localStorage: options.localStorage
185
+ };
186
+
187
+ globalThis.window = fakeWindow;
188
+ globalThis.document = fakeDocument;
189
+ // A controllable timer: tests may replace it to fire the bundle's own timeouts.
190
+ const timers = [];
191
+ globalThis.setTimeout = options.setTimeout ?? ((fn, ms) => {
192
+ const handle = { fn, ms, cleared: false };
193
+ timers.push(handle);
194
+ return handle;
195
+ });
196
+ globalThis.clearTimeout = (handle) => {
197
+ if (handle !== undefined && handle !== null) handle.cleared = true;
198
+ };
199
+
200
+ try {
201
+ // The bundle is a script: evaluate it with `window` in scope.
202
+ const evaluator = new Function("window", "document", "setTimeout", "clearTimeout", "console", "fetch", "AbortController", source);
203
+ evaluator(fakeWindow, fakeDocument, globalThis.setTimeout, globalThis.clearTimeout, console, globalThis.fetch, globalThis.AbortController);
204
+ } finally {
205
+ globalThis.window = previousWindow;
206
+ globalThis.document = previousDocument;
207
+ globalThis.setTimeout = previousSetTimeout;
208
+ }
209
+
210
+ const definition = loaded[0];
211
+ if (definition === undefined) throw new Error("the bundle never called window.__ModuleLoader__.load");
212
+
213
+ // The factory returns its own `module.exports` directly.
214
+ const exportsObject = definition.factory((id) => {
215
+ if (id === "react") return REACT;
216
+ throw new Error(`the client bundle required an unexpected module: ${id}`);
217
+ });
218
+ if (typeof exportsObject?.apply !== "function") {
219
+ throw new Error("the client bundle did not export an apply() function");
220
+ }
221
+
222
+ // A fake slots service recording inject/register, as the shell would.
223
+ const ctx = {
224
+ effect(callback, label) {
225
+ const disposer = callback();
226
+ return disposer ?? (() => void label);
227
+ },
228
+ slots: {
229
+ inject(slotName, callback) {
230
+ injected.push(slotName);
231
+ callback();
232
+ },
233
+ register(contract, Component) {
234
+ registered.push({ contract, Component });
235
+ return () => void 0;
236
+ }
237
+ }
238
+ };
239
+ exportsObject.apply(ctx);
240
+
241
+ return {
242
+ module: { exports: exportsObject },
243
+ definition,
244
+ ctx,
245
+ injected,
246
+ registered,
247
+ styles,
248
+ timers,
249
+ listeners
250
+ };
251
+ }
252
+
253
+ /**
254
+ * Render one element tree to a plain node structure, so a test can assert on
255
+ * what the user would see. Children render inline; only function components
256
+ * recurse (and get hook instances).
257
+ */
258
+ function renderToTree(element, instances = new Map()) {
259
+ if (element === null || element === undefined || element === false) return null;
260
+ if (typeof element === "string" || typeof element === "number") return { text: String(element) };
261
+ if (Array.isArray(element)) return { children: element.map((child) => renderToTree(child, instances)).filter(Boolean) };
262
+
263
+ const { type, props } = element;
264
+ if (typeof type === "function") {
265
+ let instance = instances.get(type);
266
+ if (instance === undefined) {
267
+ instance = REACT.__createInstance();
268
+ instances.set(type, instance);
269
+ }
270
+ // A fresh element identity re-runs the component body, as a re-render would.
271
+ instance.cursor = 0;
272
+ REACT.__setCurrent(instance);
273
+ const output = type(props);
274
+ return renderToTree(output, instances);
275
+ }
276
+ const children = [];
277
+ const raw = props.children;
278
+ if (Array.isArray(raw)) {
279
+ for (const child of raw) {
280
+ const node = renderToTree(child, instances);
281
+ if (node !== null) children.push(node);
282
+ }
283
+ } else if (raw !== undefined && raw !== null) {
284
+ const node = renderToTree(raw, instances);
285
+ if (node !== null) children.push(node);
286
+ }
287
+ return { type, props, children, instances };
288
+ }
289
+
290
+ /** Collect every rendered node matching a predicate. */
291
+ function collect(node, predicate, out = []) {
292
+ if (node === null || node === undefined) return out;
293
+ if (predicate(node)) out.push(node);
294
+ if (Array.isArray(node.children)) node.children.forEach((child) => collect(child, predicate, out));
295
+ return out;
296
+ }
297
+
298
+ /** Find the text of every node under `node`, concatenated. */
299
+ function textOf(node) {
300
+ if (node === null || node === undefined) return "";
301
+ if (node.text !== undefined) return node.text;
302
+ if (!Array.isArray(node.children)) return "";
303
+ return node.children.map(textOf).join("");
304
+ }
305
+
306
+ /** Find an input element whose aria-label contains `needle`. */
307
+ function findCheckbox(node, needle) {
308
+ const matches = collect(node, (candidate) => candidate.type === "input"
309
+ && candidate.props.type === "checkbox"
310
+ && String(candidate.props["aria-label"] ?? "").includes(needle));
311
+ return matches[0];
312
+ }
313
+
314
+ /**
315
+ * Let every pending promise chain and timer callback run to completion, so a
316
+ * fetch the bundle started during render (or in a click handler) has settled
317
+ * before the test asserts on the re-rendered tree.
318
+ */
319
+ async function settle() {
320
+ for (let i = 0; i < 5; i += 1) {
321
+ await new Promise((resolve) => setImmediate(resolve));
322
+ }
323
+ }
324
+
325
+ /**
326
+ * Paint the panel and settle it WITHOUT expanding — the exact view a user gets
327
+ * when the settings page first opens. Use this to assert the collapsed default.
328
+ */
329
+ async function paintCollapsed(panel, element, instances) {
330
+ const map = instances ?? new Map();
331
+ renderToTree(element, map);
332
+ REACT.__flushEffects();
333
+ await settle();
334
+ const settled = renderToTree(element, map);
335
+ REACT.__flushEffects();
336
+ await settle();
337
+ return settled;
338
+ }
339
+
340
+ /**
341
+ * Find the panel's collapse toggle in a painted tree, if it has one. Returns
342
+ * undefined when the panel rendered nothing expandable (absent provider, still
343
+ * loading, or a header carrying only an error).
344
+ */
345
+ function findToggle(tree) {
346
+ return collect(tree, (node) => node.type === "button"
347
+ && String(node.props.className ?? "").includes("dsh-imit-toggle"))[0];
348
+ }
349
+
350
+ /**
351
+ * Paint the panel the way most tests need it: settled AND expanded, so the model
352
+ * rows are visible. The expanded state comes from clicking the real toggle, and
353
+ * because expansion is detected from `aria-expanded` this is safe to call again
354
+ * mid-test without collapsing what the test already opened.
355
+ */
356
+ async function paint(panel, element, instances) {
357
+ const map = instances ?? new Map();
358
+ const collapsed = await paintCollapsed(panel, element, map);
359
+ const toggle = findToggle(collapsed);
360
+ if (toggle === undefined || toggle.props["aria-expanded"] !== "false") return collapsed;
361
+ toggle.props.onClick();
362
+ return await paintCollapsed(panel, element, map);
363
+ }
364
+
365
+ /**
366
+ * A Map-backed localStorage for a test to hand the bundle. `acknowledged` seeds
367
+ * the one-time image-input acknowledgement the confirmation dialog writes.
368
+ */
369
+ function makeStorage(acknowledged) {
370
+ const map = new Map();
371
+ if (acknowledged !== undefined) map.set("dsh-model-input-toggle:acknowledged", acknowledged);
372
+ return {
373
+ map,
374
+ getItem: (key) => (map.has(key) ? map.get(key) : null),
375
+ setItem: (key, value) => map.set(key, String(value))
376
+ };
377
+ }
378
+
379
+ // ── test fixtures ──────────────────────────────────────────────────────────
380
+
381
+ /** An inventory reply covering a pi-ai route and the deepseek route. */
382
+ const INVENTORY = {
383
+ ok: true,
384
+ providers: [
385
+ {
386
+ router: "pi-ai",
387
+ namespace: "llm-pi-ai",
388
+ provider: "agnes",
389
+ displayName: "agnes",
390
+ ae: undefined,
391
+ models: [
392
+ { id: "agnes-3.0-flash", name: "Agnes 3.0 Flash", effective: ["text"], source: "defaultInput", locked: false },
393
+ { id: "agnes-vision", name: "agnes-vision", effective: ["text", "image"], source: "model", locked: false }
394
+ ]
395
+ },
396
+ {
397
+ router: "deepseek",
398
+ namespace: "llm-deepseek",
399
+ provider: "deepseek-official",
400
+ displayName: "DeepSeek",
401
+ models: [
402
+ { id: "deepseek-v4-flash-vision-exp", name: "Vision Exp", effective: ["text", "image"], source: "catalog", locked: true }
403
+ ]
404
+ }
405
+ ]
406
+ };
407
+
408
+ /**
409
+ * Install a scripted fetch and return a log of the requests the bundle made.
410
+ * The bundle is loaded with this fetch already in scope, so a test must install
411
+ * it before loadClient().
412
+ */
413
+ function installFetch(handler) {
414
+ const calls = [];
415
+ const previous = globalThis.fetch;
416
+ globalThis.fetch = (url, init) => {
417
+ const body = init === undefined ? undefined : JSON.parse(init.body);
418
+ calls.push({ url, body });
419
+ return handler(body, calls.length - 1);
420
+ };
421
+ return {
422
+ calls,
423
+ restore() {
424
+ globalThis.fetch = previous;
425
+ }
426
+ };
427
+ }
428
+
429
+ /** A resolved fetch reply for one host method. */
430
+ function replyFor(body, replies) {
431
+ const scripted = replies[body.method];
432
+ if (scripted === undefined) throw new Error(`unexpected host call: ${body.method}`);
433
+ const value = typeof scripted === "function" ? scripted(body) : scripted;
434
+ return Promise.resolve({
435
+ status: 200,
436
+ ok: true,
437
+ json: () => Promise.resolve(value)
438
+ });
439
+ }
440
+
441
+ // ── tests ──────────────────────────────────────────────────────────────────
442
+
443
+ process.stdout.write("dsh-model-input-toggle client tests\n");
444
+
445
+ await test("the bundle registers one keyed cell per supported namespace", async () => {
446
+ const harness = loadClient();
447
+ assertEqual(harness.injected.length, 2, "inject call count");
448
+ assert(harness.injected.every((name) => name === "settings.models.provider-card"), "the injected slot is the provider card");
449
+ const keys = harness.registered.map((entry) => entry.contract.key).sort();
450
+ assertEqual(keys.join(","), "llm-deepseek,llm-pi-ai", "registered keys must be the settings namespaces");
451
+ for (const entry of harness.registered) {
452
+ assertEqual(entry.contract.name, "settings.models.provider-card", "contract name");
453
+ }
454
+ });
455
+
456
+ await test("the stylesheet is injected as an owned effect", async () => {
457
+ const harness = loadClient();
458
+ assertEqual(harness.styles.length, 1, "one style node");
459
+ const style = harness.styles[0];
460
+ assertEqual(style.tagName, "style", "tag");
461
+ assert(String(style.textContent).includes("dsh-imit-panel"), "the panel class is defined");
462
+ assert(String(style.textContent).includes("dsh-imit-spin"), "the spinner is defined");
463
+ });
464
+
465
+ await test("a provider with no settingsNs renders nothing", async () => {
466
+ const fetchLog = installFetch((body) => replyFor(body, { list: INVENTORY }));
467
+ try {
468
+ const harness = loadClient();
469
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
470
+ const tree = await paint(panel, REACT.createElement(panel, {
471
+ provider: { provider: "some-route", settingsNs: "", settingsPath: [] },
472
+ configured: true,
473
+ keyConfigured: true
474
+ }));
475
+ assert(tree === null, "no UI may be rendered for an unextendable provider");
476
+ } finally {
477
+ fetchLog.restore();
478
+ }
479
+ });
480
+
481
+ await test("an unconfigured draft card renders nothing", async () => {
482
+ const fetchLog = installFetch((body) => replyFor(body, { list: INVENTORY }));
483
+ try {
484
+ const harness = loadClient();
485
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
486
+ const tree = await paint(panel, REACT.createElement(panel, {
487
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
488
+ configured: false,
489
+ keyConfigured: false
490
+ }));
491
+ assert(tree === null, "an unsaved draft has nothing to write to");
492
+ } finally {
493
+ fetchLog.restore();
494
+ }
495
+ });
496
+
497
+ await test("the panel lists the route's own models with correct initial state", async () => {
498
+ const fetchLog = installFetch((body) => replyFor(body, { list: INVENTORY }));
499
+ try {
500
+ const harness = loadClient();
501
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
502
+ const instances = new Map();
503
+ const tree = await paint(panel, REACT.createElement(panel, {
504
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
505
+ configured: true,
506
+ keyConfigured: true
507
+ }), instances);
508
+
509
+ const text = textOf(tree);
510
+ assert(text.includes("图片输入"), "the panel title is present");
511
+ assert(text.includes("Agnes 3.0 Flash"), "the first model is listed");
512
+ assert(text.includes("agnes-vision"), "the second model is listed");
513
+
514
+ // One shared inventory fetch serves the whole panel.
515
+ assertEqual(fetchLog.calls.length, 1, "exactly one inventory request");
516
+ assertEqual(fetchLog.calls[0].body.method, "list", "the request is the list method");
517
+ } finally {
518
+ fetchLog.restore();
519
+ }
520
+ });
521
+
522
+ await test("only the card's own route is rendered, not every provider", async () => {
523
+ const fetchLog = installFetch((body) => replyFor(body, { list: INVENTORY }));
524
+ try {
525
+ const harness = loadClient();
526
+ const piPanel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
527
+ const dsPanel = harness.registered.find((entry) => entry.contract.key === "llm-deepseek").Component;
528
+
529
+ const piTree = textOf(await paint(piPanel, REACT.createElement(piPanel, {
530
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
531
+ configured: true
532
+ }), new Map()));
533
+ assert(piTree.includes("Agnes 3.0 Flash"), "the pi-ai card shows its own models");
534
+
535
+ const dsTree = textOf(await paint(dsPanel, REACT.createElement(dsPanel, {
536
+ provider: { provider: "deepseek-official", settingsNs: "llm-deepseek" },
537
+ configured: true
538
+ }), new Map()));
539
+ assert(dsTree.includes("Vision Exp"), "the deepseek card shows its own models");
540
+ assert(!dsTree.includes("Agnes 3.0 Flash"), "cards must not leak each other's models");
541
+ } finally {
542
+ fetchLog.restore();
543
+ }
544
+ });
545
+
546
+ await test("a catalog-declared model is checked, disabled, and explained", async () => {
547
+ const fetchLog = installFetch((body) => replyFor(body, { list: INVENTORY }));
548
+ try {
549
+ const harness = loadClient();
550
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-deepseek").Component;
551
+ const tree = await paint(panel, REACT.createElement(panel, {
552
+ provider: { provider: "deepseek-official", settingsNs: "llm-deepseek" },
553
+ configured: true
554
+ }), new Map());
555
+
556
+ const box = collect(tree, (node) => node.type === "input" && node.props.type === "checkbox")[0];
557
+ assert(box !== undefined, "a checkbox is rendered");
558
+ assertEqual(box.props.checked, true, "the catalog declaration shows as checked");
559
+ assertEqual(box.props.disabled, true, "the catalog declaration is locked");
560
+ assert(textOf(tree).includes("该模型由官方目录声明"), "the lock reason is stated");
561
+ } finally {
562
+ fetchLog.restore();
563
+ }
564
+ });
565
+
566
+ await test("toggling a model writes the declaration and shows a success mark", async () => {
567
+ const scripted = {
568
+ list: INVENTORY,
569
+ set: { ok: true, namespace: "llm-pi-ai", provider: "agnes", modelId: "agnes-3.0-flash", requested: true, declared: ["text", "image"], verified: true }
570
+ };
571
+ const fetchLog = installFetch((body) => replyFor(body, scripted));
572
+ try {
573
+ const harness = loadClient({ localStorage: makeStorage("1") });
574
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
575
+ const instances = new Map();
576
+
577
+ // With the one-time acknowledgement already stored, the toggle writes
578
+ // directly; the confirmation dialog itself is covered separately.
579
+ let tree = await paint(panel, REACT.createElement(panel, {
580
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
581
+ configured: true
582
+ }), instances);
583
+
584
+ const box = findCheckbox(tree, "agnes-3.0-flash");
585
+ assert(box !== undefined, "the model's checkbox exists");
586
+ assertEqual(box.props.checked, false, "it starts unchecked");
587
+
588
+ box.props.onChange({ target: { checked: true } });
589
+ await new Promise((resolve) => process.nextTick(resolve));
590
+ await new Promise((resolve) => setTimeout(resolve, 0));
591
+
592
+ const setCall = fetchLog.calls.find((entry) => entry.body.method === "set");
593
+ assert(setCall !== undefined, "the set method must be called");
594
+ assertEqual(setCall.body.namespace, "llm-pi-ai", "namespace");
595
+ assertEqual(setCall.body.provider, "agnes", "provider is the card's route");
596
+ assertEqual(setCall.body.modelId, "agnes-3.0-flash", "model id");
597
+ assertEqual(setCall.body.supportsImage, true, "the requested value");
598
+
599
+ // Re-render and assert the immediate visual outcome.
600
+ tree = await paint(panel, REACT.createElement(panel, {
601
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
602
+ configured: true
603
+ }), instances);
604
+ const text = textOf(tree);
605
+ assert(text.includes("已保存"), `a success mark is shown: ${text.slice(0, 200)}`);
606
+ } finally {
607
+ fetchLog.restore();
608
+ }
609
+ });
610
+
611
+ await test("a failed write rolls the checkbox back and shows a red error", async () => {
612
+ const scripted = {
613
+ list: INVENTORY,
614
+ set: { ok: false, code: "provider-missing", message: "provider \"agnes\" 不存在于 llm-pi-ai 配置中。" }
615
+ };
616
+ const fetchLog = installFetch((body) => replyFor(body, scripted));
617
+ try {
618
+ const harness = loadClient({ localStorage: makeStorage("1") });
619
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
620
+ const instances = new Map();
621
+
622
+ let tree = await paint(panel, REACT.createElement(panel, {
623
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
624
+ configured: true
625
+ }), instances);
626
+
627
+ const box = findCheckbox(tree, "agnes-3.0-flash");
628
+ box.props.onChange({ target: { checked: true } });
629
+ await new Promise((resolve) => process.nextTick(resolve));
630
+ await new Promise((resolve) => setTimeout(resolve, 0));
631
+
632
+ tree = await paint(panel, REACT.createElement(panel, {
633
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
634
+ configured: true
635
+ }), instances);
636
+
637
+ const after = findCheckbox(tree, "agnes-3.0-flash");
638
+ assertEqual(after.props.checked, false, "the checkbox must roll back to its pre-action state");
639
+ const text = textOf(tree);
640
+ assert(text.includes("不存在"), `the host''s message must be shown: ${text.slice(0, 240)}`);
641
+ } finally {
642
+ fetchLog.restore();
643
+ }
644
+ });
645
+
646
+ await test("the first enable is gated by the confirmation dialog, and can be cancelled", async () => {
647
+ const scripted = {
648
+ list: INVENTORY,
649
+ set: { ok: true, declared: ["text", "image"], verified: true }
650
+ };
651
+ const fetchLog = installFetch((body) => replyFor(body, scripted));
652
+ try {
653
+ // No localStorage at all: the bundle must still render and must prompt.
654
+ const harness = loadClient();
655
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
656
+ const instances = new Map();
657
+ let tree = await paint(panel, REACT.createElement(panel, {
658
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
659
+ configured: true
660
+ }), instances);
661
+
662
+ findCheckbox(tree, "agnes-3.0-flash").props.onChange({ target: { checked: true } });
663
+ tree = await paint(panel, REACT.createElement(panel, {
664
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
665
+ configured: true
666
+ }), instances);
667
+
668
+ const text = textOf(tree);
669
+ assert(text.includes("DSH 将不再拦截"), `the warning must be shown verbatim: ${text.slice(0, 300)}`);
670
+ assert(text.includes("400"), "the warning must name the failure mode");
671
+
672
+ // The write must NOT have happened yet.
673
+ assert(fetchLog.calls.every((entry) => entry.body.method !== "set"), "no write before confirmation");
674
+
675
+ // Cancel, then confirm.
676
+ const dialog = collect(tree, (node) => node.props && node.props.role === "alertdialog")[0];
677
+ assert(dialog !== undefined, "the dialog is present");
678
+ const buttons = collect(tree, (node) => node.type === "button");
679
+ const cancel = buttons.find((node) => textOf(node).includes("取消"));
680
+ const confirm = buttons.find((node) => textOf(node).includes("确认开启"));
681
+ assert(cancel !== undefined, "a cancel button exists");
682
+ assert(confirm !== undefined, "a confirm button exists");
683
+
684
+ cancel.props.onClick();
685
+ tree = await paint(panel, REACT.createElement(panel, {
686
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
687
+ configured: true
688
+ }), instances);
689
+ assert(!textOf(tree).includes("DSH 将不再拦截"), "cancelling dismisses the dialog");
690
+ assert(fetchLog.calls.every((entry) => entry.body.method !== "set"), "cancelling performs no write");
691
+ } finally {
692
+ fetchLog.restore();
693
+ }
694
+ });
695
+
696
+ await test("confirming the dialog performs the write once and remembers it", async () => {
697
+ const scripted = {
698
+ list: INVENTORY,
699
+ set: { ok: true, declared: ["text", "image"], verified: true }
700
+ };
701
+ const fetchLog = installFetch((body) => replyFor(body, scripted));
702
+ try {
703
+ const storage = makeStorage();
704
+ const harness = loadClient({ localStorage: storage });
705
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
706
+ const instances = new Map();
707
+ let tree = await paint(panel, REACT.createElement(panel, {
708
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
709
+ configured: true
710
+ }), instances);
711
+
712
+ findCheckbox(tree, "agnes-3.0-flash").props.onChange({ target: { checked: true } });
713
+ tree = await paint(panel, REACT.createElement(panel, {
714
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
715
+ configured: true
716
+ }), instances);
717
+
718
+ const confirm = collect(tree, (node) => node.type === "button" && textOf(node).includes("确认开启"))[0];
719
+ confirm.props.onClick();
720
+ await new Promise((resolve) => process.nextTick(resolve));
721
+ await new Promise((resolve) => setTimeout(resolve, 0));
722
+
723
+ const setCalls = fetchLog.calls.filter((entry) => entry.body.method === "set");
724
+ assertEqual(setCalls.length, 1, "exactly one write after confirming");
725
+ assertEqual(storage.map.get("dsh-model-input-toggle:acknowledged"), "1", "the acknowledgement is persisted");
726
+ } finally {
727
+ fetchLog.restore();
728
+ }
729
+ });
730
+
731
+ await test("disabling never prompts, even without an acknowledgement", async () => {
732
+ const starting = JSON.parse(JSON.stringify(INVENTORY));
733
+ starting.providers[0].models[0].effective = ["text", "image"];
734
+ const scripted = {
735
+ list: starting,
736
+ set: { ok: true, declared: ["text"], verified: true }
737
+ };
738
+ const fetchLog = installFetch((body) => replyFor(body, scripted));
739
+ try {
740
+ const harness = loadClient();
741
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
742
+ const instances = new Map();
743
+ let tree = await paint(panel, REACT.createElement(panel, {
744
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
745
+ configured: true
746
+ }), instances);
747
+
748
+ const box = findCheckbox(tree, "agnes-3.0-flash");
749
+ assertEqual(box.props.checked, true, "starts checked");
750
+ box.props.onChange({ target: { checked: false } });
751
+ await new Promise((resolve) => process.nextTick(resolve));
752
+ await new Promise((resolve) => setTimeout(resolve, 0));
753
+
754
+ tree = await paint(panel, REACT.createElement(panel, {
755
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
756
+ configured: true
757
+ }), instances);
758
+ assert(!textOf(tree).includes("DSH 将不再拦截"), "no confirmation for a disable");
759
+ const setCall = fetchLog.calls.find((entry) => entry.body.method === "set");
760
+ assert(setCall !== undefined, "the disabling write happened");
761
+ assertEqual(setCall.body.supportsImage, false, "the requested value");
762
+ } finally {
763
+ fetchLog.restore();
764
+ }
765
+ });
766
+
767
+ await test("a host timeout surfaces as a connection error and rolls back", async () => {
768
+ // fetch never settles for `set`; the bundle's own 10s timer must fire.
769
+ const previousFetch = globalThis.fetch;
770
+ const previousSetTimeoutGlobal = globalThis.setTimeout;
771
+ let capturedTimer = null;
772
+ globalThis.fetch = (url, init) => {
773
+ const body = JSON.parse(init.body);
774
+ if (body.method === "list") {
775
+ return Promise.resolve({ status: 200, ok: true, json: () => Promise.resolve(INVENTORY) });
776
+ }
777
+ return new Promise(() => void 0); // set: hangs forever
778
+ };
779
+ // Hand the bundle a timer we can fire deterministically. It must be passed
780
+ // through loadClient, because that is what the bundle actually captures.
781
+ const controllableSetTimeout = (fn, ms) => {
782
+ if (ms === 10000) capturedTimer = fn;
783
+ return { fn, ms };
784
+ };
785
+ try {
786
+ const harness = loadClient({ localStorage: makeStorage("1"), setTimeout: controllableSetTimeout });
787
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
788
+ const instances = new Map();
789
+ let tree = await paint(panel, REACT.createElement(panel, {
790
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
791
+ configured: true
792
+ }), instances);
793
+
794
+ findCheckbox(tree, "agnes-3.0-flash").props.onChange({ target: { checked: true } });
795
+ assert(capturedTimer !== null, "the bundle must arm a 10s timeout for the write");
796
+ capturedTimer();
797
+ await new Promise((resolve) => process.nextTick(resolve));
798
+ await new Promise((resolve) => process.nextTick(resolve));
799
+
800
+ tree = await paint(panel, REACT.createElement(panel, {
801
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
802
+ configured: true
803
+ }), instances);
804
+ const text = textOf(tree);
805
+ assert(text.includes("连接超时"), `the timeout message must be shown: ${text.slice(0, 240)}`);
806
+ assertEqual(findCheckbox(tree, "agnes-3.0-flash").props.checked, false, "rollback after timeout");
807
+ } finally {
808
+ globalThis.fetch = previousFetch;
809
+ globalThis.setTimeout = previousSetTimeoutGlobal;
810
+ }
811
+ });
812
+
813
+ await test("the 验证端点 button reports both a supported and a rejected verdict", async () => {
814
+ for (const scenario of [
815
+ { verdict: "supported", ok: true, message: "端点接受了带图片的请求(HTTP 200)。", expect: "✓" },
816
+ { verdict: "rejected", ok: true, message: "端点拒绝了图片输入(HTTP 400)。", expect: "✕" }
817
+ ]) {
818
+ const scripted = {
819
+ list: INVENTORY,
820
+ verify: { ok: scenario.ok, status: 200, verdict: scenario.verdict, message: scenario.message }
821
+ };
822
+ const fetchLog = installFetch((body) => replyFor(body, scripted));
823
+ try {
824
+ const harness = loadClient();
825
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
826
+ const instances = new Map();
827
+ let tree = await paint(panel, REACT.createElement(panel, {
828
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
829
+ configured: true
830
+ }), instances);
831
+ REACT.__flushEffects();
832
+
833
+ const verifyButton = collect(tree, (node) => node.type === "button" && textOf(node).includes("验证端点"))[0];
834
+ assert(verifyButton !== undefined, "the verify button exists");
835
+ verifyButton.props.onClick();
836
+ await new Promise((resolve) => process.nextTick(resolve));
837
+ await new Promise((resolve) => setTimeout(resolve, 0));
838
+
839
+ tree = await paint(panel, REACT.createElement(panel, {
840
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
841
+ configured: true
842
+ }), instances);
843
+
844
+ const verifyCall = fetchLog.calls.find((entry) => entry.body.method === "verify");
845
+ assert(verifyCall !== undefined, "the verify method must be called");
846
+ assertEqual(verifyCall.body.provider, "agnes", "the verify target is the card's route");
847
+ const text = textOf(tree);
848
+ assert(text.includes(scenario.expect), `the ${scenario.verdict} verdict must be visible: ${text.slice(0, 240)}`);
849
+ assert(text.includes(scenario.message), "the host's message must be shown");
850
+ } finally {
851
+ fetchLog.restore();
852
+ }
853
+ }
854
+ });
855
+
856
+ await test("every rendered checkbox carries the required tooltip", async () => {
857
+ const fetchLog = installFetch((body) => replyFor(body, { list: INVENTORY }));
858
+ try {
859
+ const harness = loadClient();
860
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
861
+ const tree = await paint(panel, REACT.createElement(panel, {
862
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
863
+ configured: true
864
+ }, new Map()));
865
+ const labels = collect(tree, (node) => node.type === "label");
866
+ assertEqual(labels.length, 2, "one label per model");
867
+ for (const label of labels) {
868
+ assert(String(label.props.title).includes("勾选后该模型可接收图片消息"), `tooltip missing: ${label.props.title}`);
869
+ assert(String(label.props.title).includes("验证端点实际能力"), "tooltip must point at verification");
870
+ }
871
+ } finally {
872
+ fetchLog.restore();
873
+ }
874
+ });
875
+
876
+ await test("an inventory failure is reported with a retry action", async () => {
877
+ const fetchLog = installFetch((body) => replyFor(body, {
878
+ list: { ok: false, code: "unreadable", message: "无法读取模型配置。" }
879
+ }));
880
+ try {
881
+ const harness = loadClient();
882
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
883
+ const tree = await paint(panel, REACT.createElement(panel, {
884
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
885
+ configured: true
886
+ }, new Map()));
887
+ const text = textOf(tree);
888
+ assert(text.includes("无法读取模型配置"), `the error is shown: ${text.slice(0, 200)}`);
889
+ assert(collect(tree, (node) => node.type === "button" && textOf(node).includes("重试")).length === 1, "a retry button exists");
890
+ } finally {
891
+ fetchLog.restore();
892
+ }
893
+ });
894
+
895
+ await test("the panel is collapsed by default and expands on demand", async () => {
896
+ const fetchLog = installFetch((body) => replyFor(body, { list: INVENTORY }));
897
+ try {
898
+ const harness = loadClient();
899
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
900
+ const instances = new Map();
901
+ const el = REACT.createElement(panel, {
902
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
903
+ configured: true,
904
+ keyConfigured: true
905
+ });
906
+
907
+ // Default view: one line, no model rows, no checkboxes.
908
+ const collapsed = await paintCollapsed(panel, el, instances);
909
+ assert(textOf(collapsed).includes("图片输入"), "the section is still discoverable when collapsed");
910
+ assertEqual(collect(collapsed, (n) => n.type === "input" && n.props.type === "checkbox").length, 0,
911
+ "no checkboxes are rendered while collapsed");
912
+ assert(!textOf(collapsed).includes("Agnes 3.0 Flash"), "model rows are hidden while collapsed");
913
+
914
+ // The collapsed summary still reports the current declaration state, so a
915
+ // user can tell at a glance whether anything is enabled.
916
+ assert(textOf(collapsed).includes("1/2 已开启"), "the summary reports how many models accept images");
917
+
918
+ const toggle = findToggle(collapsed);
919
+ assert(toggle !== undefined, "the toggle exists");
920
+ assertEqual(toggle.props["aria-expanded"], "false", "it starts collapsed");
921
+
922
+ // Expanding reveals the rows through the real handler.
923
+ toggle.props.onClick();
924
+ const open = await paintCollapsed(panel, el, instances);
925
+ assertEqual(findToggle(open).props["aria-expanded"], "true", "it reports expanded");
926
+ assert(textOf(open).includes("Agnes 3.0 Flash"), "expanding reveals the model rows");
927
+ assertEqual(collect(open, (n) => n.type === "input" && n.props.type === "checkbox").length, 2,
928
+ "expanding reveals one checkbox per model");
929
+ } finally {
930
+ fetchLog.restore();
931
+ }
932
+ });
933
+
934
+ await test("a route with nothing enabled summarises as text-only", async () => {
935
+ const textOnly = JSON.parse(JSON.stringify(INVENTORY));
936
+ textOnly.providers[0].models.forEach((model) => {
937
+ model.effective = ["text"];
938
+ });
939
+ const fetchLog = installFetch((body) => replyFor(body, { list: textOnly }));
940
+ try {
941
+ const harness = loadClient();
942
+ const panel = harness.registered.find((entry) => entry.contract.key === "llm-pi-ai").Component;
943
+ const tree = await paintCollapsed(panel, REACT.createElement(panel, {
944
+ provider: { provider: "agnes", settingsNs: "llm-pi-ai" },
945
+ configured: true
946
+ }), new Map());
947
+ assert(textOf(tree).includes("全部仅文本"), `the summary must say nothing is enabled: ${textOf(tree)}`);
948
+ } finally {
949
+ fetchLog.restore();
950
+ }
951
+ });
952
+
953
+ process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
954
+ process.exit(failed === 0 ? 0 : 1);