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/CHANGELOG.md +16 -0
- package/README.md +113 -43
- package/README.zh.md +92 -31
- package/icons/plugin.svg +6 -0
- package/lib/client.js +141 -32
- package/lib/index.js +202 -94
- package/lib/types/client/index.d.ts +13 -7
- package/lib/types/index.d.ts +14 -5
- package/package.json +4 -2
- package/test/verify.js +511 -78
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 === "
|
|
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
|
|
198
|
-
plugin.
|
|
199
|
-
plugin.
|
|
200
|
-
|
|
201
|
-
|
|
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:
|
|
204
|
-
//
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
891
|
+
function makeFakeLink(initialHref, attrs) {
|
|
892
|
+
const a = { rel: "icon", type: "", href: initialHref || "", ...(attrs || {}) };
|
|
782
893
|
return {
|
|
783
894
|
isConnected: true,
|
|
784
|
-
__attrs:
|
|
895
|
+
__attrs: a,
|
|
785
896
|
setAttribute(k, v) {
|
|
786
|
-
|
|
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
|
|
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
|
-
|
|
794
|
-
|
|
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.
|
|
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) =>
|
|
819
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
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
|
|
1354
|
-
registered.length === 1 && registered[0].slotSpec.name === "
|
|
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
|
|
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(
|
|
2070
|
+
Object.keys(cardStates).length === Object.keys(hostStates).length &&
|
|
1753
2071
|
Object.keys(cardStates).every((k) => {
|
|
1754
|
-
const host =
|
|
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:
|
|
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 ---------------------------------------------------------------
|