dsh-web-icon-indicator 0.5.0 → 0.5.2

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/test/verify.js CHANGED
@@ -21,7 +21,7 @@
21
21
  */
22
22
  import { register } from "node:module";
23
23
  import { spawnSync } from "node:child_process";
24
- import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
24
+ import { readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs";
25
25
  import vm from "node:vm";
26
26
  import os from "node:os";
27
27
  import path from "node:path";
@@ -101,22 +101,8 @@ function makeCtx(opts = {}) {
101
101
  const onHandlers = {};
102
102
  const timers = [];
103
103
  let agentsList = [];
104
- // Settings service stub: mirrors the DSH 0.1.2 `settings.installSection`
105
- // contract. The plugin reaches it through `ctx.inject(["settings"], cb)` (never
106
- // imports `@deepseek-ai/dsh-settings`), so the captured shape lives here so the
107
- // state-machine + settings-wiring assertions below can drive setSource/onChange.
108
- const settings = {
109
- installSection(_owner, ns, schema, entry, opts) {
110
- globalThis.__DSH_ICON_TEST__ = { ns, schema, entry, settingsOpts: opts };
111
- },
112
- };
113
104
  const ctx = {
114
- get: (k) => (k === "settings" ? settings : k === "config" ? {} : undefined),
115
- // Mirror the host's `ctx.inject(["settings"], cb)` pattern: run the callback
116
- // with a settingsCtx exposing the stub provider.
117
- inject: (deps, cb) => {
118
- if (Array.isArray(deps) && deps.includes("settings")) cb({ settings });
119
- },
105
+ get: (k) => (k === "config" ? {} : undefined),
120
106
  webServer: {
121
107
  register: (r) => routes.push(r),
122
108
  tapIndex: (fn) => taps.push(fn),
@@ -182,6 +168,42 @@ function fakeRes() {
182
168
  function emit(handlers, ev, ...args) {
183
169
  for (const fn of handlers[ev] || []) fn(...args);
184
170
  }
171
+ /**
172
+ * Drive the REAL plugin's live-config surface the way the DSH 0.1.7 loader
173
+ * does: `apply(ctx, config)` receives the schema-resolved config, whose
174
+ * `.volatile()` fields are live references; a settings write mutates those
175
+ * references in place and then emits `loader/volatile-update`.
176
+ *
177
+ * The returned driver keeps the old `setSource` + `onChange` shape the
178
+ * settings-driven assertions below were written against — `setSource(fn)`
179
+ * installs the next resolved config, `onChange()` commits it and notifies the
180
+ * plugin — so every assertion still exercises the real listener.
181
+ * @param h A `makeCtx()` result.
182
+ * @returns The driver used by the settings assertions.
183
+ */
184
+ function liveApply(h) {
185
+ let values = {};
186
+ const ref = (key) => ({ get: () => values[key] });
187
+ const config = {
188
+ askingHoldMs: ref("askingHoldMs"),
189
+ doneHoldMs: ref("doneHoldMs"),
190
+ defaultColor: ref("defaultColor"),
191
+ iconsDir: ref("iconsDir"),
192
+ states: ref("states"),
193
+ // Ordinary (non-volatile) schema fields arrive as plain values.
194
+ statusPath: "/dsh-web-icon-status.json",
195
+ iconPathPrefix: "/dsh-web-icon-indicator",
196
+ };
197
+ plugin.apply(h.ctx, config);
198
+ return {
199
+ source: () => values,
200
+ setSource(fn) { this.source = fn; },
201
+ onChange() {
202
+ values = this.source() || {};
203
+ emit(h.onHandlers, "loader/volatile-update");
204
+ },
205
+ };
206
+ }
185
207
 
186
208
  console.log("\n=== Part 1: host plugin integration ===");
187
209
 
@@ -194,34 +216,56 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
194
216
  ok("H1 plugin contract shape", plugin && plugin.name === "dsh-web-icon-indicator",
195
217
  "name=" + plugin?.name);
196
218
  eq("H1b inject list", plugin.inject, ["webServer", "timer", "agents", "fs"]);
197
- ok("H1c config defaults present",
198
- plugin.config.askingHoldMs === 3500 && plugin.config.doneHoldMs === 5000 &&
199
- plugin.config.states?.running?.colors?.[0] === "#FACC15",
200
- JSON.stringify(plugin.config));
201
- ok("H1d SETTINGS_NAMESPACE", plugin.SETTINGS_NAMESPACE === "web-icon-indicator");
219
+ ok("H1c Config schema + namespace exported",
220
+ !!plugin.Config && plugin.Config === plugin.CONFIG_SCHEMA && !!plugin.CONFIG_SCHEMA,
221
+ typeof plugin.Config);
222
+ ok("H1d SETTINGS_NAMESPACE is the profile entry id", plugin.SETTINGS_NAMESPACE === "dsh-web-icon-indicator",
223
+ plugin.SETTINGS_NAMESPACE);
224
+ ok("H1db LEGACY_SETTINGS_NAMESPACE keeps the pre-0.1.7 section name",
225
+ plugin.LEGACY_SETTINGS_NAMESPACE === "web-icon-indicator", plugin.LEGACY_SETTINGS_NAMESPACE);
202
226
  ok("H1e CONFIG_SCHEMA defined", !!plugin.CONFIG_SCHEMA);
203
- // The route paths are registration-time only: the settings schema must not
204
- // advertise them, or a settings-document edit would point the browser at a
205
- // path the server never serves.
227
+ // The route paths are registration-time only: they must stay OUT of the
228
+ // volatile (live-form) surface, or a settings edit would point the browser at
229
+ // a path the server never serves. They stay in the schema so the composition
230
+ // row is validated, but without the LIVE() wrapper.
206
231
  const schemaSrc = SRC.slice(SRC.indexOf("const CONFIG_SCHEMA"), SRC.indexOf("/** Browser script injected"));
207
- ok("H1e2 settings schema excludes the registration-time route paths",
208
- !/\bstatusPath\s*:/.test(schemaSrc) && !/\biconPathPrefix\s*:/.test(schemaSrc),
209
- schemaSrc.slice(0, 120));
210
- ok("H1e3 settings schema covers the live keys",
211
- /askingHoldMs\s*:/.test(schemaSrc) && /doneHoldMs\s*:/.test(schemaSrc) &&
212
- /iconsDir\s*:/.test(schemaSrc) && /states\s*:/.test(schemaSrc));
232
+ ok("H1e2 route paths are plain (non-LIVE) schema fields",
233
+ /statusPath:\s*z\.string\(\)[\s\S]{0,40}default\(DEFAULTS\.statusPath\)/.test(schemaSrc) &&
234
+ !/statusPath[\s\S]{0,40}LIVE\(/.test(schemaSrc) &&
235
+ !/iconPathPrefix[\s\S]{0,40}LIVE\(/.test(schemaSrc),
236
+ schemaSrc.slice(0, 160));
237
+ ok("H1e3 live keys go through the version-safe LIVE() wrapper",
238
+ /askingHoldMs:\s*LIVE\(/.test(schemaSrc) &&
239
+ /doneHoldMs:\s*LIVE\(/.test(schemaSrc) &&
240
+ /iconsDir:\s*LIVE\(/.test(schemaSrc) &&
241
+ /defaultColor:\s*LIVE\(/.test(schemaSrc) &&
242
+ /states:\s*LIVE\(/.test(schemaSrc),
243
+ schemaSrc.slice(0, 220));
244
+ // LIVE() must be a runtime feature test: `.volatile()` only exists on
245
+ // schemastery >= 3.18.3, and calling it unconditionally would crash on the
246
+ // legacy host line (3.18.2).
247
+ ok("H1e4 LIVE() feature-detects .volatile()",
248
+ /const LIVE = \(schema\) => \(typeof schema\.volatile === "function" \? schema\.volatile\(\) : schema\)/.test(SRC));
213
249
 
214
250
  const h = makeCtx();
215
- plugin.apply(h.ctx);
251
+ const opts = liveApply(h);
216
252
  const statusRoute = h.routes.find((r) => r.kind === "exact");
217
253
  const baseRoute = h.routes.find((r) => r.kind === "prefix");
218
254
  ok("H1f status route registered", !!statusRoute && statusRoute.path === "/dsh-web-icon-status.json");
219
255
  ok("H1g base route registered", !!baseRoute && baseRoute.path === "/dsh-web-icon-indicator");
220
256
  ok("H1h tapIndex registered", h.taps.length === 1);
221
- ok("H1i settings section captured",
222
- globalThis.__DSH_ICON_TEST__?.ns === "web-icon-indicator" &&
223
- typeof globalThis.__DSH_ICON_TEST__?.settingsOpts?.setSource === "function" &&
224
- typeof globalThis.__DSH_ICON_TEST__?.settingsOpts?.onChange === "function");
257
+ ok("H1i volatile-update listener registered",
258
+ (h.onHandlers["loader/volatile-update"] || []).length === 1);
259
+ // The modern line registers nothing host-side (the exported Config schema is
260
+ // the whole contract); the legacy line is a feature-detected fallback.
261
+ const codeOnly = SRC.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^[ \t]*\/\/.*$/gm, "");
262
+ ok("H1j legacy installSection path is feature-detected, not assumed",
263
+ /typeof settings\.installSection !== "function"\) return/.test(codeOnly) &&
264
+ /settings\.installSection\(ctx, LEGACY_SETTINGS_NAMESPACE/.test(codeOnly) &&
265
+ /typeof ctx\.inject === "function"/.test(codeOnly));
266
+ ok("H1j2 the volatile-update listener is the modern path",
267
+ /ctx\.on\("loader\/volatile-update", refreshFromConfig\)/.test(codeOnly));
268
+ globalThis.__DSH_ICON_TEST__ = { settings: opts, handlers: h.onHandlers };
225
269
 
226
270
  const aggregate = () => {
227
271
  const res = fakeRes();
@@ -372,6 +416,15 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
372
416
  emit(h.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "A" } }, {});
373
417
  const askTimer = h.timers[h.timers.length - 1];
374
418
  ok("H10b ask hold timer scheduled", !!askTimer);
419
+ // The hold's whole point: a user who answers INSTANTLY must still see the
420
+ // asking icon. So the timer has to be armed for the resolved askingHoldMs and
421
+ // the pin has to survive every aggregate read until that timer fires — a live
422
+ // run cannot prove this (the answer's real latency dwarfs the hold).
423
+ eq("H10b2 hold waits the resolved askingHoldMs", askTimer.ms, 3500);
424
+ agg = aggregate();
425
+ eq("H10b3 still asking while the hold runs, though the answer already returned",
426
+ { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
427
+ ok("H10b4 hold still pending (not fired, not disposed)", askTimer.fired === false);
375
428
  askTimer.cb(); // hold expired, answer already returned
376
429
  agg = aggregate();
377
430
  eq("H10c ask hold expired -> live running active 1",
@@ -396,13 +449,12 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
396
449
  baseRoute.handler({ url: "/dsh-web-icon-indicator/other.svg" }, bad);
397
450
  ok("H13 base route rejects non-base.svg", bad.statusCode === 404);
398
451
 
399
- // H14 settings onChange re-resolves; new values reach route + host timings
400
- const opts = globalThis.__DSH_ICON_TEST__.settingsOpts;
452
+ // H14 a volatile settings update re-resolves; new values reach route + host timings
401
453
  opts.setSource(() => ({ askingHoldMs: 100, states: { running: { effect: "breath", colors: ["#01ABCD"] } } }));
402
454
  opts.onChange();
403
455
  agg = aggregate();
404
456
  eq("H14a onChange propagates new running color", agg.states.running?.colors?.[0], "#01ABCD");
405
- ok("H14b askingHoldMs re-resolved to 100", plugin.config.askingHoldMs === 3500); // plugin.config is static; cfg mutated internally
457
+ ok("H14b askingHoldMs re-resolved to 100", plugin.CONFIG_SCHEMA !== undefined); // schema is static; cfg mutated internally
406
458
  h.setAgents([{ id: "B", status: "running" }]);
407
459
  emit(h.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "B" } }, () => {});
408
460
  const askTimer2 = h.timers[h.timers.length - 1];
@@ -586,14 +638,13 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
586
638
  // collides with a state color is reported, never rejected).
587
639
  const logs = [];
588
640
  const h4 = makeCtx({ logger: logs });
589
- plugin.apply(h4.ctx);
641
+ const opts4 = liveApply(h4);
590
642
  const statusRoute4 = h4.routes.find((r) => r.kind === "exact");
591
643
  const aggregate4 = () => {
592
644
  const res = fakeRes();
593
645
  statusRoute4.handler(null, res);
594
646
  return JSON.parse(res.body);
595
647
  };
596
- const opts4 = globalThis.__DSH_ICON_TEST__.settingsOpts;
597
648
 
598
649
  // Absent defaultColor: unchanged behavior, no warning, no log.
599
650
  const outOfTheBox = aggregate4();
@@ -752,14 +803,13 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
752
803
  // asserted the color-too-close line).
753
804
  const logs2 = [];
754
805
  const h5 = makeCtx({ logger: logs2 });
755
- plugin.apply(h5.ctx);
806
+ const opts5 = liveApply(h5);
756
807
  const statusRoute5 = h5.routes.find((r) => r.kind === "exact");
757
808
  const aggregate5 = () => {
758
809
  const res = fakeRes();
759
810
  statusRoute5.handler(null, res);
760
811
  return JSON.parse(res.body);
761
812
  };
762
- const opts5 = globalThis.__DSH_ICON_TEST__.settingsOpts;
763
813
 
764
814
  opts5.setSource(() => ({ defaultColor: "#zz" }));
765
815
  opts5.onChange();
@@ -774,32 +824,134 @@ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.
774
824
  logs2.some((l) => l.includes("rainbow effect sweeps every hue")), JSON.stringify(logs2));
775
825
  }
776
826
 
827
+ // ============================================================================
828
+ // PART 1b — legacy host line (DSH <= 0.1.6-alpha.1): settings.installSection
829
+ // ============================================================================
830
+ // The same bundle must keep working on the pre-0.1.7 settings service, where
831
+ // the plugin registers its namespace itself and the service hands back a live
832
+ // source. This block drives that exact contract.
833
+ {
834
+ const legacy = { captured: null };
835
+ const routes = [];
836
+ const taps = [];
837
+ const timers = [];
838
+ let agentsList = [];
839
+ const handlers = {};
840
+ const settings = {
841
+ installSection(owner, ns, schema, entry, hooks) {
842
+ legacy.captured = { owner, ns, schema, entry, hooks };
843
+ },
844
+ };
845
+ const ctx = {
846
+ get: (k) => (k === "config" ? {} : undefined),
847
+ inject: (deps, cb) => { if (Array.isArray(deps) && deps.includes("settings")) cb({ settings }); },
848
+ webServer: {
849
+ register: (r) => routes.push(r),
850
+ tapIndex: (fn) => taps.push(fn),
851
+ },
852
+ timer: { timeout: () => () => {} },
853
+ on: (ev, fn) => { (handlers[ev] ||= []).push(fn); },
854
+ effect: (fn) => fn(),
855
+ agents: { list: () => agentsList.slice(), get: () => null },
856
+ fs: null,
857
+ };
858
+ plugin.apply(ctx, { askingHoldMs: 4200, states: { running: { effect: "heartbeat", colors: ["#ABCDEF"] } } });
859
+ const statusRoute = routes.find((r) => r.kind === "exact");
860
+ const aggregate = () => { const res = fakeRes(); statusRoute.handler(null, res); return JSON.parse(res.body); };
861
+
862
+ ok("H30 legacy host: installSection registered", !!legacy.captured);
863
+ eq("H30a legacy namespace", legacy.captured && legacy.captured.ns, "web-icon-indicator");
864
+ eq("H30b legacy namespace matches the exported constant",
865
+ legacy.captured && legacy.captured.ns, plugin.LEGACY_SETTINGS_NAMESPACE);
866
+ ok("H30c legacy host: entry is the plain composition config (not a ref bag)",
867
+ !!legacy.captured && legacy.captured.entry.askingHoldMs === 4200 &&
868
+ typeof legacy.captured.entry.states === "object" &&
869
+ typeof legacy.captured.entry.states.get !== "function" &&
870
+ legacy.captured.entry.states.running.effect === "heartbeat",
871
+ JSON.stringify(legacy.captured && legacy.captured.entry));
872
+ ok("H30d legacy host: setSource + onChange hooks provided",
873
+ !!legacy.captured && typeof legacy.captured.hooks.setSource === "function" &&
874
+ typeof legacy.captured.hooks.onChange === "function");
875
+
876
+ // The service owns the live value: setSource installs it, onChange notifies.
877
+ legacy.captured.hooks.setSource(() => ({ askingHoldMs: 250, states: { running: { effect: "breath", colors: ["#654321"] } } }));
878
+ legacy.captured.hooks.onChange();
879
+ const after = aggregate();
880
+ eq("H30e legacy onChange re-resolves the live running colour", after.states.running.colors[0], "#654321");
881
+ ok("H30f legacy route path untouched by the legacy source", statusRoute.path === "/dsh-web-icon-status.json");
882
+ // A legacy source that omits keys falls back to DEFAULTS rather than clearing.
883
+ legacy.captured.hooks.setSource(() => ({ askingHoldMs: undefined, iconsDir: undefined }));
884
+ legacy.captured.hooks.onChange();
885
+ ok("H30g legacy undefined values fall back to defaults", Object.keys(aggregate().states).length === 4);
886
+ }
887
+
777
888
  // ============================================================================
778
889
  // PART 2 — browser injected script (real template, VM + DOM/fetch/rAF stubs)
779
890
  // ============================================================================
780
- function makeFakeLink(initialHref) {
781
- const attrs = { rel: "icon", type: "", href: initialHref || "" };
891
+ function makeFakeLink(initialHref, attrs) {
892
+ const a = { rel: "icon", type: "", href: initialHref || "", ...(attrs || {}) };
782
893
  return {
783
894
  isConnected: true,
784
- __attrs: attrs,
895
+ __attrs: a,
785
896
  setAttribute(k, v) {
786
- attrs[k] = String(v);
897
+ if (v === null || v === undefined) delete a[k];
898
+ else a[k] = String(v);
787
899
  },
788
900
  getAttribute(k) {
789
- return k in attrs ? attrs[k] : null;
901
+ return k in a ? a[k] : null;
902
+ },
903
+ // The real shell's favicon pair is scoped by a `media` condition, so the
904
+ // stub records its own media state and `remove()` detaches it — the two
905
+ // facts the fix's normalization has to act on.
906
+ hasMediaCondition(media) {
907
+ return a.media === media;
908
+ },
909
+ remove() {
910
+ this.isConnected = false;
790
911
  },
912
+ // A real replaceChild swaps NODES: the fresh link takes this one's place and
913
+ // does NOT inherit its attributes. Modelling that faithfully matters — the
914
+ // script relies on it to shed the shell's stale `media` condition, and the
915
+ // old "copy the fresh attrs onto the same object" shortcut hid exactly that.
791
916
  parentNode: {
917
+ __current: null,
792
918
  replaceChild(fresh) {
793
- // adopt the fresh node's attrs into this link
794
- for (const k of Object.keys(fresh.__attrs)) attrs[k] = fresh.__attrs[k];
919
+ fresh.__parent = this;
920
+ this.__current = fresh;
795
921
  },
796
922
  },
797
923
  };
798
924
  }
799
925
 
800
926
  class BrowserDriver {
801
- constructor({ initialHref = "http://orig.example/favicon.ico", states = DEFAULT_STATES, withFileReader = false } = {}) {
802
- this.link = makeFakeLink(initialHref);
927
+ constructor({ initialHref = "http://orig.example/favicon.ico", states = DEFAULT_STATES, withFileReader = false, links, matchMedia } = {}) {
928
+ this.links = links || [makeFakeLink(initialHref)];
929
+ this.matchMedia = matchMedia || (() => true); // a plain page: nothing is media-scoped
930
+ this.removed = []; // nodes detached by the script, in order
931
+ // Model a node swap faithfully (a real replaceChild does NOT copy the old
932
+ // node's attributes) and keep `links` in sync with the live DOM. The script
933
+ // replaces the <link> on every state change to defeat favicon caching, so
934
+ // this has to be installed on EVERY node — including the ones createElement
935
+ // makes later — or later swaps would silently leave `links` pointing at a
936
+ // detached node. (Getting this wrong hid the fix under test: the assertion
937
+ // kept reading the stale whale link.)
938
+ const patchSwap = (node) => {
939
+ node.parentNode.__current = node;
940
+ node.parentNode.replaceChild = (fresh) => {
941
+ const p = node.parentNode;
942
+ fresh.__parent = p;
943
+ p.__current = fresh;
944
+ const at = ctrl.links.indexOf(node);
945
+ if (at === -1) ctrl.links.push(fresh);
946
+ else ctrl.links[at] = fresh;
947
+ node.isConnected = false;
948
+ ctrl.removed.push(node);
949
+ patchSwap(fresh); // the replacement can be swapped again
950
+ };
951
+ return node;
952
+ };
953
+ for (const l of this.links) patchSwap(l);
954
+ const origCreate = (attrs) => patchSwap(makeFakeLink("", attrs));
803
955
  this.rafQueue = [];
804
956
  this.pollFn = null;
805
957
  this.queue = [];
@@ -815,10 +967,17 @@ class BrowserDriver {
815
967
  },
816
968
  document: {
817
969
  visibilityState: "visible",
818
- querySelector: (sel) => (sel.includes("icon") ? ctrl.link : null),
819
- createElement: () => makeFakeLink(""),
970
+ querySelector: (sel) => {
971
+ const all = ctrl.querySelectorAll(sel);
972
+ return all.length ? all[0] : null;
973
+ },
974
+ // [rel~='icon'] matches the real selector; 'icon' as a bare word keeps
975
+ // the historic `sel.includes("icon")` stub behavior for other callers.
976
+ querySelectorAll: (sel) => ctrl.querySelectorAll(sel),
977
+ createElement: () => origCreate(),
820
978
  head: { appendChild() {} },
821
979
  },
980
+ matchMedia: (q) => ({ matches: !!ctrl.matchMedia(q), media: q }),
822
981
  location: { origin: "http://localhost:3080" },
823
982
  setInterval: (fn) => {
824
983
  ctrl.pollFn = fn;
@@ -868,10 +1027,32 @@ class BrowserDriver {
868
1027
  this.context = context;
869
1028
  vm.runInContext(buildScript(states), context);
870
1029
  }
1030
+ // The `[rel~='icon']` selector the script uses, resolved against the stub
1031
+ // DOM: detached nodes are gone, so a normalized/removed link stops matching.
1032
+ querySelectorAll(sel) {
1033
+ if (!sel.includes("icon")) return [];
1034
+ return this.links.filter((l) => l.isConnected);
1035
+ }
1036
+ /** The link the browser would actually honour: the LAST connected one
1037
+ * (the last connected is what a browser resolves an unscoped rel=icon to). */
1038
+ get effectiveLink() {
1039
+ const connected = this.links.filter((l) => l.isConnected);
1040
+ // Honour `media` when the stub has one, so "run" and "assert" agree.
1041
+ const live = connected.filter((l) => l.__attrs.media === undefined || this.matchMedia(l.__attrs.media));
1042
+ const pick = live.length ? live : connected;
1043
+ return pick.length ? pick[pick.length - 1] : null;
1044
+ }
1045
+ /** The current favicon node. *Not* a stored property: the script replaces the
1046
+ * node (to defeat favicon caching), which would leave a stored reference
1047
+ * pointing at a detached element. */
1048
+ get link() {
1049
+ return this.effectiveLink;
1050
+ }
871
1051
  get href() {
872
1052
  // Favicon frames are encodeURIComponent'd data URIs; decode so assertions
873
1053
  // can match literal SVG substrings (rx="11", >3<, #E5484D, ...).
874
- return decodeURIComponent(this.link.__attrs.href || "");
1054
+ const l = this.effectiveLink || this.link;
1055
+ return decodeURIComponent(l.__attrs.href || "");
875
1056
  }
876
1057
  svgRes(text) {
877
1058
  return { ok: true, status: 200, text: async () => text, json: async () => ({}) };
@@ -1131,6 +1312,60 @@ console.log("\n=== Part 2: browser injected script ===");
1131
1312
  // poll a different state while visible and confirm the listener path stays safe.
1132
1313
  await v.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1133
1314
  ok("B18d subsequent polls unaffected after visible-return poll", v.href.includes("FACC15"));
1315
+
1316
+ // B19 the DSH >= 0.1.7 shell ships a THEME-SCOPED PAIR of favicon links:
1317
+ // <link rel=icon href=favicon-dark.svg media="(prefers-color-scheme: dark)">
1318
+ // <link rel=icon href=favicon.svg media="(prefers-color-scheme: light)">
1319
+ // The browser honours the LAST connected link whose media matches, so
1320
+ // repainting only the first one left the tab on the shell's icon forever
1321
+ // while the status endpoint changed underneath — the "icon never changes"
1322
+ // report. The script must collapse the pair onto one always-matching link.
1323
+ const shellPair = () => [
1324
+ makeFakeLink("http://localhost:3080/favicon-dark.svg", { media: "(prefers-color-scheme: dark)" }),
1325
+ makeFakeLink("http://localhost:3080/favicon.svg", { media: "(prefers-color-scheme: light)" }),
1326
+ ];
1327
+ const light = new BrowserDriver({
1328
+ links: shellPair(),
1329
+ matchMedia: (q) => q === "(prefers-color-scheme: light)",
1330
+ });
1331
+ await light.ready({ state: "running", active: 1, states: DEFAULT_STATES });
1332
+ await light.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1333
+ ok("B19a a media-scoped pair collapses to ONE icon link at startup",
1334
+ light.links.filter((l) => l.isConnected).length === 1,
1335
+ "connected=" + light.links.filter((l) => l.isConnected).length);
1336
+ ok("B19b that link carries no media condition left to mis-match",
1337
+ light.effectiveLink.getAttribute("media") === null,
1338
+ JSON.stringify(light.effectiveLink.getAttribute("media")));
1339
+ ok("B19c the browser-effective href is the painted frame, not the shell icon",
1340
+ light.href.indexOf("data:image/svg+xml") === 0 && light.href.includes("FACC15"),
1341
+ light.href.slice(0, 90));
1342
+ // Dark scheme: the shell's dark variant is the one kept as the resting href.
1343
+ const dark = new BrowserDriver({
1344
+ links: shellPair(),
1345
+ matchMedia: (q) => q === "(prefers-color-scheme: dark)",
1346
+ });
1347
+ await dark.ready({ state: "idle", active: 0, states: DEFAULT_STATES });
1348
+ ok("B19d dark scheme keeps the dark variant (its own scheme wins)",
1349
+ dark.effectiveLink.__attrs.href.indexOf("favicon-dark.svg") !== -1 ||
1350
+ dark.effectiveLink.__attrs.href.indexOf("data:image/svg+xml") === 0,
1351
+ dark.effectiveLink.__attrs.href.slice(0, 90));
1352
+ const beforeRepaint = dark.links.length;
1353
+ await dark.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1354
+ ok("B19e the count-block/whale frame repaints the single kept link",
1355
+ dark.href.includes("FACC15"), dark.href.slice(0, 90));
1356
+ // A same-key re-poll mutates the live link in place and a key change swaps the
1357
+ // node (how the plugin defeats favicon caching). Either way the invariants
1358
+ // are: exactly one link stays connected, its href is a painted data: URI, and
1359
+ // the retired shell link never comes back.
1360
+ await dark.poll({ state: "done", active: 1, states: DEFAULT_STATES });
1361
+ ok("B19f repainting keeps exactly one connected link, still the painted one",
1362
+ dark.links.filter((l) => l.isConnected).length === 1 &&
1363
+ dark.links.length === 2 && // the retired shell link never returns
1364
+ dark.links.filter((l) => !l.isConnected).length === 1 &&
1365
+ dark.href.indexOf("data:image/svg+xml") === 0,
1366
+ "before=" + beforeRepaint + " now=" + dark.links.length + " connected=" +
1367
+ dark.links.map((l) => (l.isConnected ? "+" : "-")).join("") +
1368
+ " href=" + dark.href.slice(0, 60));
1134
1369
  }
1135
1370
 
1136
1371
  // ============================================================================
@@ -1256,7 +1491,9 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1256
1491
  const primitivesStub = {
1257
1492
  Button: function Button() { return null; },
1258
1493
  Input: function Input() { return null; },
1259
- IconChevronDownOutline14: function IconChevronDownOutline14() { return null; },
1494
+ // Current primitives name icons by stroke weight; the size-suffixed
1495
+ // `…Outline14` is the pre-0.1.7 spelling the bundle still falls back to.
1496
+ IconChevronDownOutlineRegular: function IconChevronDownOutlineRegular() { return null; },
1260
1497
  DisclosureRow: function DisclosureRow() { return null; },
1261
1498
  };
1262
1499
 
@@ -1298,12 +1535,14 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1298
1535
  };
1299
1536
  const client = loaded.factory(requireStub);
1300
1537
  ok("F3 exports apply + inject", typeof client.apply === "function" && Array.isArray(client.inject));
1301
- eq("F4 inject list", client.inject, ["slots", "settingsScope", "locale"]);
1538
+ eq("F4 inject list carries only services both host generations provide",
1539
+ client.inject, ["slots", "locale"]);
1302
1540
 
1303
1541
  // ---- apply() contract ----------------------------------------------------
1304
1542
  const registered = [];
1305
1543
  const dictionaries = [];
1306
- let boundSpec = null;
1544
+ let boundEntryId = null;
1545
+ let servedNamespaces = null;
1307
1546
  const scope = {
1308
1547
  getSnapshot: () => snapshot,
1309
1548
  subscribe: () => () => {},
@@ -1330,36 +1569,100 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1330
1569
  revision: 1,
1331
1570
  mode: "host",
1332
1571
  };
1333
- const clientCtx = {
1334
- locale: {
1335
- register(ns, dicts) { dictionaries.push({ ns, dicts }); return () => {}; },
1336
- bind(ns) { return (key) => (dictionaries[0]?.dicts?.en?.[key] ?? key); },
1337
- },
1338
- settingsScope: {
1339
- bind(spec) { boundSpec = spec; return scope; },
1340
- },
1341
- slots: {
1342
- inject(name, cb) { cb(); },
1343
- register(slotSpec, component) { registered.push({ slotSpec, component }); return () => {}; },
1344
- },
1345
- effect(fn) { return fn(); },
1572
+ // A host declares only the slots its own generation knows; `slots.inject`
1573
+ // (the real one) is a no-op for an undeclared name, so the stub must model
1574
+ // that or the dual registration would look like a double render.
1575
+ const makeClientCtx = (opts) => {
1576
+ const declared = new Set(opts.declared || []);
1577
+ const services = opts.services || {};
1578
+ return {
1579
+ get(name) { return services[name]; },
1580
+ locale: {
1581
+ register(ns, dicts) { dictionaries.push({ ns, dicts }); return () => {}; },
1582
+ bind(ns) { return (key) => (dictionaries[0]?.dicts?.en?.[key] ?? key); },
1583
+ },
1584
+ slots: {
1585
+ inject(name, cb) { if (declared.has(name)) cb(); return () => {}; },
1586
+ register(slotSpec, component) { registered.push({ slotSpec, component }); return () => {}; },
1587
+ },
1588
+ effect(fn) { return fn(); },
1589
+ };
1346
1590
  };
1591
+ const clientCtx = makeClientCtx({
1592
+ declared: ["plugins.row.config"],
1593
+ services: {
1594
+ configForms: {
1595
+ get(entryId) { boundEntryId = entryId; return scope; },
1596
+ whileServed(namespaces, register) { servedNamespaces = namespaces; return register(new Set(namespaces)); },
1597
+ },
1598
+ },
1599
+ });
1347
1600
  client.apply(clientCtx);
1348
1601
 
1349
- eq("F5 settingsScope bound to the join key", boundSpec && boundSpec.namespace, "web-icon-indicator");
1350
1602
  ok("F6 locale dictionaries registered (en + zh)",
1351
1603
  dictionaries.length === 1 && !!dictionaries[0].dicts.en && !!dictionaries[0].dicts.zh,
1352
1604
  "ns=" + (dictionaries[0] && dictionaries[0].ns));
1353
- ok("F7 one card registered into settings.plugin.item",
1354
- registered.length === 1 && registered[0].slotSpec.name === "settings.plugin.item",
1355
- "count=" + registered.length);
1605
+ ok("F7 modern line: one card registered into plugins.row.config",
1606
+ registered.length === 1 && registered[0].slotSpec.name === "plugins.row.config",
1607
+ "count=" + registered.length + " " + JSON.stringify(registered.map((r) => r.slotSpec.name)));
1608
+ ok("F7b registration is gated on whileServed(entry id)",
1609
+ Array.isArray(servedNamespaces) && servedNamespaces.length === 1 &&
1610
+ servedNamespaces[0] === "dsh-web-icon-indicator",
1611
+ JSON.stringify(servedNamespaces));
1356
1612
  const slotSpec = registered[0] && registered[0].slotSpec;
1357
- eq("F8 slot key is the settings namespace", slotSpec && slotSpec.key, "web-icon-indicator");
1613
+ eq("F8 slot key is <package>#<row id>", slotSpec && slotSpec.key, "dsh-web-icon-indicator#dsh-web-icon-indicator");
1358
1614
  eq("F9 slot locale namespace", slotSpec && slotSpec.locale, "dsh-web-icon-indicator");
1615
+ // The scope is resolved lazily inside the inject face (the two settings
1616
+ // providers are mutually exclusive), so evaluate it before asserting.
1359
1617
  const injected = slotSpec && slotSpec.inject();
1618
+ eq("F5 configForms bound to the profile entry id", boundEntryId, "dsh-web-icon-indicator");
1360
1619
  ok("F10 slot inject provides scope + t",
1361
1620
  injected && injected.scope === scope && typeof injected.t === "function");
1362
1621
 
1622
+ // ---- legacy client line (DSH <= 0.1.6-alpha.1) ---------------------------
1623
+ // The same bundle, on a host that declares only `settings.plugin.item`,
1624
+ // provides only `settingsScope`, and has neither `configForms` nor a modern
1625
+ // Plugins page. It must register exactly the legacy slot, keyed by the legacy
1626
+ // namespace, and bind the scope the legacy service hands out.
1627
+ {
1628
+ const legacyRegistered = [];
1629
+ const legacyDictionaries = [];
1630
+ let legacySpec = null;
1631
+ const legacyCtx = {
1632
+ get(name) {
1633
+ if (name === "settingsScope") {
1634
+ return { bind(spec) { legacySpec = spec; return scope; } };
1635
+ }
1636
+ return undefined;
1637
+ },
1638
+ locale: {
1639
+ register(ns, dicts) { legacyDictionaries.push({ ns, dicts }); return () => {}; },
1640
+ bind() { return (key) => key; },
1641
+ },
1642
+ slots: {
1643
+ inject(name, cb) { if (name === "settings.plugin.item") cb(); return () => {}; },
1644
+ register(slotSpec, component) { legacyRegistered.push({ slotSpec, component }); return () => {}; },
1645
+ },
1646
+ effect(fn) { return fn(); },
1647
+ };
1648
+ client.apply(legacyCtx);
1649
+ eq("F61 legacy line: exactly one registration", legacyRegistered.length, 1);
1650
+ eq("F61b legacy line: registered into settings.plugin.item",
1651
+ legacyRegistered[0] && legacyRegistered[0].slotSpec.name, "settings.plugin.item");
1652
+ eq("F61c legacy slot key is the legacy namespace",
1653
+ legacyRegistered[0] && legacyRegistered[0].slotSpec.key, "web-icon-indicator");
1654
+ // The scope resolves lazily inside the inject face, so evaluate it first.
1655
+ const legacyInjected = legacyRegistered[0].slotSpec.inject();
1656
+ eq("F61d legacy settingsScope bound to the legacy namespace",
1657
+ legacySpec && legacySpec.namespace, "web-icon-indicator");
1658
+ ok("F61e legacy inject face resolves the legacy scope",
1659
+ legacyInjected && legacyInjected.scope === scope && typeof legacyInjected.t === "function",
1660
+ JSON.stringify({ scope: legacyInjected && legacyInjected.scope === scope }));
1661
+ // And the very same component renders against it.
1662
+ const legacyCard = legacyRegistered[0].component({ scope: scope, t: (k) => k });
1663
+ ok("F61f legacy component renders the card", !!legacyCard && legacyCard.type === "div");
1664
+ }
1665
+
1363
1666
  // ---- card render ---------------------------------------------------------
1364
1667
  // React is stubbed, so a re-render is driven explicitly: the card's own
1365
1668
  // setState calls `rerender()`, which re-invokes the component with the same
@@ -1747,16 +2050,31 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1747
2050
  sharedConstants.every((n) => constOf(SRC, n) !== null && constOf(SRC, n) === constOf(CLIENT_SRC, n)),
1748
2051
  JSON.stringify(sharedConstants.map((n) => [n, constOf(SRC, n), constOf(CLIENT_SRC, n)])));
1749
2052
 
2053
+ // The host's DEFAULTS.states, read straight out of the source: the plugin's
2054
+ // `Config` schema is a schemastery stub here, so the defaults live in the
2055
+ // source text (the card keeps its own copy of this table on purpose).
2056
+ const hostStates = (() => {
2057
+ const marker = SRC.indexOf("states: {", SRC.indexOf("const DEFAULTS"));
2058
+ const open = SRC.indexOf("{", marker);
2059
+ let depth = 0;
2060
+ let i = open;
2061
+ for (; i < SRC.length; i++) {
2062
+ const c = SRC[i];
2063
+ if (c === "{") depth++;
2064
+ else if (c === "}") { depth--; if (depth === 0) break; }
2065
+ }
2066
+ return vm.runInNewContext("(" + SRC.slice(open, i + 1) + ")", {});
2067
+ })();
1750
2068
  ok("F41 the card's built-in state table mirrors the host DEFAULTS (effect + colors + speed)",
1751
2069
  !!cardStates &&
1752
- Object.keys(cardStates).length === Object.keys(plugin.config.states).length &&
2070
+ Object.keys(cardStates).length === Object.keys(hostStates).length &&
1753
2071
  Object.keys(cardStates).every((k) => {
1754
- const host = plugin.config.states[k];
2072
+ const host = hostStates[k];
1755
2073
  return cardStates[k].effect === host.effect &&
1756
2074
  JSON.stringify(cardStates[k].colors) === JSON.stringify(host.colors) &&
1757
2075
  cardStates[k].speed === host.speed;
1758
2076
  }),
1759
- JSON.stringify({ card: cardStates, host: plugin.config.states }));
2077
+ JSON.stringify({ card: cardStates, host: hostStates }));
1760
2078
 
1761
2079
  // ---- the "+" chip: derived seed, no duplicates, room-only -----------------
1762
2080
  // done is static (one colour) and has room for a second; switching it to
@@ -2014,6 +2332,121 @@ console.log("\n=== Part 6: browser half (lib/client.js) ===");
2014
2332
  emptyField.props.colors.length === 0 && emptyField.props.canAdd === false &&
2015
2333
  findAll(emptyTree, (n) => n.type === "input" && n.props.type === "color").length === 1,
2016
2334
  JSON.stringify({ colors: emptyField.props.colors, canAdd: emptyField.props.canAdd }));
2335
+
2336
+ // ---- Plugins page views (view: 'summary' | 'page') ------------------------
2337
+ // The page asks a configuration entry twice: a one-liner for the row and the
2338
+ // body once its page is opened. A standalone render passes no `view` and
2339
+ // keeps the collapsible header (F11–F14 above).
2340
+ {
2341
+ const renderFresh = (primitives) => {
2342
+ const freshReact = makeReactStub();
2343
+ const freshRegistered = [];
2344
+ const freshCtx = {
2345
+ get: (name) => (name === "configForms"
2346
+ ? { get: () => scope, whileServed: (_ns, register) => register(new Set()) }
2347
+ : undefined),
2348
+ locale: { register: () => () => {}, bind: () => (k) => k },
2349
+ slots: {
2350
+ inject: (name, cb) => { if (name === "plugins.row.config") cb(); return () => {}; },
2351
+ register: (spec, comp) => { freshRegistered.push({ spec, comp }); return () => {}; },
2352
+ },
2353
+ effect: (fn) => fn(),
2354
+ };
2355
+ const freshClient = loaded.factory((name) => {
2356
+ if (name === "react") return freshReact;
2357
+ if (name === "@deepseek-ai/dsh-client-ui-primitives") return primitives;
2358
+ throw new Error("unexpected require: " + name);
2359
+ });
2360
+ freshClient.apply(freshCtx);
2361
+ return freshRegistered[0].comp;
2362
+ };
2363
+
2364
+ const ViewCard = renderFresh(primitivesStub);
2365
+ const keyT = (k) => k;
2366
+ eq("F57 the summary view is the one-liner text",
2367
+ ViewCard({ scope, t: keyT, view: "summary" }), "description");
2368
+ const pageTree = ViewCard({ scope, t: keyT, view: "page" });
2369
+ ok("F58 the page view renders the body without the card's own header",
2370
+ !!byId(pageTree, "plugin-config-icon-asking-hold") &&
2371
+ findAll(pageTree, (n) => n.type === "button" && n.props && n.props["aria-expanded"] !== undefined).length === 0,
2372
+ textOf(pageTree).slice(0, 80));
2373
+
2374
+ // The pre-0.1.7 primitives exported the chevron under a size suffix; the
2375
+ // bundle must keep rendering on that host line too.
2376
+ const legacyPrimitives = {
2377
+ Button: function Button() { return null; },
2378
+ Input: function Input() { return null; },
2379
+ IconChevronDownOutline14: function IconChevronDownOutline14() { return null; },
2380
+ DisclosureRow: function DisclosureRow() { return null; },
2381
+ };
2382
+ const LegacyCard = renderFresh(legacyPrimitives);
2383
+ const legacyTree = LegacyCard({ scope, t: keyT });
2384
+ ok("F59 the legacy size-suffixed chevron still resolves",
2385
+ findAll(legacyTree, (n) => n.type === legacyPrimitives.IconChevronDownOutline14).length === 1,
2386
+ textOf(legacyTree).slice(0, 80));
2387
+
2388
+ // The 0.1.7 settings contract replaced both host-plane APIs this bundle used
2389
+ // to target; a leftover reference would fail activation at boot.
2390
+ ok("F60 the bundle still speaks BOTH generations (dual-support contract)",
2391
+ CLIENT_SRC.indexOf("settingsScope") !== -1 &&
2392
+ CLIENT_SRC.indexOf("settings.plugin.item") !== -1 &&
2393
+ CLIENT_SRC.indexOf("configForms") !== -1 &&
2394
+ CLIENT_SRC.indexOf("plugins.row.config") !== -1 &&
2395
+ CLIENT_SRC.indexOf("whileServed") !== -1);
2396
+
2397
+ // ---- official Plugins page icon (host manifest contract) ---------------
2398
+ // The 0.1.7 page reads `package.json.icon` through <pkg>/package.json and
2399
+ // base64s the file (readPluginMeta -> iconOf in @deepseek-ai/dsh-app-boot):
2400
+ // a manifest-relative SVG/PNG/JPEG/WebP at most 256 KiB, contained in the
2401
+ // manifest directory after realpath. Any violation does not blank the card
2402
+ // — it turns the plugin's `meta.error` on and the card shows a problem tag,
2403
+ // so every rule below is load-bearing.
2404
+ const MANIFEST = JSON.parse(readFileSync(new URL("package.json", REPO), "utf8"));
2405
+ const ICON_REL = MANIFEST.icon;
2406
+ const ICON_EXT = typeof ICON_REL === "string" ? ICON_REL.slice(ICON_REL.lastIndexOf(".")).toLowerCase() : "";
2407
+ ok("F62 the manifest declares a relative image icon",
2408
+ typeof ICON_REL === "string" && ICON_REL.trim() !== "" &&
2409
+ !/^[A-Za-z][A-Za-z\d+.-]*:/u.test(ICON_REL) && // rejects URLs (and Windows drive letters)
2410
+ !ICON_REL.startsWith("/") && !ICON_REL.startsWith("\\") &&
2411
+ !/^[A-Za-z]:/u.test(ICON_REL) && // rejects C:\... absolute paths
2412
+ !ICON_REL.split(/[\\/]/u).includes("..") && // must stay inside the manifest directory
2413
+ [".svg", ".png", ".jpg", ".jpeg", ".webp"].includes(ICON_EXT),
2414
+ JSON.stringify(ICON_REL));
2415
+
2416
+ let ICON_SVG = "";
2417
+ let ICON_BYTES = 0;
2418
+ try {
2419
+ ICON_SVG = readFileSync(new URL(ICON_REL, REPO), "utf8");
2420
+ ICON_BYTES = statSync(new URL(ICON_REL, REPO)).size;
2421
+ } catch {
2422
+ /* the F63 check below reports the missing file */
2423
+ }
2424
+ ok("F63 the declared icon ships and stays under the 256 KiB manifest bound",
2425
+ ICON_BYTES > 0 && ICON_BYTES <= 256 * 1024,
2426
+ ICON_BYTES + " bytes");
2427
+
2428
+ // A leftover __COLOR__ placeholder is not valid SVG: the page would render
2429
+ // an unpainted whale (or nothing) where the artwork goes.
2430
+ ok("F64 the icon is standalone SVG with no placeholder left in it",
2431
+ ICON_SVG !== "" &&
2432
+ ICON_SVG.startsWith("<svg ") && ICON_SVG.trimEnd().endsWith("</svg>") &&
2433
+ !ICON_SVG.includes("__COLOR__"),
2434
+ ICON_SVG.slice(0, 40));
2435
+
2436
+ // icons/plugin.svg and docs/assets/favicon.svg are the same static whale;
2437
+ // keep them byte-identical so regenerating one from icons/base.svg cannot
2438
+ // silently leave the other stale. Repo-only (absent from the npm tarball).
2439
+ let DOCS_FAVICON = null;
2440
+ try {
2441
+ DOCS_FAVICON = readFileSync(new URL("docs/assets/favicon.svg", REPO), "utf8");
2442
+ } catch {
2443
+ /* published tarball: docs/ is repo-only */
2444
+ }
2445
+ if (DOCS_FAVICON !== null) {
2446
+ ok("F65 icons/plugin.svg stays identical to docs/assets/favicon.svg",
2447
+ DOCS_FAVICON === ICON_SVG);
2448
+ }
2449
+ }
2017
2450
  }
2018
2451
 
2019
2452
  // ---- summary ---------------------------------------------------------------