dsh-web-icon-indicator 0.4.2 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/test/verify.js ADDED
@@ -0,0 +1,2273 @@
1
+ /**
2
+ * Zero-dependency, zero-build verification for dsh-web-icon-indicator.
3
+ *
4
+ * Run: node test/verify.js (also wired as `npm test`)
5
+ *
6
+ * It exercises the REAL code in lib/index.js:
7
+ * - host side: imports the actual plugin through loader hooks that stub the
8
+ * two @deepseek-ai peer imports, drives `apply()` with a fake Cordis ctx
9
+ * (webServer / timer / agents / fs / settings), and asserts state-machine,
10
+ * aggregation (`active`), approval / asking / done-hold behavior, and the
11
+ * status-endpoint JSON shape via the actually-registered route handler.
12
+ * - browser side: extracts the INJECTED_SCRIPT template, substitutes the
13
+ * same placeholders the host does (__STATUS_PATH__ / __BASE_PATH__ /
14
+ * __CFG__), and runs it in a node:vm with DOM / fetch / rAF stubs to
15
+ * assert favicon frames (whale vs full-frame count block), render-key
16
+ * transitions, effect-driven fills (blink / rainbow) on the count block,
17
+ * settings sync, offline-safe poll-failure restore, and legacy-host
18
+ * compatibility.
19
+ *
20
+ * No npm dependencies, no build step, no framework — plain `node`.
21
+ */
22
+ import { register } from "node:module";
23
+ import { spawnSync } from "node:child_process";
24
+ import { readFileSync, writeFileSync, unlinkSync } from "node:fs";
25
+ import vm from "node:vm";
26
+ import os from "node:os";
27
+ import path from "node:path";
28
+
29
+ const REPO = new URL("../", import.meta.url);
30
+ const SRC = readFileSync(new URL("lib/index.js", REPO), "utf8");
31
+ const BASE_SVG = readFileSync(new URL("icons/base.svg", REPO), "utf8");
32
+
33
+ // ---- tiny assert harness ---------------------------------------------------
34
+ let passed = 0;
35
+ let failed = 0;
36
+ const failures = [];
37
+ function check(name, okFlag, detail) {
38
+ if (okFlag) passed++;
39
+ else {
40
+ failed++;
41
+ failures.push(name + (detail !== undefined ? " — " + detail : ""));
42
+ }
43
+ console.log((okFlag ? "PASS" : "FAIL") + " " + name);
44
+ }
45
+ function eq(name, actual, expected) {
46
+ const a = JSON.stringify(actual);
47
+ const e = JSON.stringify(expected);
48
+ check(name, a === e, "got " + a + ", want " + e);
49
+ }
50
+ function ok(name, cond, detail) {
51
+ check(name, !!cond, detail);
52
+ }
53
+ const tick = () => new Promise((r) => setImmediate(r));
54
+
55
+ // ---- source extraction helpers ---------------------------------------------
56
+ function extractBlock(src, marker) {
57
+ const start = src.indexOf(marker);
58
+ if (start === -1) throw new Error("marker not found: " + marker);
59
+ const open = src.indexOf("{", start);
60
+ let depth = 0;
61
+ let i = open;
62
+ for (; i < src.length; i++) {
63
+ const c = src[i];
64
+ if (c === "{") depth++;
65
+ else if (c === "}") {
66
+ depth--;
67
+ if (depth === 0) break;
68
+ }
69
+ }
70
+ if (i >= src.length) throw new Error("unbalanced block for " + marker);
71
+ return src.slice(start, i + 1);
72
+ }
73
+ function extractTemplate(src) {
74
+ const a = src.indexOf("const INJECTED_SCRIPT = `") + "const INJECTED_SCRIPT = `".length;
75
+ const b = src.indexOf("`;", a);
76
+ if (a === -1 || b === -1) throw new Error("INJECTED_SCRIPT markers not found");
77
+ return src.slice(a, b);
78
+ }
79
+ const DEFAULT_STATES = {
80
+ idle: { effect: "static", colors: ["#1a1a1a"] },
81
+ running: { effect: "static", colors: ["#FACC15"] },
82
+ asking: { effect: "blink", colors: ["#E5484D", "#FACC15"], speed: 400 },
83
+ done: { effect: "static", colors: ["#22A06B"] },
84
+ };
85
+ const TEMPLATE = extractTemplate(SRC);
86
+ // Build the injected script exactly like the host's buildScript() does.
87
+ function buildScript(states) {
88
+ return TEMPLATE
89
+ .replace("__STATUS_PATH__", "/dsh-web-icon-status.json")
90
+ .replace("__BASE_PATH__", "/dsh-web-icon-indicator/base.svg")
91
+ .replace("__CFG__", JSON.stringify({ states }));
92
+ }
93
+ const builtScript = buildScript(DEFAULT_STATES);
94
+
95
+ // ============================================================================
96
+ // PART 1 — host plugin integration (REAL apply(), stubbed ctx)
97
+ // ============================================================================
98
+ function makeCtx(opts = {}) {
99
+ const routes = [];
100
+ const taps = [];
101
+ const onHandlers = {};
102
+ const timers = [];
103
+ let agentsList = [];
104
+ const ctx = {
105
+ get: (k) => (k === "config" ? {} : undefined),
106
+ webServer: {
107
+ register: (r) => routes.push(r),
108
+ tapIndex: (fn) => taps.push(fn),
109
+ },
110
+ timer: {
111
+ timeout: (cb, ms) => {
112
+ const t = { cb, ms, fired: false, ran: false, running: false };
113
+ t.cb = () => {
114
+ t.ran = true; // executed (vs cancelled via the disposer -> fired)
115
+ t.running = true;
116
+ try { return cb(); } finally { t.running = false; }
117
+ };
118
+ // The disposer mirrors the real `ctx.timer.timeout` return value; the
119
+ // `__test` back-reference lets assertions read the timer's own flags.
120
+ const disposer = () => { t.fired = true; };
121
+ disposer.running = () => t.running;
122
+ disposer.__test = t;
123
+ t.disposer = disposer;
124
+ timers.push(t);
125
+ return disposer;
126
+ },
127
+ },
128
+ on: (ev, fn) => {
129
+ (onHandlers[ev] ||= []).push(fn);
130
+ },
131
+ effect: (fn) => fn(), // run immediately so routes register
132
+ agents: {
133
+ list: () => agentsList.slice(),
134
+ get: (id) => agentsList.find((a) => a.id === id) || null,
135
+ },
136
+ fs: null,
137
+ sandboxPolicy: null,
138
+ };
139
+ if (opts.logger) {
140
+ // Optional capture of `ctx.logger(name).warn(...)`: the host half reports its
141
+ // default-color warnings through the cordis core logger when one is present.
142
+ ctx.logger = (name) => ({ warn: (msg) => opts.logger.push(name + ": " + msg) });
143
+ }
144
+ return {
145
+ ctx,
146
+ routes,
147
+ taps,
148
+ onHandlers,
149
+ timers,
150
+ setAgents: (list) => {
151
+ agentsList = list;
152
+ },
153
+ };
154
+ }
155
+ function fakeRes() {
156
+ return {
157
+ statusCode: 0,
158
+ headers: {},
159
+ body: null,
160
+ setHeader(k, v) {
161
+ this.headers[k] = v;
162
+ },
163
+ end(b) {
164
+ this.body = b;
165
+ },
166
+ };
167
+ }
168
+ function emit(handlers, ev, ...args) {
169
+ for (const fn of handlers[ev] || []) fn(...args);
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
+ }
207
+
208
+ console.log("\n=== Part 1: host plugin integration ===");
209
+
210
+ // Register the loader hook BEFORE importing the real plugin.
211
+ register(new URL("./loader-hooks.mjs", import.meta.url), import.meta.url);
212
+ const { default: plugin } = await import(new URL("../lib/index.js", import.meta.url));
213
+
214
+ {
215
+ // H1 contract shape
216
+ ok("H1 plugin contract shape", plugin && plugin.name === "dsh-web-icon-indicator",
217
+ "name=" + plugin?.name);
218
+ eq("H1b inject list", plugin.inject, ["webServer", "timer", "agents", "fs"]);
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);
226
+ ok("H1e CONFIG_SCHEMA defined", !!plugin.CONFIG_SCHEMA);
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.
231
+ const schemaSrc = SRC.slice(SRC.indexOf("const CONFIG_SCHEMA"), SRC.indexOf("/** Browser script injected"));
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));
249
+
250
+ const h = makeCtx();
251
+ const opts = liveApply(h);
252
+ const statusRoute = h.routes.find((r) => r.kind === "exact");
253
+ const baseRoute = h.routes.find((r) => r.kind === "prefix");
254
+ ok("H1f status route registered", !!statusRoute && statusRoute.path === "/dsh-web-icon-status.json");
255
+ ok("H1g base route registered", !!baseRoute && baseRoute.path === "/dsh-web-icon-indicator");
256
+ ok("H1h tapIndex registered", h.taps.length === 1);
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 };
269
+
270
+ const aggregate = () => {
271
+ const res = fakeRes();
272
+ statusRoute.handler(null, res);
273
+ return JSON.parse(res.body);
274
+ };
275
+
276
+ // H2 no agents
277
+ const idleAgg = aggregate();
278
+ eq("H2 no agents -> idle active 0",
279
+ { state: idleAgg.state, active: idleAgg.active, sinceIsNumber: typeof idleAgg.since === "number" },
280
+ { state: "idle", active: 0, sinceIsNumber: true });
281
+ ok("H2b states echoed with 4 states", Object.keys(aggregate().states).length === 4);
282
+
283
+ // H3 one running
284
+ h.setAgents([{ id: "A", status: "running" }]);
285
+ let agg = aggregate();
286
+ eq("H3 one running -> running active 1",
287
+ { state: agg.state, active: agg.active }, { state: "running", active: 1 });
288
+
289
+ // H4 two running
290
+ h.setAgents([
291
+ { id: "A", status: "running" },
292
+ { id: "B", status: "running" },
293
+ ]);
294
+ agg = aggregate();
295
+ eq("H4 two running -> running active 2",
296
+ { state: agg.state, active: agg.active }, { state: "running", active: 2 });
297
+
298
+ // H5 pending approval (hasPendingApproval real fold) raises priority + counts
299
+ // The stub mirrors the real contract: events carry a `seq` and
300
+ // `snapshotEvents(fromSeq)` returns only the delta from that offset, so the
301
+ // incremental cursor is exercised rather than a full re-read.
302
+ const approvalLog = (events) => {
303
+ const log = events.map((ev, i) => ({ ...ev, seq: i }));
304
+ return {
305
+ log,
306
+ reads: 0,
307
+ session: {
308
+ snapshotEvents: (fromSeq) => {
309
+ const start = typeof fromSeq === "number" ? fromSeq : 0;
310
+ return log.filter((ev) => ev.seq >= start);
311
+ },
312
+ },
313
+ };
314
+ };
315
+ const pendingLog = approvalLog([{ type: "approval/asked", data: { id: "p1" } }]);
316
+ h.setAgents([
317
+ { id: "A", status: "running", session: pendingLog.session },
318
+ { id: "B", status: "running" },
319
+ ]);
320
+ agg = aggregate();
321
+ eq("H5 approval-asked + running -> asking active 2",
322
+ { state: agg.state, active: agg.active }, { state: "asking", active: 2 });
323
+
324
+ // H6 approval/decided clears the pin
325
+ const decidedLog = approvalLog([
326
+ { type: "approval/asked", data: { id: "p1" } },
327
+ { type: "approval/decided", data: { id: "p1" } },
328
+ ]);
329
+ h.setAgents([
330
+ { id: "A", status: "running", session: decidedLog.session },
331
+ { id: "B", status: "running" },
332
+ ]);
333
+ agg = aggregate();
334
+ eq("H6 approval decided -> running active 2",
335
+ { state: agg.state, active: agg.active }, { state: "running", active: 2 });
336
+
337
+ // H6b the fold is incremental: the second poll must ask for the delta only,
338
+ // and a later `approval/asked` appended to the log must still pin the agent.
339
+ // Uses a fresh agent id: the cursor is per agent, and "A" already folded a
340
+ // different log above.
341
+ const growing = [];
342
+ const readRanges = [];
343
+ h.setAgents([
344
+ {
345
+ id: "G",
346
+ status: "running",
347
+ session: {
348
+ snapshotEvents: (fromSeq) => {
349
+ const start = typeof fromSeq === "number" ? fromSeq : 0;
350
+ readRanges.push(start);
351
+ return growing.filter((ev) => ev.seq >= start);
352
+ },
353
+ },
354
+ },
355
+ ]);
356
+ growing.push({ type: "approval/asked", data: { id: "p1" }, seq: 0 });
357
+ aggregate();
358
+ eq("H6b first read starts at 0", readRanges[0], 0);
359
+ aggregate();
360
+ eq("H6c second read starts past the seen event", readRanges[1], 1);
361
+ growing.push({ type: "approval/decided", data: { id: "p1" }, seq: 1 });
362
+ agg = aggregate();
363
+ eq("H6d delta-only fold clears the pin", agg.state, "running");
364
+ growing.push({ type: "approval/asked", data: { id: "p2" }, seq: 2 });
365
+ agg = aggregate();
366
+ eq("H6e appended ask still pins", agg.state, "asking");
367
+
368
+ // H7 session/event handler pinning (approval/asked event drives setState)
369
+ h.setAgents([{ id: "A", status: "running" }]);
370
+ agg = aggregate();
371
+ eq("H7a before event -> running active 1",
372
+ { state: agg.state, active: agg.active }, { state: "running", active: 1 });
373
+ emit(h.onHandlers, "session/event", { id: "A" }, { type: "approval/asked" });
374
+ agg = aggregate();
375
+ eq("H7b approval/asked event -> asking active 1",
376
+ { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
377
+ emit(h.onHandlers, "session/event", { id: "A" }, { type: "approval/decided" });
378
+ agg = aggregate();
379
+ eq("H7c approval/decided event -> running active 1",
380
+ { state: agg.state, active: agg.active }, { state: "running", active: 1 });
381
+
382
+ // H8 reconcile: running -> idle lands in done hold, counts as active, then expires
383
+ h.setAgents([{ id: "A", status: "running" }]);
384
+ aggregate(); // record lastSeen running
385
+ h.setAgents([{ id: "A", status: "idle" }]);
386
+ agg = aggregate(); // reconcile flips to done
387
+ eq("H8a reconcile running->idle -> done active 1",
388
+ { state: agg.state, active: agg.active }, { state: "done", active: 1 });
389
+ const doneTimer = h.timers.find((t) => !t.fired && t.cb);
390
+ ok("H8b done hold timer scheduled", !!doneTimer);
391
+ doneTimer.cb(); // expire the hold
392
+ agg = aggregate();
393
+ eq("H8c done hold expired -> idle active 0",
394
+ { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
395
+
396
+ // H9 done-hold + another running -> running active 2 (done counts once)
397
+ h.setAgents([
398
+ { id: "A", status: "running" },
399
+ { id: "B", status: "running" },
400
+ ]);
401
+ aggregate();
402
+ h.setAgents([
403
+ { id: "A", status: "idle" },
404
+ { id: "B", status: "running" },
405
+ ]);
406
+ agg = aggregate();
407
+ eq("H9 done-hold + running -> running active 2",
408
+ { state: agg.state, active: agg.active }, { state: "running", active: 2 });
409
+
410
+ // H10 ask_user_question pin: pre-execute -> asking, result -> hold, expiry -> live state
411
+ h.setAgents([{ id: "A", status: "running" }]);
412
+ emit(h.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "A" } }, () => {});
413
+ agg = aggregate();
414
+ eq("H10a ask pre-execute -> asking active 1",
415
+ { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
416
+ emit(h.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "A" } }, {});
417
+ const askTimer = h.timers[h.timers.length - 1];
418
+ ok("H10b ask hold timer scheduled", !!askTimer);
419
+ askTimer.cb(); // hold expired, answer already returned
420
+ agg = aggregate();
421
+ eq("H10c ask hold expired -> live running active 1",
422
+ { state: agg.state, active: agg.active }, { state: "running", active: 1 });
423
+
424
+ // H11 agent/disposed cleanup
425
+ emit(h.onHandlers, "agent/disposed", { agent: { id: "A" } });
426
+ h.setAgents([]);
427
+ agg = aggregate();
428
+ eq("H11 disposed -> idle active 0", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
429
+
430
+ // H12 status endpoint HTTP shape (headers + cache-control)
431
+ const res = fakeRes();
432
+ statusRoute.handler(null, res);
433
+ ok("H12 status 200 + json + no-store",
434
+ res.statusCode === 200 &&
435
+ res.headers["Content-Type"] === "application/json; charset=utf-8" &&
436
+ res.headers["Cache-Control"] === "no-store");
437
+
438
+ // H13 base route guards the filename regex
439
+ const bad = fakeRes();
440
+ baseRoute.handler({ url: "/dsh-web-icon-indicator/other.svg" }, bad);
441
+ ok("H13 base route rejects non-base.svg", bad.statusCode === 404);
442
+
443
+ // H14 a volatile settings update re-resolves; new values reach route + host timings
444
+ opts.setSource(() => ({ askingHoldMs: 100, states: { running: { effect: "breath", colors: ["#01ABCD"] } } }));
445
+ opts.onChange();
446
+ agg = aggregate();
447
+ eq("H14a onChange propagates new running color", agg.states.running?.colors?.[0], "#01ABCD");
448
+ ok("H14b askingHoldMs re-resolved to 100", plugin.CONFIG_SCHEMA !== undefined); // schema is static; cfg mutated internally
449
+ h.setAgents([{ id: "B", status: "running" }]);
450
+ emit(h.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "B" } }, () => {});
451
+ const askTimer2 = h.timers[h.timers.length - 1];
452
+ ok("H14c ask hold timer uses new 100ms", askTimer2.ms === 100, "ms=" + askTimer2.ms);
453
+
454
+ // H14d/H14e an `undefined` value in the settings source must not shadow a
455
+ // DEFAULTS entry, and a settings edit must not move the baked route paths.
456
+ opts.setSource(() => ({ askingHoldMs: undefined, iconsDir: undefined, statusPath: "/ignored.json" }));
457
+ opts.onChange();
458
+ const res14 = fakeRes();
459
+ statusRoute.handler(null, res14);
460
+ const agg14 = JSON.parse(res14.body);
461
+ ok("H14d resolved config still serves all four default states", Object.keys(agg14.states).length === 4,
462
+ JSON.stringify(Object.keys(agg14.states)));
463
+ ok("H14e status route path unchanged by settings", statusRoute.path === "/dsh-web-icon-status.json");
464
+ }
465
+
466
+ {
467
+ // H15–H24 — event-driven paths surfaced by the standalone audit:
468
+ // agent/status branches, agent/turn-stopping (+asking-pin guard), the
469
+ // ask re-arm cap (lost tools/result fallback), and single done-hold
470
+ // management (round-trip re-arm, dispose cancellation).
471
+ const h2 = makeCtx();
472
+ plugin.apply(h2.ctx);
473
+ const statusRoute2 = h2.routes.find((r) => r.kind === "exact");
474
+ const aggregate2 = () => {
475
+ const res = fakeRes();
476
+ statusRoute2.handler(null, res);
477
+ return JSON.parse(res.body);
478
+ };
479
+ const liveTimers = () => h2.timers.filter((t) => !t.fired && !t.ran);
480
+ // H15 agent/status running (event-driven) -> running
481
+ h2.setAgents([{ id: "A", status: "running" }]);
482
+ emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "running" });
483
+ let agg = aggregate2();
484
+ eq("H15 agent/status running -> running active 1",
485
+ { state: agg.state, active: agg.active }, { state: "running", active: 1 });
486
+
487
+ // H16 running event while the asking pin is on -> pin wins
488
+ emit(h2.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "A" } }, () => {});
489
+ emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "running" });
490
+ agg = aggregate2();
491
+ eq("H16 running event under asking pin -> asking",
492
+ { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
493
+
494
+ // H17 agent/status idle after running -> done hold (event-driven), then expiry
495
+ // H17 agent/status idle after running -> done hold (event-driven), then expiry.
496
+ // The event and the live agents list must agree: aggregations read the list
497
+ // status first, so the idle event is paired with list status "idle".
498
+ emit(h2.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "A" } }, {});
499
+ const askT = h2.timers[h2.timers.length - 1];
500
+ askT.cb(); // hold expired, result present -> back to live running
501
+ agg = aggregate2();
502
+ eq("H17a ask released -> running", { state: agg.state, active: agg.active }, { state: "running", active: 1 });
503
+ h2.setAgents([{ id: "A", status: "idle" }]);
504
+ emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "idle" });
505
+ agg = aggregate2();
506
+ eq("H17b idle event (prev running) -> done hold",
507
+ { state: agg.state, active: agg.active }, { state: "done", active: 1 });
508
+ const dt = liveTimers().at(-1);
509
+ dt.cb();
510
+ agg = aggregate2();
511
+ eq("H17c done hold expires -> idle", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
512
+
513
+ // H18 idle event on an idle agent -> stays idle
514
+ emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "idle" });
515
+ agg = aggregate2();
516
+ eq("H18 idle event on idle -> idle", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
517
+
518
+ // H19 agent/turn-stopping (running) -> done (live list follows to "idle",
519
+ // as in reality the stopping turn syncs to idle before the next aggregation)
520
+ h2.setAgents([{ id: "A", status: "running" }]);
521
+ emit(h2.onHandlers, "agent/status", { agent: { id: "A" }, status: "running" });
522
+ emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "A" } });
523
+ h2.setAgents([{ id: "A", status: "idle" }]);
524
+ agg = aggregate2();
525
+ eq("H19 turn-stopping (running) -> done",
526
+ { state: agg.state, active: agg.active }, { state: "done", active: 1 });
527
+
528
+ // H20 turn-stopping while the asking pin is on -> guard keeps asking
529
+ emit(h2.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "A" } }, () => {});
530
+ emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "A" } });
531
+ agg = aggregate2();
532
+ eq("H20 turn-stopping under pin -> stays asking",
533
+ { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
534
+
535
+ // H21 lost tools/result: re-arms while the agent stays running (the user may
536
+ // still be deciding), force-releases only once the agent leaves `running`.
537
+ emit(h2.onHandlers, "agent/disposed", { agent: { id: "A" } });
538
+ h2.setAgents([{ id: "B", status: "running" }]);
539
+ emit(h2.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "B" } }, () => {});
540
+ agg = aggregate2();
541
+ eq("H21a ask pinned", { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
542
+ for (let i = 1; i <= 5; i++) {
543
+ h2.timers[h2.timers.length - 1].cb(); // re-arm (no result yet, still running)
544
+ agg = aggregate2();
545
+ eq("H21b re-arm #" + i + " (no result, still running) -> still asking",
546
+ { state: agg.state, active: agg.active }, { state: "asking", active: 1 });
547
+ }
548
+ // The turn ended while the result never arrived: the agent left `running`,
549
+ // so the pin must force-release instead of re-arming forever. The release is
550
+ // not a silent switch to idle — the running→idle transition is the standard
551
+ // turn-end path, so reconcile arms the normal done hold first, then idle.
552
+ h2.setAgents([{ id: "B", status: "idle" }]);
553
+ h2.timers[h2.timers.length - 1].cb(); // hold expired, agent no longer running
554
+ agg = aggregate2();
555
+ eq("H21c force-release after agent leaves running -> done hold",
556
+ { state: agg.state, active: agg.active }, { state: "done", active: 1 });
557
+ liveTimers().at(-1).cb(); // done hold expires
558
+ agg = aggregate2();
559
+ eq("H21d done hold expiry -> idle", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
560
+
561
+ // H22 done->running->done round-trip keeps a SINGLE live hold
562
+ h2.setAgents([{ id: "C", status: "running" }]);
563
+ emit(h2.onHandlers, "agent/status", { agent: { id: "C" }, status: "running" });
564
+ emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "C" } }); // -> done (hold 1)
565
+ emit(h2.onHandlers, "agent/status", { agent: { id: "C" }, status: "running" }); // -> running (cancels hold 1)
566
+ emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "C" } }); // -> done (hold 2)
567
+ const doneLives = liveTimers();
568
+ eq("H22a only one live done hold after round-trip", doneLives.length, 1);
569
+ h2.setAgents([{ id: "C", status: "idle" }]); // list agrees: turn finished
570
+ doneLives[0].cb();
571
+ agg = aggregate2();
572
+ eq("H22b live hold expiry -> idle",
573
+ { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
574
+
575
+ // H23 dispose cancels an in-flight done hold
576
+ h2.setAgents([{ id: "D", status: "running" }]);
577
+ emit(h2.onHandlers, "agent/status", { agent: { id: "D" }, status: "running" });
578
+ emit(h2.onHandlers, "agent/turn-stopping", { agent: { id: "D" } }); // done + hold
579
+ const dHold = liveTimers();
580
+ ok("H23a done hold armed", dHold.length === 1, "live=" + dHold.length);
581
+ emit(h2.onHandlers, "agent/disposed", { agent: { id: "D" } });
582
+ ok("H23b disposed cancels done hold", dHold[0].fired === true);
583
+ h2.setAgents([]);
584
+ agg = aggregate2();
585
+ eq("H23c disposed -> idle active 0", { state: agg.state, active: agg.active }, { state: "idle", active: 0 });
586
+ }
587
+
588
+ {
589
+ // H24 the asking pin must still be releasable after a re-arm followed by a
590
+ // second pre-execute. Regression: scheduleAskCheck cancelled the stored handle
591
+ // unconditionally, so a pre-execute arriving while the callback was executing
592
+ // its re-arm path killed the fresh handle — the session stayed pinned to
593
+ // `asking` forever (nothing left to release it).
594
+ const h3 = makeCtx();
595
+ plugin.apply(h3.ctx);
596
+ const statusRoute3 = h3.routes.find((r) => r.kind === "exact");
597
+ const aggregate3 = () => {
598
+ const res = fakeRes();
599
+ statusRoute3.handler(null, res);
600
+ return JSON.parse(res.body);
601
+ };
602
+ const state3 = () => { const a = aggregate3(); return { state: a.state, active: a.active }; };
603
+
604
+ h3.setAgents([{ id: "E", status: "running" }]);
605
+ emit(h3.onHandlers, "agent/status", { agent: { id: "E" }, status: "running" });
606
+ emit(h3.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "E" } }, () => {});
607
+ eq("H24a ask pinned", state3(), { state: "asking", active: 1 });
608
+
609
+ h3.timers.at(-1).cb(); // no result yet + still running -> re-arm
610
+ eq("H24b still asking after re-arm", state3(), { state: "asking", active: 1 });
611
+
612
+ // Second ask_user_question while the re-armed timer is pending.
613
+ emit(h3.onHandlers, "tools/pre-execute", { name: "ask_user_question", agent: { id: "E" } }, () => {});
614
+ eq("H24c still asking after second pre-execute", state3(), { state: "asking", active: 1 });
615
+
616
+ // The tool finally returns: SOME pending timer must be able to release the
617
+ // pin. Every handle created for this agent is exercised, so a fix that left
618
+ // only dead handles behind fails here.
619
+ emit(h3.onHandlers, "tools/result", { name: "ask_user_question", agent: { id: "E" } }, {});
620
+ const pending = h3.timers.filter((t) => !t.ran && !t.fired);
621
+ ok("H24d at least one pending hold timer survived", pending.length >= 1, "pending=" + pending.length);
622
+ for (const t of pending) t.cb();
623
+ eq("H24e hold expiry after answer -> running", state3(), { state: "running", active: 1 });
624
+ }
625
+
626
+ {
627
+ // H25–H28 — `defaultColor`: folded into the idle primary, echoed by the status
628
+ // endpoint, and judged by the CIE76 similarity warning (advisory: a value that
629
+ // collides with a state color is reported, never rejected).
630
+ const logs = [];
631
+ const h4 = makeCtx({ logger: logs });
632
+ const opts4 = liveApply(h4);
633
+ const statusRoute4 = h4.routes.find((r) => r.kind === "exact");
634
+ const aggregate4 = () => {
635
+ const res = fakeRes();
636
+ statusRoute4.handler(null, res);
637
+ return JSON.parse(res.body);
638
+ };
639
+
640
+ // Absent defaultColor: unchanged behavior, no warning, no log.
641
+ const outOfTheBox = aggregate4();
642
+ ok("H25a absent defaultColor keeps the idle default and warns about nothing",
643
+ outOfTheBox.states.idle.colors[0] === "#1a1a1a" &&
644
+ Array.isArray(outOfTheBox.warnings) && outOfTheBox.warnings.length === 0,
645
+ JSON.stringify(outOfTheBox.warnings));
646
+ ok("H25b absent defaultColor logs nothing", logs.length === 0, JSON.stringify(logs));
647
+
648
+ // A distinct default color folds into the idle primary (colors[1] survives for
649
+ // a multi-color idle effect) and is echoed by the status payload.
650
+ opts4.setSource(() => ({
651
+ defaultColor: "#5B8DEF",
652
+ states: { idle: { effect: "breath", colors: ["#111111", "#DDDDDD"] } },
653
+ }));
654
+ opts4.onChange();
655
+ const distinct = aggregate4();
656
+ eq("H26a defaultColor overrides the idle primary", distinct.states.idle.colors[0], "#5b8def");
657
+ eq("H26b defaultColor keeps the idle state's secondary color for multi-color effects",
658
+ distinct.states.idle.colors[1], "#DDDDDD");
659
+ eq("H26c status echoes the resolved defaultColor", distinct.defaultColor, "#5b8def");
660
+ ok("H26d a distinct default color raises no similarity warning",
661
+ distinct.warnings.filter((w) => w.code === "color-too-close").length === 0,
662
+ JSON.stringify(distinct.warnings));
663
+
664
+ // Exactly the running color: strong (ΔE 0), and the asking blink shares it.
665
+ opts4.setSource(() => ({ defaultColor: "#FACC15", states: { idle: { effect: "static", colors: ["#111111"] } } }));
666
+ opts4.onChange();
667
+ const colliding = aggregate4();
668
+ const runningWarn = colliding.warnings.find((w) => w.state === "running" && w.code === "color-too-close");
669
+ ok("H27a a default color equal to the running color warns strongly",
670
+ !!runningWarn && runningWarn.level === "strong" && runningWarn.deltaE === 0 && runningWarn.base === "#facc15",
671
+ JSON.stringify(colliding.warnings));
672
+ ok("H27b the asking blink's shared color warns too",
673
+ colliding.warnings.some((w) => w.state === "asking" && w.code === "color-too-close"),
674
+ JSON.stringify(colliding.warnings));
675
+ ok("H27c the host logged the warning through ctx.logger",
676
+ logs.some((l) => l.includes("dsh-web-icon-indicator") && l.includes("#facc15")),
677
+ JSON.stringify(logs));
678
+
679
+ // The soft band (12 ≤ ΔE < 25) reports level "warn" instead of "strong".
680
+ opts4.setSource(() => ({ defaultColor: "#b8a000" }));
681
+ opts4.onChange();
682
+ const soft = aggregate4().warnings.find((w) => w.state === "running" && w.code === "color-too-close");
683
+ ok("H28a the soft band reports level warn",
684
+ !!soft && soft.level === "warn" && soft.deltaE > 12 && soft.deltaE < 25, JSON.stringify(soft));
685
+
686
+ // A malformed value is dropped (never painted) and reported.
687
+ opts4.setSource(() => ({ defaultColor: "#12" }));
688
+ opts4.onChange();
689
+ const malformed = aggregate4();
690
+ ok("H28b an invalid defaultColor is dropped, not painted (echoed as null)",
691
+ malformed.defaultColor === null && malformed.states.idle.colors[0] === "#1a1a1a",
692
+ JSON.stringify({ defaultColor: malformed.defaultColor, idle: malformed.states.idle }));
693
+ ok("H28c the invalid value is reported as a warning",
694
+ malformed.warnings.some((w) => w.code === "invalid-color" && w.value === "#12"),
695
+ JSON.stringify(malformed.warnings));
696
+
697
+ // A rainbow state sweeps every hue, so any chromatic default collides with it.
698
+ opts4.setSource(() => ({ defaultColor: "#5B8DEF", states: { done: { effect: "rainbow", colors: ["#22A06B"] } } }));
699
+ opts4.onChange();
700
+ const rainbow = aggregate4();
701
+ ok("H28d a rainbow state warns against a chromatic default",
702
+ rainbow.warnings.some((w) => w.code === "rainbow-overlap" && w.state === "done"),
703
+ JSON.stringify(rainbow.warnings));
704
+ ok("H28d2 a rainbow state does not also warn about a color it never paints",
705
+ !rainbow.warnings.some((w) => w.state === "done" && w.code === "color-too-close"),
706
+ JSON.stringify(rainbow.warnings));
707
+
708
+ // "" / whitespace in a hand-written composition entry means "unset", not a
709
+ // malformed color to warn about.
710
+ opts4.setSource(() => ({ defaultColor: " " }));
711
+ opts4.onChange();
712
+ const blank = aggregate4();
713
+ ok("H28e a blank defaultColor means unset (echoed as null)",
714
+ blank.defaultColor === null && blank.states.idle.colors[0] === "#1a1a1a" && blank.warnings.length === 0,
715
+ JSON.stringify({ defaultColor: blank.defaultColor, warnings: blank.warnings }));
716
+
717
+ // An empty YAML value (`defaultColor:`) parses as null: unset, not malformed.
718
+ opts4.setSource(() => ({ defaultColor: null }));
719
+ opts4.onChange();
720
+ const nullDefault = aggregate4();
721
+ ok("H28e2 a null defaultColor means unset too",
722
+ nullDefault.defaultColor === null && nullDefault.warnings.length === 0,
723
+ JSON.stringify({ defaultColor: nullDefault.defaultColor, warnings: nullDefault.warnings }));
724
+
725
+ // A state whose colors are entirely invalid falls back to its built-in color
726
+ // (resolveConfig), and the warning must judge that color — this pins the
727
+ // card's matching fallback (F36).
728
+ opts4.setSource(() => ({ defaultColor: "#FACC15", states: { running: { colors: ["red"] } } }));
729
+ opts4.onChange();
730
+ const fallback = aggregate4();
731
+ ok("H28f an invalid state color falls back to the built-in color before comparing",
732
+ fallback.states.running.colors[0] === "#FACC15" &&
733
+ fallback.warnings.some((w) => w.state === "running" && w.color === "#facc15" && w.deltaE === 0),
734
+ JSON.stringify({ running: fallback.states.running, warnings: fallback.warnings }));
735
+
736
+ // A 4/5-digit hex is not a colour either: dropped by the host exactly like the
737
+ // card drops it, never parsed into a bogus RGB triple.
738
+ opts4.setSource(() => ({ states: { running: { colors: ["#1234"] } } }));
739
+ opts4.onChange();
740
+ const baddigit = aggregate4();
741
+ ok("H28g a 4-digit hex state color is dropped, not parsed",
742
+ baddigit.states.running.colors[0] === "#FACC15", JSON.stringify(baddigit.states.running));
743
+
744
+ // A PARTIALLY invalid list keeps its valid entries (the card has to agree —
745
+ // F49 pins the card side).
746
+ opts4.setSource(() => ({ defaultColor: "#FF0000", states: { running: { colors: ["#FF0000", "red"] } } }));
747
+ opts4.onChange();
748
+ const partial = aggregate4();
749
+ ok("H28h a partially invalid colors list keeps the valid entries",
750
+ partial.states.running.colors.join(",") === "#FF0000" &&
751
+ partial.warnings.some((w) => w.state === "running" && w.color === "#ff0000" && w.deltaE === 0),
752
+ JSON.stringify({ running: partial.states.running, warnings: partial.warnings }));
753
+
754
+ // The browser paints breath as a CONTINUOUS mix: a blue -> green gradient runs
755
+ // straight through the configured default's own colour, which the old 5-point
756
+ // sampling missed entirely (reported ΔE 37 instead of 0).
757
+ opts4.setSource(() => ({
758
+ defaultColor: "#005aa5",
759
+ states: { running: { effect: "breath", colors: ["#0000ff", "#00ff00"] } },
760
+ }));
761
+ opts4.onChange();
762
+ const breathGap = aggregate4();
763
+ const breathWarn = breathGap.warnings.find((w) => w.state === "running" && w.code === "color-too-close");
764
+ ok("H28i a breath gradient is sampled densely enough to catch the collision",
765
+ !!breathWarn && breathWarn.deltaE < 8, JSON.stringify(breathGap.warnings));
766
+
767
+ // idle itself on `rainbow` (composition entry only): it sweeps every hue, so
768
+ // the default colour cannot stay distinguishable — one advisory, no pairwise pass.
769
+ opts4.setSource(() => ({ defaultColor: "#ff0000", states: { idle: { effect: "rainbow", colors: ["#1a1a1a"] } } }));
770
+ opts4.onChange();
771
+ const idleRainbow = aggregate4();
772
+ ok("H28j idle running rainbow is reported as a hue sweep",
773
+ idleRainbow.warnings.some((w) => w.code === "rainbow-overlap" && w.state === "idle") &&
774
+ !idleRainbow.warnings.some((w) => w.code === "color-too-close"),
775
+ JSON.stringify(idleRainbow.warnings));
776
+ opts4.setSource(() => ({ defaultColor: "#1a1a1a", states: { idle: { effect: "rainbow", colors: ["#1a1a1a"] } } }));
777
+ opts4.onChange();
778
+ ok("H28j2 a neutral default stays quiet even when idle is rainbow",
779
+ aggregate4().warnings.length === 0, JSON.stringify(aggregate4().warnings));
780
+
781
+ // A hand-written states.idle entry is honoured on its own (no defaultColor).
782
+ opts4.setSource(() => ({ states: { idle: { effect: "blink", colors: ["#111111", "#DDDDDD"], speed: 900 } } }));
783
+ opts4.onChange();
784
+ const idleOnly = aggregate4();
785
+ ok("H28k a hand-written states.idle entry survives without defaultColor",
786
+ idleOnly.states.idle.effect === "blink" &&
787
+ idleOnly.states.idle.colors.join(",") === "#111111,#DDDDDD" &&
788
+ idleOnly.defaultColor === null,
789
+ JSON.stringify({ idle: idleOnly.states.idle, defaultColor: idleOnly.defaultColor }));
790
+ }
791
+
792
+ {
793
+ // H29 — the host log covers the other two warning codes as well (H27c only
794
+ // asserted the color-too-close line).
795
+ const logs2 = [];
796
+ const h5 = makeCtx({ logger: logs2 });
797
+ const opts5 = liveApply(h5);
798
+ const statusRoute5 = h5.routes.find((r) => r.kind === "exact");
799
+ const aggregate5 = () => {
800
+ const res = fakeRes();
801
+ statusRoute5.handler(null, res);
802
+ return JSON.parse(res.body);
803
+ };
804
+
805
+ opts5.setSource(() => ({ defaultColor: "#zz" }));
806
+ opts5.onChange();
807
+ aggregate5();
808
+ ok("H29 an invalid defaultColor is logged with its value",
809
+ logs2.some((l) => l.includes("not a 3- or 6-digit hex color")), JSON.stringify(logs2));
810
+
811
+ opts5.setSource(() => ({ defaultColor: "#ff0000", states: { done: { effect: "rainbow", colors: ["#22A06B"] } } }));
812
+ opts5.onChange();
813
+ aggregate5();
814
+ ok("H29b a rainbow state is logged as an all-hue sweep",
815
+ logs2.some((l) => l.includes("rainbow effect sweeps every hue")), JSON.stringify(logs2));
816
+ }
817
+
818
+ // ============================================================================
819
+ // PART 1b — legacy host line (DSH <= 0.1.6-alpha.1): settings.installSection
820
+ // ============================================================================
821
+ // The same bundle must keep working on the pre-0.1.7 settings service, where
822
+ // the plugin registers its namespace itself and the service hands back a live
823
+ // source. This block drives that exact contract.
824
+ {
825
+ const legacy = { captured: null };
826
+ const routes = [];
827
+ const taps = [];
828
+ const timers = [];
829
+ let agentsList = [];
830
+ const handlers = {};
831
+ const settings = {
832
+ installSection(owner, ns, schema, entry, hooks) {
833
+ legacy.captured = { owner, ns, schema, entry, hooks };
834
+ },
835
+ };
836
+ const ctx = {
837
+ get: (k) => (k === "config" ? {} : undefined),
838
+ inject: (deps, cb) => { if (Array.isArray(deps) && deps.includes("settings")) cb({ settings }); },
839
+ webServer: {
840
+ register: (r) => routes.push(r),
841
+ tapIndex: (fn) => taps.push(fn),
842
+ },
843
+ timer: { timeout: () => () => {} },
844
+ on: (ev, fn) => { (handlers[ev] ||= []).push(fn); },
845
+ effect: (fn) => fn(),
846
+ agents: { list: () => agentsList.slice(), get: () => null },
847
+ fs: null,
848
+ };
849
+ plugin.apply(ctx, { askingHoldMs: 4200, states: { running: { effect: "heartbeat", colors: ["#ABCDEF"] } } });
850
+ const statusRoute = routes.find((r) => r.kind === "exact");
851
+ const aggregate = () => { const res = fakeRes(); statusRoute.handler(null, res); return JSON.parse(res.body); };
852
+
853
+ ok("H30 legacy host: installSection registered", !!legacy.captured);
854
+ eq("H30a legacy namespace", legacy.captured && legacy.captured.ns, "web-icon-indicator");
855
+ eq("H30b legacy namespace matches the exported constant",
856
+ legacy.captured && legacy.captured.ns, plugin.LEGACY_SETTINGS_NAMESPACE);
857
+ ok("H30c legacy host: entry is the plain composition config (not a ref bag)",
858
+ !!legacy.captured && legacy.captured.entry.askingHoldMs === 4200 &&
859
+ typeof legacy.captured.entry.states === "object" &&
860
+ typeof legacy.captured.entry.states.get !== "function" &&
861
+ legacy.captured.entry.states.running.effect === "heartbeat",
862
+ JSON.stringify(legacy.captured && legacy.captured.entry));
863
+ ok("H30d legacy host: setSource + onChange hooks provided",
864
+ !!legacy.captured && typeof legacy.captured.hooks.setSource === "function" &&
865
+ typeof legacy.captured.hooks.onChange === "function");
866
+
867
+ // The service owns the live value: setSource installs it, onChange notifies.
868
+ legacy.captured.hooks.setSource(() => ({ askingHoldMs: 250, states: { running: { effect: "breath", colors: ["#654321"] } } }));
869
+ legacy.captured.hooks.onChange();
870
+ const after = aggregate();
871
+ eq("H30e legacy onChange re-resolves the live running colour", after.states.running.colors[0], "#654321");
872
+ ok("H30f legacy route path untouched by the legacy source", statusRoute.path === "/dsh-web-icon-status.json");
873
+ // A legacy source that omits keys falls back to DEFAULTS rather than clearing.
874
+ legacy.captured.hooks.setSource(() => ({ askingHoldMs: undefined, iconsDir: undefined }));
875
+ legacy.captured.hooks.onChange();
876
+ ok("H30g legacy undefined values fall back to defaults", Object.keys(aggregate().states).length === 4);
877
+ }
878
+
879
+ // ============================================================================
880
+ // PART 2 — browser injected script (real template, VM + DOM/fetch/rAF stubs)
881
+ // ============================================================================
882
+ function makeFakeLink(initialHref) {
883
+ const attrs = { rel: "icon", type: "", href: initialHref || "" };
884
+ return {
885
+ isConnected: true,
886
+ __attrs: attrs,
887
+ setAttribute(k, v) {
888
+ attrs[k] = String(v);
889
+ },
890
+ getAttribute(k) {
891
+ return k in attrs ? attrs[k] : null;
892
+ },
893
+ parentNode: {
894
+ replaceChild(fresh) {
895
+ // adopt the fresh node's attrs into this link
896
+ for (const k of Object.keys(fresh.__attrs)) attrs[k] = fresh.__attrs[k];
897
+ },
898
+ },
899
+ };
900
+ }
901
+
902
+ class BrowserDriver {
903
+ constructor({ initialHref = "http://orig.example/favicon.ico", states = DEFAULT_STATES, withFileReader = false } = {}) {
904
+ this.link = makeFakeLink(initialHref);
905
+ this.rafQueue = [];
906
+ this.pollFn = null;
907
+ this.queue = [];
908
+ this.aborts = []; // AbortController stubs handed to fetch, in request order
909
+ this.pendingTimeouts = []; // armed fetch-deadline callbacks (never auto-fired)
910
+ const ctrl = this;
911
+ const context = vm.createContext({
912
+ window: {
913
+ __DSH_WEB_ICON_INDICATOR__: false,
914
+ addEventListener(ev, fn) {
915
+ if (ev === "visibilitychange") ctrl.visFn = fn; // capture for the B18 test
916
+ },
917
+ },
918
+ document: {
919
+ visibilityState: "visible",
920
+ querySelector: (sel) => (sel.includes("icon") ? ctrl.link : null),
921
+ createElement: () => makeFakeLink(""),
922
+ head: { appendChild() {} },
923
+ },
924
+ location: { origin: "http://localhost:3080" },
925
+ setInterval: (fn) => {
926
+ ctrl.pollFn = fn;
927
+ return 1;
928
+ },
929
+ clearInterval() {},
930
+ requestAnimationFrame: (cb) => {
931
+ ctrl.rafQueue.push(cb);
932
+ return ctrl.rafQueue.length;
933
+ },
934
+ cancelAnimationFrame: () => {
935
+ ctrl.rafQueue.length = 0; // a new loop invalidates any stale steps
936
+ },
937
+ // Minimal AbortController so the script's deadline-bounded fetch path runs
938
+ // inside the VM; tests can drive abort() through d.aborts[i].
939
+ AbortController: class {
940
+ constructor() {
941
+ this.signal = { aborted: false };
942
+ ctrl.aborts.push(this);
943
+ }
944
+ abort() {
945
+ this.signal.aborted = true;
946
+ }
947
+ },
948
+ // Timer stubs for the script's fetch deadline: the tests never wait 8 s,
949
+ // they drive the abort through d.aborts[i].abort() instead.
950
+ setTimeout: (fn) => { ctrl.pendingTimeouts.push(fn); return ctrl.pendingTimeouts.length; },
951
+ clearTimeout: (id) => { if (id) ctrl.pendingTimeouts[id - 1] = null; },
952
+ fetch: (url, init) =>
953
+ new Promise((resolve, reject) => {
954
+ ctrl.queue.push({ url: String(url), resolve, reject, signal: init && init.signal });
955
+ }),
956
+ // Optional Blob/FileReader stubs: let the original-icon capture path of
957
+ // the offline-safe restore run inside the VM (fetch -> blob -> data URI).
958
+ ...(withFileReader
959
+ ? {
960
+ Blob: class BlobStub {},
961
+ FileReader: class FileReaderStub {
962
+ readAsDataURL() {
963
+ this.result = "data:image/x-icon;base64,AAEC";
964
+ if (this.onload) this.onload();
965
+ }
966
+ },
967
+ }
968
+ : {}),
969
+ });
970
+ this.context = context;
971
+ vm.runInContext(buildScript(states), context);
972
+ }
973
+ get href() {
974
+ // Favicon frames are encodeURIComponent'd data URIs; decode so assertions
975
+ // can match literal SVG substrings (rx="11", >3<, #E5484D, ...).
976
+ return decodeURIComponent(this.link.__attrs.href || "");
977
+ }
978
+ svgRes(text) {
979
+ return { ok: true, status: 200, text: async () => text, json: async () => ({}) };
980
+ }
981
+ statusRes(j) {
982
+ return { ok: true, status: 200, text: async () => "", json: async () => j };
983
+ }
984
+ // answer every queued request; responder(url) -> response object | Error
985
+ async pump(responder) {
986
+ while (this.queue.length) {
987
+ const req = this.queue.shift();
988
+ const out = responder(req.url);
989
+ if (out instanceof Error) req.reject(out);
990
+ else req.resolve(out);
991
+ }
992
+ await tick();
993
+ }
994
+ // The script polls once at load; consume that initial request deterministically
995
+ // so the first test-driven poll() sees a clean single-apply state.
996
+ async ready(initialStatus) {
997
+ await this.pump((url) =>
998
+ url.includes("/base.svg") ? this.svgRes(BASE_SVG) : this.statusRes(initialStatus)
999
+ );
1000
+ if (this.queue.length) await this.pump((url) => this.svgRes(BASE_SVG)); // deferred base fetch
1001
+ await tick();
1002
+ }
1003
+ // drive one poll: status json -> optional base fetch -> allow microtasks
1004
+ async poll(statusJson) {
1005
+ this.pollFn();
1006
+ await this.pump((url) =>
1007
+ url.includes("/base.svg") ? this.svgRes(BASE_SVG) : this.statusRes(statusJson)
1008
+ );
1009
+ if (this.queue.length) await this.pump((url) => this.svgRes(BASE_SVG)); // deferred base fetch
1010
+ await tick();
1011
+ }
1012
+ async pollFail() {
1013
+ this.pollFn();
1014
+ await this.pump(() => new Error("network down"));
1015
+ await tick();
1016
+ }
1017
+ raf(ts) {
1018
+ this.rafQueue.shift()(ts);
1019
+ }
1020
+ }
1021
+
1022
+ console.log("\n=== Part 2: browser injected script ===");
1023
+ {
1024
+ // B1 initial whale frame (active 1)
1025
+ const d = new BrowserDriver();
1026
+ await d.ready({ state: "running", active: 1, states: DEFAULT_STATES });
1027
+ ok("B1 whale frame painted", d.href.includes("M48.8354") && d.href.includes("FACC15"),
1028
+ d.href.slice(0, 120));
1029
+
1030
+ // B2-B4 count transitions on one driver
1031
+ await d.poll({ state: "running", active: 3, states: DEFAULT_STATES });
1032
+ ok("B2 active 3 -> number block",
1033
+ d.href.includes('rx="11"') && d.href.includes(">3<") && !d.href.includes("M48.8354"),
1034
+ d.href.slice(0, 160));
1035
+ await d.poll({ state: "running", active: 2, states: DEFAULT_STATES });
1036
+ ok("B3 active 2 -> number 2", d.href.includes(">2<") && d.href.includes('rx="11"'));
1037
+ await d.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1038
+ ok("B4 active 1 -> whale back", d.href.includes("M48.8354"));
1039
+
1040
+ // B5 same-key repoll stable (no loop restart churn)
1041
+ const frameBefore = d.href;
1042
+ await d.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1043
+ ok("B5 same key repoll -> identical frame", d.href === frameBefore);
1044
+
1045
+ // B6 legacy host (no active) keeps count; non-numeric active ignored
1046
+ await d.poll({ state: "running", states: DEFAULT_STATES }); // no active field
1047
+ ok("B6 legacy no active -> whale kept", d.href.includes("M48.8354"));
1048
+ await d.poll({ state: "running", active: "2", states: DEFAULT_STATES }); // non-number ignored
1049
+ ok("B6b string active ignored -> still whale", d.href.includes("M48.8354"));
1050
+ await d.poll({ state: "running", active: 4, states: DEFAULT_STATES });
1051
+ ok("B6c numeric active 4 -> number 4", d.href.includes(">4<"));
1052
+
1053
+ // B7 blink animates the count block fill
1054
+ const b = new BrowserDriver();
1055
+ await b.ready({ state: "asking", active: 2, states: DEFAULT_STATES });
1056
+ ok("B7 blink initial frame is colors[0]",
1057
+ b.href.includes("E5484D") && b.href.includes(">2<"), b.href.slice(0, 140));
1058
+ b.raf(400); // t0 = 400 -> t = 0 -> c0
1059
+ ok("B7b step t=0 -> E5484D", b.href.includes("E5484D"));
1060
+ b.raf(800); // t = 400 -> toggle -> c1
1061
+ ok("B7c step t=400 -> FACC15", b.href.includes("FACC15") && b.href.includes(">2<"));
1062
+
1063
+ // B8 rainbow cycles the count block fill
1064
+ const rainbowStates = { ...DEFAULT_STATES, running: { effect: "rainbow", colors: ["#FACC15"], speed: 1200 } };
1065
+ const r = new BrowserDriver({ states: rainbowStates });
1066
+ await r.ready({ state: "running", active: 2, states: rainbowStates });
1067
+ const r0 = r.href;
1068
+ r.raf(800); // t0=800, t=0 -> same hue
1069
+ r.raf(1600); // t=800 -> hue rotated
1070
+ const r2 = r.href;
1071
+ ok("B8 rainbow fills differ, both number blocks",
1072
+ r0 !== r2 && r0.includes(">2<") && r2.includes(">2<") && r0.includes('rx="11"'));
1073
+
1074
+ // B9 syncCfg: settings change repaints with the new color
1075
+ const s = new BrowserDriver();
1076
+ await s.ready({ state: "running", active: 1, states: DEFAULT_STATES });
1077
+ ok("B9a baseline yellow whale", s.href.includes("FACC15"));
1078
+ await s.poll({
1079
+ state: "running",
1080
+ active: 1,
1081
+ states: { ...DEFAULT_STATES, running: { effect: "static", colors: ["#123456"] } },
1082
+ });
1083
+ ok("B9b new state color applied", s.href.includes("123456") && !s.href.includes("FACC15"));
1084
+
1085
+ // B10 outage with a server-URL original and NO cached copy (no FileReader
1086
+ // in this driver): restore() must keep the last painted frame — writing the
1087
+ // original URL would blank the tab, exactly the "backend stopped → icon
1088
+ // lost" bug this guards against.
1089
+ const f = new BrowserDriver({ initialHref: "http://orig.example/favicon.ico" });
1090
+ await f.ready({ state: "running", active: 1, states: DEFAULT_STATES }); // paint something else
1091
+ const lastFrame = f.href;
1092
+ ok("B10a icon painted before the failure",
1093
+ lastFrame.includes("data:image/svg+xml") && lastFrame.includes("FACC15"), lastFrame.slice(0, 120));
1094
+ await f.pollFail();
1095
+ ok("B10b outage keeps the last painted icon", f.href === lastFrame, f.href);
1096
+ ok("B10c dead server URL never written on failure", !f.href.includes("orig.example"), f.href);
1097
+ await f.pollFail();
1098
+ ok("B10d repeated failures stay stable (restore once per outage)", f.href === lastFrame, f.href);
1099
+
1100
+ // B12 the original icon is captured as a data-URI copy at startup, so an
1101
+ // outage restores THAT (renderable without the host) instead of a dead URL.
1102
+ const c = new BrowserDriver({ initialHref: "/favicon.svg", withFileReader: true });
1103
+ // Startup order: captureOriginal's fetch + the initial poll, then the
1104
+ // deferred base.svg fetch once apply() runs.
1105
+ const origRes = { ok: true, status: 200, blob: async () => ({}) };
1106
+ await c.pump((url) =>
1107
+ url.includes("/base.svg") ? c.svgRes(BASE_SVG)
1108
+ : url.includes("/favicon.svg") ? origRes
1109
+ : c.statusRes({ state: "running", active: 1, states: DEFAULT_STATES })
1110
+ );
1111
+ await c.pump((url) => (url.includes("/base.svg") ? c.svgRes(BASE_SVG) : new Error("unexpected " + url)));
1112
+ await tick();
1113
+ ok("B12a plugin frame painted", c.href.includes("data:image/svg+xml") && c.href.includes("FACC15"),
1114
+ c.href.slice(0, 120));
1115
+ await c.pollFail();
1116
+ ok("B12b outage restores the cached data-URI copy", c.href === "data:image/x-icon;base64,AAEC", c.href);
1117
+ await c.pollFail();
1118
+ ok("B12c repeated failures keep the restored icon stable", c.href === "data:image/x-icon;base64,AAEC", c.href);
1119
+
1120
+ // B11 old-host payload (no states) leaves CFG untouched
1121
+ const g = new BrowserDriver();
1122
+ await g.ready({ state: "running", active: 2, states: DEFAULT_STATES });
1123
+ ok("B11a number before legacy payload", g.href.includes(">2<"));
1124
+ await g.poll({ state: "running", active: 2 }); // no states -> older host
1125
+ ok("B11b old-host poll keeps frames", g.href.includes(">2<"));
1126
+
1127
+ // B13 geometric effects wrap the whale in a <g transform> (previously
1128
+ // untested: heartbeat and bounce were only ever exercised in the demo page).
1129
+ const hbStates = { ...DEFAULT_STATES, running: { effect: "heartbeat", colors: ["#FACC15"], speed: 1200 } };
1130
+ const hb = new BrowserDriver({ states: hbStates });
1131
+ await hb.ready({ state: "running", active: 1, states: hbStates });
1132
+ hb.raf(100); // t=0 -> outside the lub-dub window, scale 1
1133
+ ok("B13a heartbeat wraps the whale in a scale group",
1134
+ hb.href.includes('<g transform="translate(27.889625 24.95264) scale(1) translate(-27.889625 -24.95264)">') &&
1135
+ hb.href.includes("</g></svg>") && hb.href.includes("FACC15"),
1136
+ hb.href.slice(0, 200));
1137
+ hb.raf(160); // t=60ms -> 0.05 of the cycle -> first beat, scale > 1
1138
+ ok("B13b heartbeat beats inside the first window",
1139
+ /scale\(1\.\d+\)/.test(hb.href) && parseFloat(/scale\((1\.\d+)\)/.exec(hb.href)[1]) > 1.05,
1140
+ (hb.href.match(/scale\([^)]*\)/) || ["none"])[0]);
1141
+
1142
+ const boStates = { ...DEFAULT_STATES, running: { effect: "bounce", colors: ["#22A06B"], speed: 1200 } };
1143
+ const bo = new BrowserDriver({ states: boStates });
1144
+ await bo.ready({ state: "running", active: 1, states: boStates });
1145
+ bo.raf(100);
1146
+ ok("B13c bounce wraps the whale in a translate group",
1147
+ /<g transform="translate\(0 -?0\.\d+\)">/.test(bo.href) && bo.href.includes("22A06B"),
1148
+ (bo.href.match(/<g transform="[^"]*"/) || ["none"])[0]);
1149
+ bo.raf(312); // quarter of a 1.6-cycle -> maximum lift
1150
+ ok("B13d bounce lifts the whale", /translate\(0 -[1-9]/.test(bo.href), bo.href.slice(0, 200));
1151
+
1152
+ // B14 breath interpolates between colors[0] and colors[1]. Sample at
1153
+ // t = speed/4 and t = 3·speed/4 (the two extremes) — t = speed is the
1154
+ // midpoint again, because sin(2π) is -2.4e-16 rather than exactly 0.
1155
+ const brStates = { ...DEFAULT_STATES, running: { effect: "breath", colors: ["#000000", "#FFFFFF"], speed: 1200 } };
1156
+ const br = new BrowserDriver({ states: brStates });
1157
+ await br.ready({ state: "running", active: 1, states: brStates });
1158
+ const fillOf = (d) => (/fill: (#[0-9a-f]{6})/.exec(d.href) || [])[1] || "";
1159
+ br.raf(100); // t=0 -> midpoint
1160
+ const midFill = fillOf(br);
1161
+ br.raf(400); // t=300 -> quarter cycle -> colors[1]
1162
+ const firstExtreme = fillOf(br);
1163
+ br.raf(1000); // t=900 -> three-quarter cycle -> colors[0]
1164
+ const secondExtreme = fillOf(br);
1165
+ const lum = (hex) => parseInt(hex.slice(1, 3), 16) + parseInt(hex.slice(3, 5), 16) + parseInt(hex.slice(5, 7), 16);
1166
+ ok("B14a breath midpoint between the two colors", midFill === "#808080", midFill);
1167
+ ok("B14b breath reaches both configured colors",
1168
+ firstExtreme === "#ffffff" && secondExtreme === "#000000" &&
1169
+ lum(firstExtreme) > lum(midFill) && lum(secondExtreme) < lum(midFill),
1170
+ "q1=" + firstExtreme + " mid=" + midFill + " q3=" + secondExtreme);
1171
+
1172
+ // B15 hidden-tab fallback: an unchanged static state repaints (self-heal)
1173
+ // instead of leaving a stale frame; an unchanged animated state gets a
1174
+ // wall-clock frame rather than freezing.
1175
+ const sh = new BrowserDriver();
1176
+ await sh.ready({ state: "running", active: 1, states: DEFAULT_STATES });
1177
+ const shFrame = sh.href;
1178
+ sh.link.__attrs.href = "data:image/svg+xml,stale";
1179
+ await sh.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1180
+ ok("B15a unchanged static poll self-heals the frame", sh.href === shFrame && !sh.href.includes("stale"));
1181
+
1182
+ // B16 the count block does not depend on base.svg: with the template
1183
+ // permanently unavailable the count must still render (only the whale waits).
1184
+ const nb = new BrowserDriver();
1185
+ await nb.pump((url) => (url.includes("/base.svg") ? new Error("base down") : nb.statusRes({ state: "running", active: 3, states: DEFAULT_STATES })));
1186
+ await tick();
1187
+ ok("B16a no base.svg -> no whale frame", !nb.href.includes("M48.8354"), nb.href.slice(0, 120));
1188
+ await nb.poll({ state: "running", active: 3, states: DEFAULT_STATES });
1189
+ await nb.pump((url) => (url.includes("/base.svg") ? new Error("base down") : nb.statusRes({ state: "running", active: 3, states: DEFAULT_STATES })));
1190
+ ok("B16b count block renders without base.svg", nb.href.includes(">3<") && nb.href.includes('rx="11"'), nb.href.slice(0, 160));
1191
+
1192
+ // B17 every request is deadline-bounded: fetch receives an AbortSignal, and a
1193
+ // request that hangs is aborted instead of holding the poll chain forever.
1194
+ const t = new BrowserDriver();
1195
+ await t.pump((url) => (url.includes("/base.svg") ? t.svgRes(BASE_SVG) : t.statusRes({ state: "running", active: 1, states: DEFAULT_STATES })));
1196
+ ok("B17a fetch requests carry an abort signal",
1197
+ t.aborts.length > 0 && t.aborts.every((a) => a.signal && a.signal.aborted === false),
1198
+ "controllers=" + t.aborts.length);
1199
+ ok("B17b a deadline timer was armed per request", t.pendingTimeouts.length > 0,
1200
+ "timers=" + t.pendingTimeouts.length);
1201
+ // Simulate the deadline expiring on the in-flight poll.
1202
+ t.pollFn();
1203
+ const hanging = t.queue[t.queue.length - 1];
1204
+ t.aborts[t.aborts.length - 1].abort();
1205
+ ok("B17c aborting marks the request signal", hanging.signal.aborted === true);
1206
+ hanging.reject(new Error("AbortError"));
1207
+ await tick();
1208
+ // The poll chain survived the abort: the next tick still works.
1209
+ await t.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1210
+ ok("B17d poll recovers after an aborted request", t.href.includes("FACC15"));
1211
+
1212
+ // B18 returning to a hidden tab repaints immediately: the visibilitychange
1213
+ // listener fires a fresh poll instead of waiting for the next — possibly
1214
+ // throttled — interval tick, so a revert that happened while the tab was
1215
+ // away (e.g. done -> idle) shows the moment the tab becomes visible again.
1216
+ const v = new BrowserDriver();
1217
+ await v.ready({ state: "running", active: 1, states: DEFAULT_STATES });
1218
+ v.context.document.visibilityState = "hidden";
1219
+ await v.poll({ state: "done", active: 1, states: DEFAULT_STATES }); // what the tab saw before going away
1220
+ ok("B18a done frame painted while 'hidden'", v.href.includes("22A06B"), v.href.slice(0, 120));
1221
+ // The host reverts to idle while the tab is away; the tab becomes visible.
1222
+ v.context.document.visibilityState = "visible";
1223
+ ok("B18b visibilitychange listener captured", typeof v.visFn === "function");
1224
+ v.visFn(); // fires the immediate poll
1225
+ await v.pump((url) =>
1226
+ url.includes("/base.svg") ? v.svgRes(BASE_SVG) : v.statusRes({ state: "idle", active: 0, states: DEFAULT_STATES })
1227
+ );
1228
+ if (v.queue.length) await v.pump((url) => v.svgRes(BASE_SVG)); // deferred base fetch
1229
+ await tick();
1230
+ ok("B18c visible-return poll repaints the reverted idle frame",
1231
+ v.href.includes("1a1a1a") && !v.href.includes("22A06B"), v.href.slice(0, 160));
1232
+ // A hidden->visible transition must NOT clobber an animated frame either:
1233
+ // poll a different state while visible and confirm the listener path stays safe.
1234
+ await v.poll({ state: "running", active: 1, states: DEFAULT_STATES });
1235
+ ok("B18d subsequent polls unaffected after visible-return poll", v.href.includes("FACC15"));
1236
+ }
1237
+
1238
+ // ============================================================================
1239
+ // PART 3 — bigNumUri unit checks (extracted from the template)
1240
+ // ============================================================================
1241
+ console.log("\n=== Part 3: bigNumUri rendering ===");
1242
+ {
1243
+ const bigNumSrc = extractBlock(TEMPLATE, "function bigNumUri(fill) {");
1244
+ // bigNumUri memoizes through cachedUri, so the extracted slice needs that
1245
+ // helper too (plus the map it closes over).
1246
+ const cachedUriSrc = extractBlock(TEMPLATE, "function cachedUri(kind, fill, build) {");
1247
+ function hexToRgbStub(h) {
1248
+ h = String(h).replace("#", "");
1249
+ if (h.length === 3) h = h.charAt(0) + h.charAt(0) + h.charAt(1) + h.charAt(1) + h.charAt(2) + h.charAt(2);
1250
+ const n = parseInt(h, 16);
1251
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
1252
+ }
1253
+ const makeBigNum = (ACTIVE) =>
1254
+ new Function(
1255
+ "ACTIVE", "BIG_NUM_RX", "hexToRgb", "encodeURIComponent", "URI_CACHE",
1256
+ cachedUriSrc + "\n" + bigNumSrc + "\nreturn bigNumUri;"
1257
+ )(ACTIVE, 11, hexToRgbStub, (s) => s, new Map());
1258
+
1259
+ // contrast: dark/red/green -> white text; bright -> dark text
1260
+ for (const [fill, wantFg] of [
1261
+ ["#1a1a1a", "#FFFFFF"],
1262
+ ["#E5484D", "#FFFFFF"],
1263
+ ["#22A06B", "#FFFFFF"],
1264
+ ["#FACC15", "#111111"],
1265
+ ["#ffffff", "#111111"],
1266
+ ]) {
1267
+ const out = makeBigNum(2)(fill);
1268
+ ok("C contrast " + fill + " -> " + wantFg,
1269
+ out.includes('fill="' + wantFg + '"') && out.includes('fill="' + fill + '"'));
1270
+ }
1271
+ ok("C2 one digit fontSize 26", makeBigNum(2)("#FACC15").includes('font-size="26"'));
1272
+ const two = makeBigNum(12)("#FACC15");
1273
+ ok("C3 two digits fontSize 20 + text", two.includes('font-size="20"') && two.includes(">12<"));
1274
+ const capped = makeBigNum(100)("#FACC15");
1275
+ ok("C4 cap 99+ fontSize 15.5", capped.includes(">99+<") && capped.includes('font-size="15.5"'));
1276
+ ok("C5 rounded rect rx=11 full frame", makeBigNum(2)("#FACC15").includes('width="50" height="50" rx="11"'));
1277
+ }
1278
+
1279
+ // ============================================================================
1280
+ // PART 4 — injected script syntax
1281
+ // ============================================================================
1282
+ console.log("\n=== Part 4: injected script syntax ===");
1283
+ {
1284
+ const tmp = path.join(os.tmpdir(), "dsh-injected-check-" + process.pid + ".js");
1285
+ writeFileSync(tmp, builtScript);
1286
+ const out = spawnSync(process.execPath, ["--check", tmp], { encoding: "utf8" });
1287
+ ok("D1 injected script parses", out.status === 0, out.stderr);
1288
+ unlinkSync(tmp);
1289
+ }
1290
+
1291
+ // ============================================================================
1292
+ // PART 5 — demo/badge.html inline script syntax
1293
+ // ============================================================================
1294
+ console.log("\n=== Part 5: demo/badge.html inline script syntax ===");
1295
+ {
1296
+ // demo/ is a repo-only playground (not in the npm `files` allowlist), so an
1297
+ // installed tarball must still be able to run `npm test`.
1298
+ let html = null;
1299
+ try {
1300
+ html = readFileSync(new URL("../demo/badge.html", import.meta.url), "utf8");
1301
+ } catch (e) {
1302
+ html = null;
1303
+ }
1304
+ if (html === null) {
1305
+ console.log("SKIP E1/E2 demo/badge.html not present (published tarball)");
1306
+ } else {
1307
+ const m = html.match(/<script>([\s\S]*?)<\/script>/);
1308
+ ok("E1 demo has an inline script", !!m);
1309
+ if (m) {
1310
+ const tmp = path.join(os.tmpdir(), "badge-inline-" + process.pid + ".js");
1311
+ writeFileSync(tmp, m[1]);
1312
+ const out = spawnSync(process.execPath, ["--check", tmp], { encoding: "utf8" });
1313
+ ok("E2 demo inline script parses + threshold aligned",
1314
+ out.status === 0 && m[1].includes('count() >= 2') && m[1].includes('n < 2'), out.stderr);
1315
+ unlinkSync(tmp);
1316
+ }
1317
+ }
1318
+ }
1319
+
1320
+ // ============================================================================
1321
+ // PART 6 — browser half (lib/client.js): loader contract, card render, writes
1322
+ // ============================================================================
1323
+ // The card bundle is a hand-written ModuleLoader factory with two shell-provided
1324
+ // requires (`react`, `@deepseek-ai/dsh-client-ui-primitives`). Both are stubbed
1325
+ // here — zero dependencies, no build step — so the REAL bundle runs and the
1326
+ // rendered tree plus the scope writes it performs can be asserted.
1327
+ console.log("\n=== Part 6: browser half (lib/client.js) ===");
1328
+ {
1329
+ const CLIENT_SRC = readFileSync(new URL("lib/client.js", REPO), "utf8");
1330
+
1331
+ /** Minimal React stub: createElement trees + the two hooks the card uses. */
1332
+ function makeReactStub() {
1333
+ let states = [];
1334
+ let cursor = 0;
1335
+ let rerender = () => {};
1336
+ const createElement = (type, props, ...children) => ({
1337
+ type,
1338
+ props: { ...(props || {}), children: children.length <= 1 ? children[0] : children },
1339
+ });
1340
+ return {
1341
+ createElement,
1342
+ useState(initial) {
1343
+ const i = cursor++;
1344
+ if (!(i in states)) states[i] = initial;
1345
+ return [states[i], (next) => {
1346
+ states[i] = typeof next === "function" ? next(states[i]) : next;
1347
+ rerender();
1348
+ }];
1349
+ },
1350
+ useSyncExternalStore(subscribe, getSnapshot) {
1351
+ return getSnapshot();
1352
+ },
1353
+ __reset() { states = []; cursor = 0; },
1354
+ __beginRender(fn) { cursor = 0; rerender = fn; },
1355
+ };
1356
+ }
1357
+
1358
+ const primitivesStub = {
1359
+ Button: function Button() { return null; },
1360
+ Input: function Input() { return null; },
1361
+ // Current primitives name icons by stroke weight; the size-suffixed
1362
+ // `…Outline14` is the pre-0.1.7 spelling the bundle still falls back to.
1363
+ IconChevronDownOutlineRegular: function IconChevronDownOutlineRegular() { return null; },
1364
+ DisclosureRow: function DisclosureRow() { return null; },
1365
+ };
1366
+
1367
+ /** Walk a createElement tree, yielding every node (depth first). */
1368
+ function* walk(node) {
1369
+ if (node === null || node === undefined || node === false) return;
1370
+ if (Array.isArray(node)) {
1371
+ for (const child of node) yield* walk(child);
1372
+ return;
1373
+ }
1374
+ if (typeof node !== "object" || !("type" in node)) return;
1375
+ yield node;
1376
+ yield* walk(node.props?.children);
1377
+ }
1378
+ const findAll = (tree, pred) => [...walk(tree)].filter(pred);
1379
+ const textOf = (tree) => [...walk(tree)].filter((n) => typeof n.props?.children === "string")
1380
+ .map((n) => n.props.children).join(" ");
1381
+ const findButton = (tree, label) =>
1382
+ findAll(tree, (n) => n.type === primitivesStub.Button && n.props.children === label)[0] || null;
1383
+
1384
+ // ---- loader + factory ----------------------------------------------------
1385
+ let loaded = null;
1386
+ const loaderWindow = {
1387
+ __ModuleLoader__: {
1388
+ load(def) { loaded = def; },
1389
+ },
1390
+ };
1391
+ const vmCtx = vm.createContext({ window: loaderWindow });
1392
+ vm.runInContext(CLIENT_SRC, vmCtx);
1393
+ ok("F1 bundle registers through window.__ModuleLoader__", !!loaded && loaded.id === "dsh-web-icon-indicator",
1394
+ "id=" + (loaded && loaded.id));
1395
+ ok("F2 bundle declares a factory", !!loaded && typeof loaded.factory === "function");
1396
+
1397
+ const react = makeReactStub();
1398
+ const requireStub = (name) => {
1399
+ if (name === "react") return react;
1400
+ if (name === "@deepseek-ai/dsh-client-ui-primitives") return primitivesStub;
1401
+ throw new Error("unexpected require: " + name);
1402
+ };
1403
+ const client = loaded.factory(requireStub);
1404
+ ok("F3 exports apply + inject", typeof client.apply === "function" && Array.isArray(client.inject));
1405
+ eq("F4 inject list carries only services both host generations provide",
1406
+ client.inject, ["slots", "locale"]);
1407
+
1408
+ // ---- apply() contract ----------------------------------------------------
1409
+ const registered = [];
1410
+ const dictionaries = [];
1411
+ let boundEntryId = null;
1412
+ let servedNamespaces = null;
1413
+ const scope = {
1414
+ getSnapshot: () => snapshot,
1415
+ subscribe: () => () => {},
1416
+ set: async (field, value) => { writes.push({ op: "set", field, value }); },
1417
+ unset: async (field) => { writes.push({ op: "unset", field }); },
1418
+ mutate: async (ops) => { writes.push({ op: "mutate", ops }); },
1419
+ };
1420
+ const writes = [];
1421
+ const snapshot = {
1422
+ status: "ready",
1423
+ writable: true,
1424
+ value: {
1425
+ askingHoldMs: 3500,
1426
+ doneHoldMs: 5000,
1427
+ states: {
1428
+ idle: { effect: "static", colors: ["#1a1a1a"] },
1429
+ running: { effect: "static", colors: ["#FACC15"] },
1430
+ asking: { effect: "blink", colors: ["#E5484D", "#FACC15"], speed: 400 },
1431
+ done: { effect: "static", colors: ["#22A06B"] },
1432
+ },
1433
+ },
1434
+ base: {},
1435
+ user: {},
1436
+ revision: 1,
1437
+ mode: "host",
1438
+ };
1439
+ // A host declares only the slots its own generation knows; `slots.inject`
1440
+ // (the real one) is a no-op for an undeclared name, so the stub must model
1441
+ // that or the dual registration would look like a double render.
1442
+ const makeClientCtx = (opts) => {
1443
+ const declared = new Set(opts.declared || []);
1444
+ const services = opts.services || {};
1445
+ return {
1446
+ get(name) { return services[name]; },
1447
+ locale: {
1448
+ register(ns, dicts) { dictionaries.push({ ns, dicts }); return () => {}; },
1449
+ bind(ns) { return (key) => (dictionaries[0]?.dicts?.en?.[key] ?? key); },
1450
+ },
1451
+ slots: {
1452
+ inject(name, cb) { if (declared.has(name)) cb(); return () => {}; },
1453
+ register(slotSpec, component) { registered.push({ slotSpec, component }); return () => {}; },
1454
+ },
1455
+ effect(fn) { return fn(); },
1456
+ };
1457
+ };
1458
+ const clientCtx = makeClientCtx({
1459
+ declared: ["plugins.row.config"],
1460
+ services: {
1461
+ configForms: {
1462
+ get(entryId) { boundEntryId = entryId; return scope; },
1463
+ whileServed(namespaces, register) { servedNamespaces = namespaces; return register(new Set(namespaces)); },
1464
+ },
1465
+ },
1466
+ });
1467
+ client.apply(clientCtx);
1468
+
1469
+ ok("F6 locale dictionaries registered (en + zh)",
1470
+ dictionaries.length === 1 && !!dictionaries[0].dicts.en && !!dictionaries[0].dicts.zh,
1471
+ "ns=" + (dictionaries[0] && dictionaries[0].ns));
1472
+ ok("F7 modern line: one card registered into plugins.row.config",
1473
+ registered.length === 1 && registered[0].slotSpec.name === "plugins.row.config",
1474
+ "count=" + registered.length + " " + JSON.stringify(registered.map((r) => r.slotSpec.name)));
1475
+ ok("F7b registration is gated on whileServed(entry id)",
1476
+ Array.isArray(servedNamespaces) && servedNamespaces.length === 1 &&
1477
+ servedNamespaces[0] === "dsh-web-icon-indicator",
1478
+ JSON.stringify(servedNamespaces));
1479
+ const slotSpec = registered[0] && registered[0].slotSpec;
1480
+ eq("F8 slot key is <package>#<row id>", slotSpec && slotSpec.key, "dsh-web-icon-indicator#dsh-web-icon-indicator");
1481
+ eq("F9 slot locale namespace", slotSpec && slotSpec.locale, "dsh-web-icon-indicator");
1482
+ // The scope is resolved lazily inside the inject face (the two settings
1483
+ // providers are mutually exclusive), so evaluate it before asserting.
1484
+ const injected = slotSpec && slotSpec.inject();
1485
+ eq("F5 configForms bound to the profile entry id", boundEntryId, "dsh-web-icon-indicator");
1486
+ ok("F10 slot inject provides scope + t",
1487
+ injected && injected.scope === scope && typeof injected.t === "function");
1488
+
1489
+ // ---- legacy client line (DSH <= 0.1.6-alpha.1) ---------------------------
1490
+ // The same bundle, on a host that declares only `settings.plugin.item`,
1491
+ // provides only `settingsScope`, and has neither `configForms` nor a modern
1492
+ // Plugins page. It must register exactly the legacy slot, keyed by the legacy
1493
+ // namespace, and bind the scope the legacy service hands out.
1494
+ {
1495
+ const legacyRegistered = [];
1496
+ const legacyDictionaries = [];
1497
+ let legacySpec = null;
1498
+ const legacyCtx = {
1499
+ get(name) {
1500
+ if (name === "settingsScope") {
1501
+ return { bind(spec) { legacySpec = spec; return scope; } };
1502
+ }
1503
+ return undefined;
1504
+ },
1505
+ locale: {
1506
+ register(ns, dicts) { legacyDictionaries.push({ ns, dicts }); return () => {}; },
1507
+ bind() { return (key) => key; },
1508
+ },
1509
+ slots: {
1510
+ inject(name, cb) { if (name === "settings.plugin.item") cb(); return () => {}; },
1511
+ register(slotSpec, component) { legacyRegistered.push({ slotSpec, component }); return () => {}; },
1512
+ },
1513
+ effect(fn) { return fn(); },
1514
+ };
1515
+ client.apply(legacyCtx);
1516
+ eq("F61 legacy line: exactly one registration", legacyRegistered.length, 1);
1517
+ eq("F61b legacy line: registered into settings.plugin.item",
1518
+ legacyRegistered[0] && legacyRegistered[0].slotSpec.name, "settings.plugin.item");
1519
+ eq("F61c legacy slot key is the legacy namespace",
1520
+ legacyRegistered[0] && legacyRegistered[0].slotSpec.key, "web-icon-indicator");
1521
+ // The scope resolves lazily inside the inject face, so evaluate it first.
1522
+ const legacyInjected = legacyRegistered[0].slotSpec.inject();
1523
+ eq("F61d legacy settingsScope bound to the legacy namespace",
1524
+ legacySpec && legacySpec.namespace, "web-icon-indicator");
1525
+ ok("F61e legacy inject face resolves the legacy scope",
1526
+ legacyInjected && legacyInjected.scope === scope && typeof legacyInjected.t === "function",
1527
+ JSON.stringify({ scope: legacyInjected && legacyInjected.scope === scope }));
1528
+ // And the very same component renders against it.
1529
+ const legacyCard = legacyRegistered[0].component({ scope: scope, t: (k) => k });
1530
+ ok("F61f legacy component renders the card", !!legacyCard && legacyCard.type === "div");
1531
+ }
1532
+
1533
+ // ---- card render ---------------------------------------------------------
1534
+ // React is stubbed, so a re-render is driven explicitly: the card's own
1535
+ // setState calls `rerender()`, which re-invokes the component with the same
1536
+ // props against the same hook storage.
1537
+ const Card = registered[0].component;
1538
+ const renderCard = (props) => {
1539
+ let tree = null;
1540
+ const render = () => { react.__beginRender(render); tree = Card(props); };
1541
+ render();
1542
+ return () => tree;
1543
+ };
1544
+ const t = injected.t;
1545
+ const cardProps = { scope, t };
1546
+ const card = renderCard(cardProps);
1547
+ const collapsed = card();
1548
+ ok("F11 card renders its title", textOf(collapsed).includes("Favicon indicator"),
1549
+ textOf(collapsed).slice(0, 80));
1550
+ ok("F12 card is collapsed by default (no fields yet)",
1551
+ findAll(collapsed, (n) => n.props && n.props.id === "plugin-config-icon-asking-hold").length === 0);
1552
+
1553
+ // Expand it (the header button toggles local state and re-renders).
1554
+ const header = findAll(collapsed, (n) => n.type === "button")[0];
1555
+ ok("F13 header button toggles the card", !!header && typeof header.props.onClick === "function");
1556
+ header.props.onClick();
1557
+ const openTree = card();
1558
+ ok("F14 expanded card exposes both hold fields",
1559
+ findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-asking-hold").length === 1 &&
1560
+ findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-done-hold").length === 1);
1561
+ ok("F15 the animated / signal states get a detail row",
1562
+ ["Running", "Asking", "Done"].every((name) =>
1563
+ findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === name).length === 1),
1564
+ textOf(openTree).slice(0, 120));
1565
+ // Idle has no detail row: its single color is the default-color field and it
1566
+ // takes no animation, so no idle effect/colors/cycle field may render.
1567
+ ok("F15b idle has no detail row and no idle field",
1568
+ findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Idle").length === 0 &&
1569
+ findAll(openTree, (n) => n.props && typeof n.props.id === "string" && n.props.id.indexOf("plugin-config-icon-idle-") === 0).length === 0,
1570
+ textOf(openTree).slice(0, 160));
1571
+ // The row header previews EVERY color of the state: asking blinks between two
1572
+ // colors, so a single dot hid one of them.
1573
+ const askingRowNode = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Asking")[0];
1574
+ const askingIcon = askingRowNode.props.icon;
1575
+ const askingBands = askingIcon && askingIcon.props && askingIcon.props.children;
1576
+ ok("F15c the row icon is ONE chip split into explicit bands (asking: red | yellow)",
1577
+ !!askingIcon && askingIcon.props.style.position === "relative" &&
1578
+ Array.isArray(askingBands) && askingBands.length === 2 &&
1579
+ askingBands.map((b) => b.props.style.background).join(",") === "#E5484D,#FACC15" &&
1580
+ // 1px seam between the bands, and the last band overflows so the right edge
1581
+ // never shows a gap (the chip clips it).
1582
+ askingBands[0].props.style.width === "calc(50% - 1px)" &&
1583
+ askingBands[1].props.style.width === "calc(50% + 1px)",
1584
+ JSON.stringify(askingBands && askingBands.map((b) => b.props.style)));
1585
+ const runningIcon = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Running")[0].props.icon;
1586
+ ok("F15c2 a single-color state is one full-width band",
1587
+ runningIcon.props.children.length === 1 &&
1588
+ runningIcon.props.children[0].props.style.background === "#FACC15" &&
1589
+ runningIcon.props.children[0].props.style.width === "calc(100% + 1px)",
1590
+ JSON.stringify(runningIcon.props.children[0].props.style));
1591
+ // The colors field edits chips: asking carries both colors plus add/remove,
1592
+ // while a static state (no second color in play) offers no add chip.
1593
+ const askingColorsField = findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-asking-colors")[0];
1594
+ const runningColorsField = findAll(openTree, (n) => n.props && n.props.id === "plugin-config-icon-running-colors")[0];
1595
+ ok("F15d a full two-colour list exposes both colours and a remove (no add) affordance",
1596
+ !!askingColorsField && askingColorsField.props.colors.join(",") === "#E5484D,#FACC15" &&
1597
+ askingColorsField.props.canAdd === false && askingColorsField.props.canRemove === true &&
1598
+ !!runningColorsField && runningColorsField.props.canAdd === false,
1599
+ JSON.stringify({
1600
+ asking: askingColorsField && askingColorsField.props.colors,
1601
+ askingAdd: askingColorsField && askingColorsField.props.canAdd,
1602
+ runningAdd: runningColorsField && runningColorsField.props.canAdd,
1603
+ }));
1604
+ const askingRow = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Asking")[0];
1605
+ // `textOf` only collects single-string children; the summary mixes strings
1606
+ // with chip elements, so walk the children recursively for its text.
1607
+ const deepText = (node) => {
1608
+ if (node === null || node === undefined || node === false) return "";
1609
+ if (typeof node === "string" || typeof node === "number") return String(node);
1610
+ if (Array.isArray(node)) return node.map(deepText).join(" ");
1611
+ if (typeof node !== "object" || !("type" in node)) return "";
1612
+ return deepText(node.props && node.props.children);
1613
+ };
1614
+ const askingSummary = deepText(askingRow.props.collapsedContent);
1615
+ const summaryChips = findAll(askingRow.props.collapsedContent, (n) => n.props && n.props.style && n.props.style.background);
1616
+ ok("F16 the summary stays compact text (effect + cycle), no hex and no repeated chips",
1617
+ askingSummary === "Blink · 400ms" && summaryChips.length === 0,
1618
+ JSON.stringify({ text: askingSummary, chips: summaryChips.length }));
1619
+
1620
+ // ---- writes --------------------------------------------------------------
1621
+ const saveBtn = findButton(openTree, "Save");
1622
+ const discardBtn = findButton(openTree, "Discard");
1623
+ const resetBtn = findButton(openTree, "Reset to defaults");
1624
+ ok("F17 footer exposes Save / Discard / Reset", !!saveBtn && !!discardBtn && !!resetBtn);
1625
+ ok("F18 Save is disabled while the form is pristine", saveBtn.props.disabled === true);
1626
+
1627
+ // Reset clears the three namespace keys back to the composition layer. The
1628
+ // handler itself is synchronous; the UI settles on the next microtask.
1629
+ writes.length = 0;
1630
+ resetBtn.props.onClick();
1631
+ await tick();
1632
+ eq("F19 reset unsets every namespace key",
1633
+ writes.map((w) => w.op + ":" + w.field).sort(),
1634
+ ["unset:askingHoldMs", "unset:defaultColor", "unset:doneHoldMs", "unset:states"]);
1635
+ ok("F19b form returns to pristine after reset", findButton(card(), "Save").props.disabled === true);
1636
+
1637
+ // ---- a staged edit saves as ONE atomic mutation --------------------------
1638
+ writes.length = 0;
1639
+ const byId = (tree, id) => findAll(tree, (n) => n.props && n.props.id === id)[0] || null;
1640
+ const holdField = byId(openTree, "plugin-config-icon-asking-hold");
1641
+ ok("F20 hold field bound to the resolved value", holdField && holdField.props.value === "3500",
1642
+ holdField && holdField.props.value);
1643
+ holdField.props.onChange("2500");
1644
+ const dirtyTree = card();
1645
+ const dirtySave = findButton(dirtyTree, "Save");
1646
+ ok("F21 editing a field marks the form dirty", dirtySave.props.disabled === false);
1647
+ ok("F22 unsaved badge appears", textOf(dirtyTree).includes("Unsaved"));
1648
+ dirtySave.props.onClick();
1649
+ await tick();
1650
+ eq("F23 save issues one atomic mutate with path ops",
1651
+ writes.length === 1 && writes[0].op === "mutate" ? writes[0].ops : writes,
1652
+ [{ op: "set", path: ["askingHoldMs"], value: 2500 }]);
1653
+
1654
+ // A per-state edit rebuilds the whole `states` entry in the same mutate.
1655
+ // Idle has no row (F15b), so this uses Running.
1656
+ writes.length = 0;
1657
+ const runningRow = findAll(openTree, (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Running")[0];
1658
+ runningRow.props.onToggle(); // expand the running row (re-render is explicit)
1659
+ const runningOpenTree = card();
1660
+ const runningEffect = byId(runningOpenTree, "plugin-config-icon-running-effect");
1661
+ ok("F24 expanded state row exposes its effect select", !!runningEffect && runningEffect.props.value === "static",
1662
+ runningEffect && runningEffect.props.value);
1663
+ runningEffect.props.onChange("breath");
1664
+ findButton(card(), "Save").props.onClick();
1665
+ await tick();
1666
+ const stateOp = writes[0] && writes[0].ops && writes[0].ops[0];
1667
+ eq("F25 state edit saves the whole states object at its path",
1668
+ stateOp && stateOp.op === "set" && stateOp.path.join(".") + "=" + stateOp.value.running.effect,
1669
+ "states=breath");
1670
+ ok("F26 only edited states are written to the user layer",
1671
+ Object.keys(stateOp.value).length === 1 && !!stateOp.value.running,
1672
+ JSON.stringify(stateOp.value));
1673
+
1674
+ // A previously saved override for another state must survive an edit of a
1675
+ // different state: `scope.set('states', …)` replaces the whole field, so the
1676
+ // card rebuilds it from the raw user layer. An idle entry the card cannot
1677
+ // edit (e.g. hand-written into settings.yaml) must survive untouched too.
1678
+ writes.length = 0;
1679
+ snapshot.user = {
1680
+ states: {
1681
+ done: { effect: "rainbow", colors: ["#FF0000"] },
1682
+ idle: { effect: "breath", colors: ["#111111", "#DDDDDD"] },
1683
+ },
1684
+ };
1685
+ const runningAgain = byId(card(), "plugin-config-icon-running-effect");
1686
+ runningAgain.props.onChange("bounce");
1687
+ findButton(card(), "Save").props.onClick();
1688
+ await tick();
1689
+ const carried = writes[0] && writes[0].ops && writes[0].ops[0] && writes[0].ops[0].value;
1690
+ ok("F27 an existing user-layer override is carried through",
1691
+ carried && carried.done && carried.done.effect === "rainbow" && carried.running.effect === "bounce",
1692
+ JSON.stringify(carried));
1693
+ ok("F27b a hand-written idle entry survives untouched",
1694
+ carried && carried.idle && carried.idle.effect === "breath" && carried.idle.colors.length === 2,
1695
+ JSON.stringify(carried && carried.idle));
1696
+
1697
+ // ---- default color: field, live warning, one-atomic-mutate save -----------
1698
+ writes.length = 0;
1699
+ const defaultField = byId(card(), "plugin-config-icon-default-color");
1700
+ ok("F28 with no override configured the field shows the idle color chip",
1701
+ !!defaultField && Array.isArray(defaultField.props.colors) && defaultField.props.colors[0] === "#1a1a1a",
1702
+ JSON.stringify(defaultField && defaultField.props.colors));
1703
+ const paletteLabel = findAll(card(), (n) => n.props && n.props.children === "Palette")[0];
1704
+ const paletteRow = findAll(card(), (n) => Array.isArray(n.props && n.props.children) &&
1705
+ n.props.children.indexOf(paletteLabel) !== -1)[0];
1706
+ const paletteItems = (paletteRow && paletteRow.props.children[1]) || [];
1707
+ const paletteBands = (item) => item.props.children[1].props.children.map((b) => b.props.style.background);
1708
+ ok("F28b the palette lists every state as name-then-chip, one banded chip for asking",
1709
+ !!paletteLabel && paletteItems.length === 4 &&
1710
+ paletteItems.every((item) => typeof item.props.children[0] === "string" &&
1711
+ item.props.children[1] && Array.isArray(item.props.children[1].props.children)) &&
1712
+ paletteItems.some((item) => paletteBands(item).join(",") === "#E5484D,#FACC15"),
1713
+ JSON.stringify(paletteItems.map((item) => [item.props.children[0], paletteBands(item)])));
1714
+ // The card shows colors as chips; the hex value lives in the native picker.
1715
+ // No rendered text may contain a hex literal (warnings are the only place a
1716
+ // color code appears, and there is none configured at this point).
1717
+ ok("F28d the card renders colors as chips, never as hex text",
1718
+ !/#[0-9a-fA-F]{3,6}/.test(deepText(card())), deepText(card()).slice(0, 200));
1719
+ // Every native color picker is an invisible absolute overlay inside its 14x14
1720
+ // chip: without that style the UA widget renders at its own size and overflows
1721
+ // the row (the stray control seen next to the text field).
1722
+ // The stubbed React never invokes function components, so materialize the
1723
+ // ColorField elements before looking for their native color inputs.
1724
+ const colorFieldNodes = findAll(card(), (n) => n.props && typeof n.props.onColors === "function" && typeof n.props.id === "string");
1725
+ const colorInputs = colorFieldNodes
1726
+ .map((n) => n.type(n.props))
1727
+ .filter(Boolean)
1728
+ .reduce((acc, tree) => acc.concat(findAll(tree, (n) => n.type === "input" && n.props && n.props.type === "color")), []);
1729
+ ok("F28c every colour field keeps at least one native picker (5 chips today)",
1730
+ colorInputs.length === 5 &&
1731
+ colorFieldNodes.every((n) => findAll(n.type(n.props), (c) => c.type === "input" && c.props.type === "color").length >= 1),
1732
+ "inputs=" + colorInputs.length);
1733
+ ok("F28c every native color picker is an invisible overlay",
1734
+ colorInputs.length >= 1 && colorInputs.every((n) => n.props.style &&
1735
+ n.props.style.position === "absolute" && n.props.style.opacity === 0 &&
1736
+ n.props.style.width === "100%" && n.props.style.height === "100%"),
1737
+ JSON.stringify(colorInputs.map((n) => n.props.style)));
1738
+
1739
+ // Typing a color equal to the running state raises the strong warning live
1740
+ // (before any save) — advisory only, so Save stays enabled below.
1741
+ // Only the warning paragraphs (`role="status"`) count below — the palette row
1742
+ // renders the state names too, so `textOf` alone would be vacuous.
1743
+ const warnTexts = (tree) => findAll(tree, (n) => n.props && n.props.role === "status" && typeof n.props.children === "string")
1744
+ .map((n) => n.props.children);
1745
+ defaultField.props.onColors(["#FACC15"]);
1746
+ const warnedTree = card();
1747
+ ok("F29 a near-state color warns before saving",
1748
+ warnTexts(warnedTree).some((line) => line.includes("Nearly identical") && line.includes("#FACC15") && line.includes("Running")),
1749
+ JSON.stringify(warnTexts(warnedTree)));
1750
+
1751
+ findButton(warnedTree, "Save").props.onClick();
1752
+ await tick();
1753
+ const dcOps = (writes[0] && writes[0].ops) || [];
1754
+ ok("F30 save writes defaultColor in one atomic mutate despite the warning",
1755
+ writes.length === 1 &&
1756
+ dcOps.some((o) => o.op === "set" && o.path.join(".") === "defaultColor" && o.value === "#FACC15"),
1757
+ JSON.stringify(writes));
1758
+
1759
+ // A distinct color clears the warning again.
1760
+ const distinctField = byId(card(), "plugin-config-icon-default-color");
1761
+ distinctField.props.onColors(["#5B8DEF"]);
1762
+ ok("F31 a distinct color shows no warning", warnTexts(card()).length === 0, JSON.stringify(warnTexts(card())));
1763
+
1764
+ // ---- an override configured outside this card ----------------------------
1765
+ // The scope resolves `states` and `defaultColor` independently, so the
1766
+ // configured override (base or user layer) is NOT visible in
1767
+ // value.states.idle.colors[0]: the field, the palette and the warnings must
1768
+ // read the key itself.
1769
+ snapshot.user = {};
1770
+ snapshot.value.defaultColor = "#FACC15";
1771
+ // The stub only re-renders on a hook setState, so snapshot mutations must
1772
+ // land BEFORE this click (which also drops the '#5B8DEF' draft).
1773
+ findButton(card(), "Discard").props.onClick();
1774
+ const overrideField = byId(card(), "plugin-config-icon-default-color");
1775
+ ok("F32 the field shows the configured defaultColor chip, not the scope's idle color",
1776
+ overrideField.props.colors[0] === "#FACC15", JSON.stringify(overrideField.props.colors));
1777
+ ok("F33 the configured defaultColor is what the warnings judge",
1778
+ warnTexts(card()).some((line) => line.includes("Running") && line.includes("#FACC15")),
1779
+ JSON.stringify(warnTexts(card())));
1780
+
1781
+ // A default-color edit and a state edit land in ONE atomic mutate.
1782
+ snapshot.user = {};
1783
+ snapshot.value.defaultColor = "#FACC15";
1784
+ writes.length = 0;
1785
+ byId(card(), "plugin-config-icon-default-color").props.onColors(["#5B8DEF"]);
1786
+ byId(card(), "plugin-config-icon-running-effect").props.onChange("blink");
1787
+ findButton(card(), "Save").props.onClick();
1788
+ await tick();
1789
+ const combinedOps = (writes[0] && writes[0].ops) || [];
1790
+ ok("F34 a default-color edit and a state edit share one atomic mutate",
1791
+ writes.length === 1 &&
1792
+ combinedOps.some((o) => o.op === "set" && o.path.join(".") === "defaultColor" && o.value === "#5B8DEF") &&
1793
+ combinedOps.some((o) => o.op === "set" && o.path.join(".") === "states"),
1794
+ JSON.stringify(writes));
1795
+
1796
+ // Editing a state must never touch the default color: idle is not a card
1797
+ // state, so no idle row can drag the override into the write.
1798
+ snapshot.value.defaultColor = "#FACC15";
1799
+ writes.length = 0;
1800
+ byId(card(), "plugin-config-icon-running-colors").props.onColors(["#3366FF"]);
1801
+ findButton(card(), "Save").props.onClick();
1802
+ await tick();
1803
+ const stateOnlyOps = (writes[0] && writes[0].ops) || [];
1804
+ ok("F35 a state edit leaves the default color alone",
1805
+ stateOnlyOps.some((o) => o.op === "set" && o.path.join(".") === "states") &&
1806
+ !stateOnlyOps.some((o) => o.path.join(".") === "defaultColor"),
1807
+ JSON.stringify(writes));
1808
+
1809
+ // Host parity for malformed state colors: resolveConfig drops them and falls
1810
+ // back to the built-in color, so the card must judge that same color.
1811
+ snapshot.value.states = Object.assign({}, snapshot.value.states, { running: { effect: "static", colors: ["red"] } });
1812
+ snapshot.value.defaultColor = "#FACC15";
1813
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
1814
+ ok("F36 the card falls back to the built-in state color like the host",
1815
+ warnTexts(card()).some((line) => line.includes("Running") && line.includes("#FACC15")),
1816
+ JSON.stringify(warnTexts(card())));
1817
+
1818
+ // ---- only the colors the effect uses are listed (and saved) ---------------
1819
+ const askingColorsBefore = byId(card(), "plugin-config-icon-asking-colors");
1820
+ ok("F37 a two-color effect (blink) lists both colors and has no room to add",
1821
+ askingColorsBefore.props.colors.join(",") === "#E5484D,#FACC15" && askingColorsBefore.props.canAdd === false,
1822
+ JSON.stringify({ colors: askingColorsBefore.props.colors, canAdd: askingColorsBefore.props.canAdd }));
1823
+
1824
+ // blink -> static: the unused second color must disappear from the field...
1825
+ byId(card(), "plugin-config-icon-asking-effect").props.onChange("static");
1826
+ const singleField = byId(card(), "plugin-config-icon-asking-colors");
1827
+ ok("F38 a single-color effect shows exactly one chip and no add chip",
1828
+ singleField.props.colors.join(",") === "#E5484D" && singleField.props.canAdd === false,
1829
+ JSON.stringify({ colors: singleField.props.colors, canAdd: singleField.props.canAdd }));
1830
+ // ...and from the stored config: no invisible second color survives the save.
1831
+ writes.length = 0;
1832
+ findButton(card(), "Save").props.onClick();
1833
+ await tick();
1834
+ const statesWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
1835
+ ok("F38b saving a single-color effect drops the unused second color",
1836
+ !!statesWrite && statesWrite.value.asking.effect === "static" &&
1837
+ statesWrite.value.asking.colors.join(",") === "#E5484D",
1838
+ JSON.stringify(statesWrite && statesWrite.value.asking));
1839
+
1840
+ // rainbow needs no color configuration: the field and the row icon show the
1841
+ // hue wheel instead of pickers.
1842
+ byId(card(), "plugin-config-icon-asking-effect").props.onChange("rainbow");
1843
+ const rainbowField = byId(card(), "plugin-config-icon-asking-colors");
1844
+ const rainbowIcon = findAll(card(), (n) => n.type === primitivesStub.DisclosureRow && n.props.title === "Asking")[0].props.icon;
1845
+ const rainbowTree = rainbowField.type(rainbowField.props);
1846
+ // The stub collapses a single child to the child itself, so normalize it.
1847
+ const iconLayers = Array.isArray(rainbowIcon.props.children) ? rainbowIcon.props.children : [rainbowIcon.props.children];
1848
+ const rainbowInputs = findAll(rainbowTree, (n) => n.type === "input" && n.props.type === "color");
1849
+ ok("F39 rainbow shows the hue wheel plus one optional starting-hue chip",
1850
+ rainbowField.props.rainbow === true &&
1851
+ // exactly one colour is carried: the hue seed the browser sweeps from
1852
+ rainbowField.props.colors.length === 1 && rainbowField.props.canAdd === false &&
1853
+ rainbowInputs.length === 1 &&
1854
+ // no add / remove affordances for a colour list that does not exist
1855
+ findAll(rainbowTree, (n) => n.type === "button").length === 0 &&
1856
+ // the field preview is a 20px disc
1857
+ findAll(rainbowTree, (n) => n.props && n.props.style && n.props.style.borderRadius === 999 &&
1858
+ n.props.style.width === 20 && n.props.style.height === 20).length === 1 &&
1859
+ iconLayers.length === 1 &&
1860
+ String(iconLayers[0].props.style.background).indexOf("linear-gradient(135deg,") === 0 &&
1861
+ String(iconLayers[0].props.style.backgroundImage).indexOf("conic-gradient(from 0deg,") === 0,
1862
+ JSON.stringify({
1863
+ rainbow: rainbowField.props.rainbow,
1864
+ colors: rainbowField.props.colors,
1865
+ inputs: rainbowInputs.length,
1866
+ buttons: findAll(rainbowTree, (n) => n.type === "button").length,
1867
+ icon: iconLayers[0] && iconLayers[0].props.style.background,
1868
+ }));
1869
+
1870
+ // The starting-hue chip rewrites colors[0] alone (rainbow never carries a
1871
+ // second colour), so the sweep begins at the picked hue.
1872
+ rainbowInputs[0].props.onChange({ target: { value: "#0066ff" } });
1873
+ writes.length = 0;
1874
+ findButton(card(), "Save").props.onClick();
1875
+ await tick();
1876
+ const seedWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
1877
+ ok("F39b the starting-hue chip saves a single-colour rainbow state",
1878
+ !!seedWrite && seedWrite.value.asking.effect === "rainbow" &&
1879
+ seedWrite.value.asking.colors.join(",") === "#0066ff",
1880
+ JSON.stringify(seedWrite && seedWrite.value.asking));
1881
+
1882
+ // rainbow keeps exactly one stored colour: the hue seed the browser's
1883
+ // frameColor() starts from. Switching a two-colour state (asking is blink
1884
+ // with #E5484D ⇄ #FACC15) to rainbow must TRIM the stored list to that one
1885
+ // seed — this is the save-side half of "only what the effect uses is kept".
1886
+ byId(card(), "plugin-config-icon-asking-effect").props.onChange("rainbow");
1887
+ writes.length = 0;
1888
+ findButton(card(), "Save").props.onClick();
1889
+ await tick();
1890
+ const rainbowWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
1891
+ ok("F40 switching blink -> rainbow trims the stored colours to the single hue seed",
1892
+ !!rainbowWrite && rainbowWrite.value.asking.effect === "rainbow" &&
1893
+ rainbowWrite.value.asking.colors.join(",") === "#E5484D",
1894
+ JSON.stringify(rainbowWrite && rainbowWrite.value.asking));
1895
+
1896
+ // The card duplicates the host's built-in per-state colours (it cannot import
1897
+ // lib/index.js). Pin the copy to plugin.config.states so the two can never
1898
+ // drift: a drift would make the card warn about a colour the host never paints.
1899
+ let cardStates = null;
1900
+ try {
1901
+ // extractBlock() balances braces, so a reformat or a nested literal cannot
1902
+ // break this the way a hand-rolled substring slice would.
1903
+ const block = extractBlock(CLIENT_SRC, "var DEFAULT_STATES = ");
1904
+ cardStates = eval("(" + block.slice(block.indexOf("{")) + ")");
1905
+ } catch (err) {
1906
+ cardStates = null; // reported as a failure below, never an aborted suite
1907
+ }
1908
+ // The ΔE thresholds and the accepted-hex regex are duplicated by design (no
1909
+ // shared module, no build step): pin them together so tuning one half cannot
1910
+ // silently change the other's verdicts.
1911
+ const constOf = (src, name) => {
1912
+ const m = src.match(new RegExp("(?:const|let|var)\\s+" + name + "\\s*=\\s*([^;]+);"));
1913
+ return m ? m[1].trim() : null;
1914
+ };
1915
+ const sharedConstants = ["SIMILAR_DE_STRONG", "SIMILAR_DE_WARN", "RAINBOW_CHROMA_MIN", "DEFAULT_COLOR_RX"];
1916
+ ok("F42 the duplicated thresholds / hex regex match between the two halves",
1917
+ sharedConstants.every((n) => constOf(SRC, n) !== null && constOf(SRC, n) === constOf(CLIENT_SRC, n)),
1918
+ JSON.stringify(sharedConstants.map((n) => [n, constOf(SRC, n), constOf(CLIENT_SRC, n)])));
1919
+
1920
+ // The host's DEFAULTS.states, read straight out of the source: the plugin's
1921
+ // `Config` schema is a schemastery stub here, so the defaults live in the
1922
+ // source text (the card keeps its own copy of this table on purpose).
1923
+ const hostStates = (() => {
1924
+ const marker = SRC.indexOf("states: {", SRC.indexOf("const DEFAULTS"));
1925
+ const open = SRC.indexOf("{", marker);
1926
+ let depth = 0;
1927
+ let i = open;
1928
+ for (; i < SRC.length; i++) {
1929
+ const c = SRC[i];
1930
+ if (c === "{") depth++;
1931
+ else if (c === "}") { depth--; if (depth === 0) break; }
1932
+ }
1933
+ return vm.runInNewContext("(" + SRC.slice(open, i + 1) + ")", {});
1934
+ })();
1935
+ ok("F41 the card's built-in state table mirrors the host DEFAULTS (effect + colors + speed)",
1936
+ !!cardStates &&
1937
+ Object.keys(cardStates).length === Object.keys(hostStates).length &&
1938
+ Object.keys(cardStates).every((k) => {
1939
+ const host = hostStates[k];
1940
+ return cardStates[k].effect === host.effect &&
1941
+ JSON.stringify(cardStates[k].colors) === JSON.stringify(host.colors) &&
1942
+ cardStates[k].speed === host.speed;
1943
+ }),
1944
+ JSON.stringify({ card: cardStates, host: hostStates }));
1945
+
1946
+ // ---- the "+" chip: derived seed, no duplicates, room-only -----------------
1947
+ // done is static (one colour) and has room for a second; switching it to
1948
+ // breath is the exact state that should offer the add chip.
1949
+ byId(card(), "plugin-config-icon-done-effect").props.onChange("breath");
1950
+ const addField = byId(card(), "plugin-config-icon-done-colors");
1951
+ ok("F43 a single-colour list has room, so the add chip appears",
1952
+ addField.props.canAdd === true && addField.props.colors.join(",") === "#22A06B",
1953
+ JSON.stringify({ canAdd: addField.props.canAdd, colors: addField.props.colors }));
1954
+ const addTree = addField.type(addField.props);
1955
+ const addInputs = findAll(addTree, (n) => n.type === "input" && n.props.type === "color");
1956
+ // The add picker opens on the colour the browser derives anyway
1957
+ // (mix(#22A06B, black, 35%)), so confirming it as-is is meaningful.
1958
+ ok("F43b the add picker opens on the derived second colour",
1959
+ addInputs.length === 2 && addInputs[1].props.value === "#166846",
1960
+ JSON.stringify(addInputs.map((i) => i.props.value)));
1961
+ // Re-materialize before every edit: a handler captured from an earlier tree
1962
+ // closes over the OLD colour list, which would mask a real append bug.
1963
+ const addInputsOf = () => {
1964
+ const field = byId(card(), "plugin-config-icon-done-colors");
1965
+ return findAll(field.type(field.props), (n) => n.type === "input" && n.props.type === "color");
1966
+ };
1967
+ addInputsOf()[1].props.onChange({ target: { value: "#22a06b" } }); // already present, lower-case
1968
+ ok("F43c an already-present colour is not appended",
1969
+ byId(card(), "plugin-config-icon-done-colors").props.colors.length === 1,
1970
+ JSON.stringify(byId(card(), "plugin-config-icon-done-colors").props.colors));
1971
+ addInputsOf()[1].props.onChange({ target: { value: "#00c8ff" } });
1972
+ ok("F43d a new colour is appended as the second one",
1973
+ byId(card(), "plugin-config-icon-done-colors").props.colors.join(",") === "#22A06B,#00c8ff",
1974
+ JSON.stringify(byId(card(), "plugin-config-icon-done-colors").props.colors));
1975
+
1976
+ // ---- reset also neutralizes a COMPOSITION-ENTRY defaultColor --------------
1977
+ // `scope.unset` cannot reach the base layer, so a base-provided defaultColor
1978
+ // would survive "Reset to defaults" and the field would look untouched.
1979
+ snapshot.base = { defaultColor: "#FACC15" };
1980
+ snapshot.user = {};
1981
+ snapshot.value.defaultColor = "#FACC15";
1982
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
1983
+ writes.length = 0;
1984
+ findButton(card(), "Reset to defaults").props.onClick();
1985
+ await tick();
1986
+ const idleOwnColor = snapshot.value.states.idle.colors[0];
1987
+ const resetOps = writes.map((w) => w.op + ":" + w.field + (w.value !== undefined ? "=" + w.value : ""));
1988
+ ok("F44 a reset rewrites a base-layer defaultColor to the idle colour",
1989
+ resetOps.indexOf("set:defaultColor=" + idleOwnColor) !== -1 &&
1990
+ resetOps.indexOf("unset:states") !== -1,
1991
+ JSON.stringify(resetOps));
1992
+
1993
+ // ---- clear-override persists as an unset ----------------------------------
1994
+ snapshot.base = {};
1995
+ snapshot.user = { defaultColor: "#FACC15" };
1996
+ snapshot.value.defaultColor = "#FACC15";
1997
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
1998
+ const clearField = byId(card(), "plugin-config-icon-default-color");
1999
+ ok("F45 the clear-override control is offered for a user-layer value",
2000
+ typeof clearField.props.onClear === "function", String(clearField.props.clearLabel));
2001
+ clearField.props.onClear();
2002
+ writes.length = 0;
2003
+ findButton(card(), "Save").props.onClick();
2004
+ await tick();
2005
+ const clearOps = ((writes[0] && writes[0].ops) || []);
2006
+ ok("F45b clearing the override saves an unset of defaultColor",
2007
+ clearOps.some((o) => o.op === "unset" && o.path.join(".") === "defaultColor"),
2008
+ JSON.stringify(writes));
2009
+
2010
+ // ---- a save with nothing to write settles the form ------------------------
2011
+ snapshot.user = {};
2012
+ snapshot.value.defaultColor = null;
2013
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // same as resolved
2014
+ writes.length = 0;
2015
+ const noopSave = findButton(card(), "Save");
2016
+ ok("F46 a draft equal to the resolved value keeps Save enabled", noopSave.props.disabled === false);
2017
+ noopSave.props.onClick();
2018
+ await tick();
2019
+ ok("F46b a no-op save writes nothing and clears the draft",
2020
+ writes.length === 0 && findButton(card(), "Save").props.disabled === true,
2021
+ JSON.stringify({ writes, disabled: findButton(card(), "Save").props.disabled }));
2022
+
2023
+ // ---- the rainbow warning needs a CHROMATIC default ------------------------
2024
+ snapshot.value.defaultColor = "#1a1a1a"; // neutral: no hue to collide with
2025
+ snapshot.value.states.done = { effect: "rainbow", colors: ["#22A06B"] };
2026
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2027
+ const neutralLines = warnTexts(card());
2028
+ ok("F47 a neutral default colour raises no rainbow warning",
2029
+ neutralLines.length === 0, JSON.stringify(neutralLines));
2030
+ snapshot.value.defaultColor = "#E5484D";
2031
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2032
+ ok("F47b a chromatic default colour raises the rainbow warning",
2033
+ warnTexts(card()).some((line) => line.includes("rainbow")), JSON.stringify(warnTexts(card())));
2034
+
2035
+ // ---- the warning judges the fills the effect actually paints --------------
2036
+ // blink with a single colour: the browser derives mix(c0, #000, 35%).
2037
+ snapshot.value.defaultColor = "#952F32";
2038
+ snapshot.value.states.asking = { effect: "blink", colors: ["#E5484D"], speed: 400 };
2039
+ snapshot.value.states.done = { effect: "static", colors: ["#22A06B"] };
2040
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2041
+ ok("F48 the derived second fill of a one-colour blink is compared",
2042
+ warnTexts(card()).some((line) => line.includes("Asking") && line.includes("#952f32")),
2043
+ JSON.stringify(warnTexts(card())));
2044
+ // breath interpolates: the midpoint of #E5484D ⇄ #FFFFFF is a real frame.
2045
+ snapshot.value.defaultColor = "#F2A4A6";
2046
+ snapshot.value.states.asking = { effect: "breath", colors: ["#E5484D", "#FFFFFF"] };
2047
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2048
+ ok("F48b the breath interpolation is sampled",
2049
+ warnTexts(card()).some((line) => line.includes("Asking") && line.includes("#f2a4a6")),
2050
+ JSON.stringify(warnTexts(card())));
2051
+
2052
+ // ---- a partially invalid stored list keeps its valid entries --------------
2053
+ snapshot.value.defaultColor = "#FF0000";
2054
+ snapshot.value.states.asking = { effect: "static", colors: ["#FF0000", "red"] };
2055
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2056
+ const partialField = byId(card(), "plugin-config-icon-asking-colors");
2057
+ const partialLines = warnTexts(card());
2058
+ ok("F49 the card keeps the valid entries of a partially invalid list",
2059
+ partialField.props.colors.join(",") === "#FF0000" &&
2060
+ partialLines.some((line) => line.includes("Asking") && line.toLowerCase().includes("#ff0000")) &&
2061
+ !partialLines.some((line) => line.toLowerCase().includes("#facc15")),
2062
+ JSON.stringify({ colors: partialField.props.colors, lines: partialLines }));
2063
+
2064
+ // ---- a PARTIAL resolved states dict must read like what the host paints ----
2065
+ // The card writes only the states it drafted, so `value.states` really can
2066
+ // omit `asking`; the host still blinks it with the built-in config, and the
2067
+ // card used to show "Static" + a dashed chip there — then SAVE that "static"
2068
+ // over the user's config on the next colour edit.
2069
+ snapshot.base = {};
2070
+ snapshot.user = { states: { running: { effect: "bounce", colors: ["#FF0000"] } } };
2071
+ snapshot.value = {
2072
+ askingHoldMs: 3500,
2073
+ doneHoldMs: 5000,
2074
+ defaultColor: null,
2075
+ states: { running: { effect: "bounce", colors: ["#FF0000"] } },
2076
+ };
2077
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500"); // force a render
2078
+ ok("F50 a state missing from the resolved dict falls back to the built-in config",
2079
+ byId(card(), "plugin-config-icon-asking-effect").props.value === "blink" &&
2080
+ byId(card(), "plugin-config-icon-asking-colors").props.colors.join(",") === "#E5484D,#FACC15",
2081
+ JSON.stringify({
2082
+ effect: byId(card(), "plugin-config-icon-asking-effect").props.value,
2083
+ colors: byId(card(), "plugin-config-icon-asking-colors").props.colors,
2084
+ }));
2085
+ writes.length = 0;
2086
+ byId(card(), "plugin-config-icon-asking-colors").props.onColors(["#123456", "#FACC15"]);
2087
+ findButton(card(), "Save").props.onClick();
2088
+ await tick();
2089
+ const partialWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
2090
+ ok("F50b a colour edit on that state keeps the built-in effect",
2091
+ !!partialWrite && partialWrite.value.asking.effect === "blink" &&
2092
+ partialWrite.value.asking.colors.join(",") === "#123456,#FACC15",
2093
+ JSON.stringify(partialWrite && partialWrite.value.asking));
2094
+
2095
+ // ---- those two host behaviours again, on the card side --------------------
2096
+ snapshot.user = {};
2097
+ snapshot.value = {
2098
+ askingHoldMs: 3500,
2099
+ doneHoldMs: 5000,
2100
+ defaultColor: "#005aa5",
2101
+ states: { running: { effect: "breath", colors: ["#0000ff", "#00ff00"] } },
2102
+ };
2103
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2104
+ const breathLine = warnTexts(card()).find((line) => line.includes("Running"));
2105
+ const breathDe = breathLine ? Number((breathLine.match(/ΔE ([0-9.]+)/) || [])[1]) : NaN;
2106
+ ok("F51 the card samples the breath gradient as densely as the host",
2107
+ !!breathLine && breathDe < 8, JSON.stringify({ breathLine, breathDe }));
2108
+
2109
+ snapshot.value = {
2110
+ askingHoldMs: 3500,
2111
+ doneHoldMs: 5000,
2112
+ defaultColor: "#ff0000",
2113
+ states: { idle: { effect: "rainbow", colors: ["#1a1a1a"] } },
2114
+ };
2115
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2116
+ const idleRainbowLines = warnTexts(card());
2117
+ ok("F52 idle running rainbow warns on the card side too (host parity)",
2118
+ idleRainbowLines.length === 1 && idleRainbowLines[0].includes("rainbow") && idleRainbowLines[0].includes("Idle"),
2119
+ JSON.stringify(idleRainbowLines));
2120
+
2121
+ // ---- reset leaves the deployment value one click away ---------------------
2122
+ snapshot.base = { defaultColor: "#FACC15" };
2123
+ snapshot.user = {};
2124
+ snapshot.value = { askingHoldMs: 3500, doneHoldMs: 5000, defaultColor: "#FACC15", states: {} };
2125
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2126
+ const baseOnlyField = byId(card(), "plugin-config-icon-default-color");
2127
+ ok("F53 a base-layer value offers no clear-override control",
2128
+ !baseOnlyField.props.onClear, String(baseOnlyField.props.clearLabel));
2129
+ writes.length = 0;
2130
+ findButton(card(), "Reset to defaults").props.onClick();
2131
+ await tick();
2132
+ // The reset writes an explicit user-layer value (the idle colour), so the
2133
+ // control that takes the user back to the deployment value comes back.
2134
+ snapshot.user = { defaultColor: "#1a1a1a" };
2135
+ snapshot.value.defaultColor = "#1a1a1a";
2136
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2137
+ ok("F53b after the reset the clear-override control reappears",
2138
+ typeof byId(card(), "plugin-config-icon-default-color").props.onClear === "function",
2139
+ String(byId(card(), "plugin-config-icon-default-color").props.clearLabel));
2140
+
2141
+ // ---- the cycle field follows the effect -----------------------------------
2142
+ snapshot.base = {};
2143
+ snapshot.user = {};
2144
+ snapshot.value = { askingHoldMs: 3500, doneHoldMs: 5000, defaultColor: null, states: {} };
2145
+ byId(card(), "plugin-config-icon-asking-effect").props.onChange("blink");
2146
+ const speedField = byId(card(), "plugin-config-icon-asking-speed");
2147
+ ok("F54 an animated state exposes its cycle field, seeded from the built-in default",
2148
+ !!speedField && speedField.props.value === "400",
2149
+ JSON.stringify(speedField && speedField.props.value));
2150
+ speedField.props.onChange("");
2151
+ writes.length = 0;
2152
+ findButton(card(), "Save").props.onClick();
2153
+ await tick();
2154
+ const speedWrite = ((writes[0] && writes[0].ops) || []).find((o) => o.path.join(".") === "states");
2155
+ ok("F54b a blank cycle drops the key (the built-in cycle applies again)",
2156
+ !!speedWrite && speedWrite.value.asking.speed === undefined &&
2157
+ speedWrite.value.asking.effect === "blink",
2158
+ JSON.stringify(speedWrite && speedWrite.value.asking));
2159
+ byId(card(), "plugin-config-icon-done-effect").props.onChange("static");
2160
+ ok("F54c a static effect hides the cycle field",
2161
+ !byId(card(), "plugin-config-icon-done-speed"));
2162
+
2163
+ // ---- the add chip's edge cases the mutation run proved unpinned -------------
2164
+ // #0c8 and #00cc88 are the SAME colour: picking the long spelling must not
2165
+ // append a duplicate band.
2166
+ snapshot.user = {};
2167
+ snapshot.value = {
2168
+ askingHoldMs: 3500,
2169
+ doneHoldMs: 5000,
2170
+ defaultColor: null,
2171
+ states: { done: { effect: "breath", colors: ["#0c8"] } },
2172
+ };
2173
+ // F54c left an unsaved "static" draft on done -> drop it, or the field would
2174
+ // render for that draft instead of the stored breath effect.
2175
+ findButton(card(), "Discard").props.onClick();
2176
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2177
+ const addInputsNow = () => {
2178
+ const field = byId(card(), "plugin-config-icon-done-colors");
2179
+ return findAll(field.type(field.props), (n) => n.type === "input" && n.props.type === "color");
2180
+ };
2181
+ addInputsNow()[1].props.onChange({ target: { value: "#00cc88" } });
2182
+ ok("F55 a 3-digit spelling counts as the same colour",
2183
+ byId(card(), "plugin-config-icon-done-colors").props.colors.length === 1,
2184
+ JSON.stringify(byId(card(), "plugin-config-icon-done-colors").props.colors));
2185
+
2186
+ // An unusable stored colour shows the dashed replacement chip and NO add chip:
2187
+ // "add a second color" is nonsense with zero colours.
2188
+ snapshot.value = {
2189
+ askingHoldMs: 3500,
2190
+ doneHoldMs: 5000,
2191
+ defaultColor: null,
2192
+ states: { done: { effect: "breath", colors: ["nope"] } },
2193
+ };
2194
+ findButton(card(), "Discard").props.onClick(); // drop anything F55 left behind
2195
+ byId(card(), "plugin-config-icon-asking-hold").props.onChange("3500");
2196
+ const emptyField = byId(card(), "plugin-config-icon-done-colors");
2197
+ const emptyTree = emptyField.type(emptyField.props);
2198
+ ok("F56 an unusable colour list offers the dashed chip but no add chip",
2199
+ emptyField.props.colors.length === 0 && emptyField.props.canAdd === false &&
2200
+ findAll(emptyTree, (n) => n.type === "input" && n.props.type === "color").length === 1,
2201
+ JSON.stringify({ colors: emptyField.props.colors, canAdd: emptyField.props.canAdd }));
2202
+
2203
+ // ---- Plugins page views (view: 'summary' | 'page') ------------------------
2204
+ // The page asks a configuration entry twice: a one-liner for the row and the
2205
+ // body once its page is opened. A standalone render passes no `view` and
2206
+ // keeps the collapsible header (F11–F14 above).
2207
+ {
2208
+ const renderFresh = (primitives) => {
2209
+ const freshReact = makeReactStub();
2210
+ const freshRegistered = [];
2211
+ const freshCtx = {
2212
+ get: (name) => (name === "configForms"
2213
+ ? { get: () => scope, whileServed: (_ns, register) => register(new Set()) }
2214
+ : undefined),
2215
+ locale: { register: () => () => {}, bind: () => (k) => k },
2216
+ slots: {
2217
+ inject: (name, cb) => { if (name === "plugins.row.config") cb(); return () => {}; },
2218
+ register: (spec, comp) => { freshRegistered.push({ spec, comp }); return () => {}; },
2219
+ },
2220
+ effect: (fn) => fn(),
2221
+ };
2222
+ const freshClient = loaded.factory((name) => {
2223
+ if (name === "react") return freshReact;
2224
+ if (name === "@deepseek-ai/dsh-client-ui-primitives") return primitives;
2225
+ throw new Error("unexpected require: " + name);
2226
+ });
2227
+ freshClient.apply(freshCtx);
2228
+ return freshRegistered[0].comp;
2229
+ };
2230
+
2231
+ const ViewCard = renderFresh(primitivesStub);
2232
+ const keyT = (k) => k;
2233
+ eq("F57 the summary view is the one-liner text",
2234
+ ViewCard({ scope, t: keyT, view: "summary" }), "description");
2235
+ const pageTree = ViewCard({ scope, t: keyT, view: "page" });
2236
+ ok("F58 the page view renders the body without the card's own header",
2237
+ !!byId(pageTree, "plugin-config-icon-asking-hold") &&
2238
+ findAll(pageTree, (n) => n.type === "button" && n.props && n.props["aria-expanded"] !== undefined).length === 0,
2239
+ textOf(pageTree).slice(0, 80));
2240
+
2241
+ // The pre-0.1.7 primitives exported the chevron under a size suffix; the
2242
+ // bundle must keep rendering on that host line too.
2243
+ const legacyPrimitives = {
2244
+ Button: function Button() { return null; },
2245
+ Input: function Input() { return null; },
2246
+ IconChevronDownOutline14: function IconChevronDownOutline14() { return null; },
2247
+ DisclosureRow: function DisclosureRow() { return null; },
2248
+ };
2249
+ const LegacyCard = renderFresh(legacyPrimitives);
2250
+ const legacyTree = LegacyCard({ scope, t: keyT });
2251
+ ok("F59 the legacy size-suffixed chevron still resolves",
2252
+ findAll(legacyTree, (n) => n.type === legacyPrimitives.IconChevronDownOutline14).length === 1,
2253
+ textOf(legacyTree).slice(0, 80));
2254
+
2255
+ // The 0.1.7 settings contract replaced both host-plane APIs this bundle used
2256
+ // to target; a leftover reference would fail activation at boot.
2257
+ ok("F60 the bundle still speaks BOTH generations (dual-support contract)",
2258
+ CLIENT_SRC.indexOf("settingsScope") !== -1 &&
2259
+ CLIENT_SRC.indexOf("settings.plugin.item") !== -1 &&
2260
+ CLIENT_SRC.indexOf("configForms") !== -1 &&
2261
+ CLIENT_SRC.indexOf("plugins.row.config") !== -1 &&
2262
+ CLIENT_SRC.indexOf("whileServed") !== -1);
2263
+ }
2264
+ }
2265
+
2266
+ // ---- summary ---------------------------------------------------------------
2267
+ console.log("\n" + "=".repeat(60));
2268
+ console.log(`result: ${passed} passed, ${failed} failed`);
2269
+ if (failures.length) {
2270
+ console.log("failures:");
2271
+ for (const f of failures) console.log(" - " + f);
2272
+ }
2273
+ process.exit(failed ? 1 : 0);