@celestia-island/hikari 0.46.0 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,8 @@
14
14
  */
15
15
  import { readonly, ref, type Ref } from "vue";
16
16
 
17
+ import { reportHkRuntime, type HkRuntimeHandle } from "./registry";
18
+
17
19
  interface PageLifecycleState {
18
20
  visible: boolean;
19
21
  online: boolean;
@@ -26,11 +28,24 @@ let installed = false;
26
28
  type LifecycleListener = (state: PageLifecycleState) => void;
27
29
  const listeners = new Set<LifecycleListener>();
28
30
 
31
+ // Runtime-registry reporting (the "context of contexts"). Lazy: the
32
+ // lifecycle context reports itself on first subscribe/probe and pulses
33
+ // on every visibility/online transition it fans out.
34
+ let runtimeReport: HkRuntimeHandle | null = null;
35
+ function ensureRuntimeReport(): HkRuntimeHandle {
36
+ return (runtimeReport ??= reportHkRuntime("pageLifecycle", {
37
+ kind: "context",
38
+ description: "The page lifecycle context: visibility + online/offline state fanned out to framework-free listeners.",
39
+ read: () => ({ ...currentState(), listeners: listeners.size }),
40
+ }));
41
+ }
42
+
29
43
  function currentState(): PageLifecycleState {
30
44
  return { visible: visible.value, online: online.value };
31
45
  }
32
46
 
33
47
  function notify(): void {
48
+ runtimeReport?.pulse({ ...currentState() });
34
49
  const snapshot = currentState();
35
50
  for (const listener of listeners) {
36
51
  try {
@@ -66,6 +81,7 @@ export function usePageLifecycle(): {
66
81
  online: Readonly<Ref<boolean>>;
67
82
  } {
68
83
  install();
84
+ ensureRuntimeReport();
69
85
  return { visible: readonly(visible), online: readonly(online) };
70
86
  }
71
87
 
@@ -76,15 +92,19 @@ export function usePageLifecycle(): {
76
92
  */
77
93
  export function onPageLifecycle(listener: LifecycleListener): () => void {
78
94
  install();
95
+ ensureRuntimeReport();
79
96
  listeners.add(listener);
80
97
  return () => {
81
98
  listeners.delete(listener);
82
99
  };
83
100
  }
84
101
 
85
- /** Imperative probe — no subscription. */
102
+ /** Imperative probe — no subscription. Reports to the runtime registry
103
+ * too, so an install-through-probe (no subscriber ever) is still
104
+ * observable. */
86
105
  export function pageLifecycleState(): PageLifecycleState {
87
106
  install();
107
+ ensureRuntimeReport();
88
108
  return currentState();
89
109
  }
90
110
 
@@ -0,0 +1,309 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+
3
+ import { setLocale } from "../i18n/context";
4
+ import {
5
+ getHkRuntimeEntry,
6
+ hkRuntimeSnapshot,
7
+ readHkRuntime,
8
+ writeHkRuntime,
9
+ } from "./registry";
10
+ import { installHkImageFallback } from "./imageFallback";
11
+ import { onFrame, setReducedMotion } from "./animationBus";
12
+ import { scheduleInterval } from "./intervalBus";
13
+ import { installHkTooltipBridge } from "./tooltipBridge";
14
+ import { installHistorySafetyNet } from "./historySafetyNet";
15
+ import { installNavigationSafetyNet } from "./navigationGuard";
16
+ import { usePopupManager } from "./usePopupManager";
17
+ import { useOverlay } from "./useOverlay";
18
+ import { useToast } from "./useToast";
19
+ import { clearErrorReportingState, reportError } from "../errorReporting/state";
20
+ import { clearBlockingToasts, showBlockingToast } from "./useBlockingToast";
21
+ import { onPageLifecycle, pageLifecycleState } from "./pageLifecycle";
22
+ import { showProgressDialog } from "../composables/useProgressDialog";
23
+
24
+ /**
25
+ * Integration contract: every hikari global context / hook / bus reports
26
+ * itself to the runtime registry (the "context of contexts") WITHOUT its
27
+ * registration API changing, pulses on activity, and exposes its global
28
+ * state for cross-context read/write through the registry.
29
+ */
30
+
31
+ const uninstalls: Array<() => void> = [];
32
+
33
+ afterEach(() => {
34
+ for (const u of uninstalls.splice(0)) u();
35
+ setReducedMotion(false);
36
+ clearErrorReportingState();
37
+ });
38
+
39
+ describe("i18n context auto-report", () => {
40
+ it("registers at module init as a live context", () => {
41
+ const entry = getHkRuntimeEntry("i18n");
42
+ expect(entry).toBeDefined();
43
+ expect(entry!.status).toBe("active");
44
+ expect(entry!.kind).toBe("context");
45
+ const state = readHkRuntime("i18n");
46
+ expect(typeof state!.locale).toBe("string");
47
+ expect(typeof state!.keys).toBe("number");
48
+ });
49
+
50
+ it("pulses and updates the read facet on locale switches", async () => {
51
+ const before = getHkRuntimeEntry("i18n")!.pulses;
52
+ await setLocale("zh-CN");
53
+ const after = getHkRuntimeEntry("i18n")!;
54
+ expect(after.pulses).toBeGreaterThan(before);
55
+ expect(readHkRuntime("i18n")!.locale).toBe("zh-CN");
56
+ await setLocale("en");
57
+ });
58
+
59
+ it("supports plugin-style steering through the write facet", async () => {
60
+ expect(writeHkRuntime("i18n", { type: "setLocale", locale: "ja" })).toBe(true);
61
+ expect(readHkRuntime("i18n")!.locale).toBe("ja");
62
+ expect(writeHkRuntime("i18n", { type: "nonsense" })).toBe(false);
63
+ await setLocale("en");
64
+ });
65
+ });
66
+
67
+ describe("popup manager (modal context) auto-report", () => {
68
+ it("reports lazily on first use and counts live popups per kind", () => {
69
+ const manager = usePopupManager();
70
+ expect(getHkRuntimeEntry("popupManager")).toBeDefined();
71
+ const handleA = manager.register("modal", true, "Settings");
72
+ const handleB = manager.register("dropdown");
73
+ const state = readHkRuntime("popupManager")!;
74
+ expect(state.open).toBe(2);
75
+ expect((state.byKind as Record<string, number>).modal).toBe(1);
76
+ expect((state.byKind as Record<string, number>).dropdown).toBe(1);
77
+ expect(state.scrollLocks).toBe(1);
78
+ manager.unregister(handleA.id);
79
+ manager.unregister(handleB.id);
80
+ expect(readHkRuntime("popupManager")!.open).toBe(0);
81
+ });
82
+
83
+ it("pulses on register/unregister", () => {
84
+ const manager = usePopupManager();
85
+ const before = getHkRuntimeEntry("popupManager")!.pulses;
86
+ const handle = manager.register("tooltip");
87
+ manager.unregister(handle.id);
88
+ expect(getHkRuntimeEntry("popupManager")!.pulses).toBeGreaterThanOrEqual(before + 2);
89
+ });
90
+ });
91
+
92
+ describe("overlay context auto-report", () => {
93
+ it("reports lazily on first use and lists open overlay names", () => {
94
+ const overlay = useOverlay({ name: "hk-test-select" });
95
+ overlay.open();
96
+ const state = readHkRuntime("overlay")!;
97
+ expect(state.open).toBe(1);
98
+ expect(state.names).toContain("hk-test-select");
99
+ overlay.close();
100
+ expect(readHkRuntime("overlay")!.open).toBe(0);
101
+ });
102
+ });
103
+
104
+ describe("toast context auto-report", () => {
105
+ it("reports lazily on first use and counts slots/messages", () => {
106
+ const toast = useToast();
107
+ toast.info("hello");
108
+ toast.error("bad");
109
+ const state = readHkRuntime("toast")!;
110
+ expect(state.slots).toBe(2);
111
+ expect(state.messages).toBe(2);
112
+ expect(getHkRuntimeEntry("toast")!.pulses).toBeGreaterThan(0);
113
+ });
114
+
115
+ it("an unknown-id remove is a full no-op — no pulse, no state change", () => {
116
+ const { remove } = useToast();
117
+ const pulsesBefore = getHkRuntimeEntry("toast")!.pulses;
118
+ const slotsBefore = readHkRuntime("toast")!.slots;
119
+ remove(999999);
120
+ expect(getHkRuntimeEntry("toast")!.pulses).toBe(pulsesBefore);
121
+ expect(readHkRuntime("toast")!.slots).toBe(slotsBefore);
122
+ });
123
+ });
124
+
125
+ describe("image fallback hook auto-report", () => {
126
+ it("reports on install, pulses with a running marked count, disposes on uninstall", () => {
127
+ expect(getHkRuntimeEntry("imageFallback")?.status ?? "absent").not.toBe("active");
128
+ const uninstall = installHkImageFallback();
129
+ uninstalls.push(uninstall);
130
+
131
+ const entry = getHkRuntimeEntry("imageFallback")!;
132
+ expect(entry.kind).toBe("hook");
133
+ expect(entry.status).toBe("active");
134
+ expect(entry.meta.fallback).toBe("bundled");
135
+
136
+ const el = document.createElement("img");
137
+ el.src = "https://example.invalid/pic.png";
138
+ document.body.appendChild(el);
139
+ el.dispatchEvent(new Event("error"));
140
+
141
+ expect(entry.meta.marked).toBe(1);
142
+ expect(readHkRuntime("imageFallback")).toEqual({ installed: true, marked: 1 });
143
+ el.remove();
144
+
145
+ uninstall();
146
+ expect(getHkRuntimeEntry("imageFallback")!.status).toBe("disposed");
147
+ expect(readHkRuntime("imageFallback")).toBeUndefined();
148
+ });
149
+ });
150
+
151
+ describe("tooltip bridge hook auto-report", () => {
152
+ it("reports on install with options meta and disposes on uninstall", () => {
153
+ const uninstall = installHkTooltipBridge({ delay: 10, placement: "bottom" });
154
+ uninstalls.push(uninstall);
155
+ const entry = getHkRuntimeEntry("tooltipBridge")!;
156
+ expect(entry.kind).toBe("hook");
157
+ expect(entry.status).toBe("active");
158
+ expect(entry.meta.delay).toBe(10);
159
+ expect(entry.meta.placement).toBe("bottom");
160
+ uninstall();
161
+ expect(getHkRuntimeEntry("tooltipBridge")!.status).toBe("disposed");
162
+ });
163
+ });
164
+
165
+ describe("animation bus auto-report", () => {
166
+ it("reports lazily on first schedule and exposes live load", () => {
167
+ const handle = onFrame(() => undefined, "normal");
168
+ const entry = getHkRuntimeEntry("animationBus");
169
+ expect(entry).toBeDefined();
170
+ expect(entry!.kind).toBe("bus");
171
+ expect((readHkRuntime("animationBus")!.normal as number) >= 1).toBe(true);
172
+ handle.disconnect();
173
+ });
174
+
175
+ it("supports plugin-style reduced-motion steering through the write facet", () => {
176
+ onFrame(() => undefined); // activate the bus's registry entry
177
+ expect(writeHkRuntime("animationBus", { type: "setReducedMotion", value: true })).toBe(true);
178
+ expect(readHkRuntime("animationBus")!.paused).toBe(true);
179
+ expect(writeHkRuntime("animationBus", { type: "setReducedMotion", value: false })).toBe(true);
180
+ expect(readHkRuntime("animationBus")!.paused).toBe(false);
181
+ expect(writeHkRuntime("animationBus", { type: "warp" })).toBe(false);
182
+ });
183
+ });
184
+
185
+ describe("interval bus auto-report", () => {
186
+ it("reports lazily on first schedule with live slot counts", () => {
187
+ const handle = scheduleInterval(() => undefined, 60_000);
188
+ const entry = getHkRuntimeEntry("intervalBus");
189
+ expect(entry).toBeDefined();
190
+ expect(readHkRuntime("intervalBus")!.slots).toBe(1);
191
+ handle.disconnect();
192
+ expect(readHkRuntime("intervalBus")!.slots).toBe(0);
193
+ });
194
+ });
195
+
196
+ describe("blocking toast context auto-report", () => {
197
+ afterEach(() => clearBlockingToasts());
198
+
199
+ it("reports lazily on first prompt and counts pending gates", () => {
200
+ expect(getHkRuntimeEntry("blockingToast")?.status ?? "absent").not.toBe("active");
201
+ void showBlockingToast("join this group?");
202
+ expect(getHkRuntimeEntry("blockingToast")!.kind).toBe("context");
203
+ expect(readHkRuntime("blockingToast")!.pending).toBe(1);
204
+ clearBlockingToasts();
205
+ expect(readHkRuntime("blockingToast")!.pending).toBe(0);
206
+ });
207
+ });
208
+
209
+ describe("page lifecycle context auto-report", () => {
210
+ it("reports (lazily, or via a sibling bus) and exposes visibility/online state", () => {
211
+ // NOTE: no "not active before" precondition here — the interval bus
212
+ // subscribes to the lifecycle internally, so any earlier test that
213
+ // touched it has already activated this entry. That transitive
214
+ // activation is exactly the auto-report behavior under test.
215
+ const unsubscribe = onPageLifecycle(() => undefined);
216
+ const entry = getHkRuntimeEntry("pageLifecycle")!;
217
+ expect(entry.kind).toBe("context");
218
+ expect(entry.status).toBe("active");
219
+ const state = readHkRuntime("pageLifecycle")!;
220
+ expect(typeof state.visible).toBe("boolean");
221
+ expect(typeof state.online).toBe("boolean");
222
+ expect(state.listeners).toBe(1);
223
+ unsubscribe();
224
+ expect(readHkRuntime("pageLifecycle")!.listeners).toBe(0);
225
+ expect(pageLifecycleState().visible).toBe(true);
226
+ });
227
+ });
228
+
229
+ describe("error reporting plugin auto-report", () => {
230
+ it("reports at module init and counts accepted errors in the read facet", () => {
231
+ const entry = getHkRuntimeEntry("errorReporting");
232
+ expect(entry).toBeDefined();
233
+ expect(entry!.kind).toBe("plugin");
234
+ const before = readHkRuntime("errorReporting")!.reports as number;
235
+ reportError(new Error("registry-test"), "manual");
236
+ const state = readHkRuntime("errorReporting")!;
237
+ expect(state.reports).toBe(before + 1);
238
+ expect(state.landingUp).toBe(true);
239
+ expect((state.current as { message: string }).message).toBe("registry-test");
240
+ // First-error-wins: a second error while the landing is up logs but
241
+ // does NOT count as accepted.
242
+ reportError(new Error("registry-test-2"), "manual");
243
+ expect(readHkRuntime("errorReporting")!.reports).toBe(before + 1);
244
+ expect((readHkRuntime("errorReporting")!.current as { message: string }).message).toBe("registry-test");
245
+ });
246
+ });
247
+
248
+ describe("safety-net hooks report on install, not on import", () => {
249
+ // ONE test for the whole safety-net family on purpose: the "not active
250
+ // before install" premise is only sound while no sibling test has
251
+ // installed either net (installNavigationSafetyNet internally installs
252
+ // the history net), so every install in this file lives HERE — split
253
+ // tests were order-dependent under --sequence.shuffle.
254
+ it("nets become active only when installed; meta reports the ACTIVE patch's options", () => {
255
+ // Importing the modules (which this file did transitively) must not
256
+ // claim installed hooks.
257
+ expect(getHkRuntimeEntry("historySafetyNet")?.status ?? "absent").not.toBe("active");
258
+ expect(getHkRuntimeEntry("navigationGuard")?.status ?? "absent").not.toBe("active");
259
+
260
+ installHistorySafetyNet();
261
+ const historyEntry = getHkRuntimeEntry("historySafetyNet")!;
262
+ expect(historyEntry.kind).toBe("hook");
263
+ expect(historyEntry.status).toBe("active");
264
+ expect(historyEntry.meta.fallback).toBe("/");
265
+ // The history net alone must not claim the router guard.
266
+ expect(getHkRuntimeEntry("navigationGuard")?.status ?? "absent").not.toBe("active");
267
+
268
+ installNavigationSafetyNet();
269
+ const navEntry = getHkRuntimeEntry("navigationGuard")!;
270
+ expect(navEntry.kind).toBe("hook");
271
+ expect(navEntry.status).toBe("active");
272
+ expect(navEntry.meta.routerGuard).toBe(false);
273
+
274
+ // A no-op re-install with a DIFFERENT fallback cannot change the
275
+ // live closure (installs are idempotent) — the meta must not claim
276
+ // the new target either.
277
+ installHistorySafetyNet({ fallback: "/no-op-reinstall-target" });
278
+ expect(getHkRuntimeEntry("historySafetyNet")!.meta.fallback).not.toBe("/no-op-reinstall-target");
279
+ });
280
+ });
281
+
282
+ describe("progress dialog context auto-report", () => {
283
+ it("reports lazily on first show and exposes the live dialog state", () => {
284
+ expect(getHkRuntimeEntry("progressDialog")?.status ?? "absent").not.toBe("active");
285
+ const dialog = showProgressDialog({ title: "Deploying" });
286
+ dialog.log("step 1");
287
+ dialog.log("step 2");
288
+ const state = readHkRuntime("progressDialog")!;
289
+ expect(state.open).toBe(true);
290
+ expect(state.title).toBe("Deploying");
291
+ expect(state.logLines).toBe(2);
292
+ dialog.close();
293
+ expect(readHkRuntime("progressDialog")!.open).toBe(false);
294
+ });
295
+ });
296
+
297
+ describe("whole-registry debug snapshot", () => {
298
+ it("captures every reporting subsystem with live state", () => {
299
+ const uninstall = installHkImageFallback();
300
+ uninstalls.push(uninstall);
301
+ usePopupManager();
302
+ const snap = hkRuntimeSnapshot();
303
+ const ids = snap.entries.filter((e) => e.status === "active").map((e) => e.id);
304
+ for (const expected of ["i18n", "popupManager", "imageFallback", "errorReporting"]) {
305
+ expect(ids).toContain(expected);
306
+ }
307
+ expect(() => JSON.stringify(snap)).not.toThrow();
308
+ });
309
+ });
@@ -0,0 +1,279 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ getHkRuntimeEntry,
5
+ hkRuntimeSnapshot,
6
+ listHkRuntime,
7
+ readHkRuntime,
8
+ reportHkRuntime,
9
+ useHkRegistry,
10
+ writeHkRuntime,
11
+ } from "./registry";
12
+
13
+ /**
14
+ * Core registry contract tests (the "context of contexts"):
15
+ * - report is an idempotent upsert while active: facets refresh, but
16
+ * registeredAt and the pulse history survive a re-report
17
+ * - dispose marks the entry disposed and drops its facets; the record
18
+ * stays listed for post-mortem debugging
19
+ * - a re-report AFTER dispose starts a fresh lifetime; stale handles
20
+ * from the previous lifetime can neither pulse nor dispose the new one
21
+ * - pulse bumps pulses/lastActiveAt and shallow-merges meta
22
+ * - read/write facets: cross-context access, unknown ids, throwing
23
+ * facets and unknown op types all degrade without throwing
24
+ * - list/snapshot are stable-ordered plain clones
25
+ */
26
+
27
+ describe("reportHkRuntime", () => {
28
+ it("registers an active entry with kind, description and meta", () => {
29
+ const h = reportHkRuntime("test.core.basic", {
30
+ kind: "hook",
31
+ description: "a test hook",
32
+ meta: { flavor: "mint" },
33
+ });
34
+ expect(h.id).toBe("test.core.basic");
35
+ const entry = getHkRuntimeEntry("test.core.basic");
36
+ expect(entry).toBeDefined();
37
+ expect(entry!.status).toBe("active");
38
+ expect(entry!.kind).toBe("hook");
39
+ expect(entry!.description).toBe("a test hook");
40
+ expect(entry!.meta).toEqual({ flavor: "mint" });
41
+ expect(entry!.pulses).toBe(0);
42
+ expect(entry!.registeredAt).toBeGreaterThan(0);
43
+ h.dispose();
44
+ });
45
+
46
+ it("re-report while active keeps the lifetime (registeredAt, pulses) and refreshes facets", () => {
47
+ const first = reportHkRuntime("test.core.upsert", {
48
+ kind: "context",
49
+ meta: { v: 1 },
50
+ read: () => ({ v: 1 }),
51
+ });
52
+ first.pulse();
53
+ first.pulse();
54
+ const before = getHkRuntimeEntry("test.core.upsert")!;
55
+ const registeredAt = before.registeredAt;
56
+
57
+ const second = reportHkRuntime("test.core.upsert", {
58
+ kind: "context",
59
+ description: "refreshed",
60
+ meta: { v: 2 },
61
+ read: () => ({ v: 2 }),
62
+ });
63
+ const after = getHkRuntimeEntry("test.core.upsert")!;
64
+ expect(after.registeredAt).toBe(registeredAt);
65
+ expect(after.pulses).toBe(2);
66
+ expect(readHkRuntime("test.core.upsert")).toEqual({ v: 2 });
67
+ // Both handles address the SAME live entry.
68
+ second.pulse();
69
+ expect(getHkRuntimeEntry("test.core.upsert")!.pulses).toBe(3);
70
+ first.dispose();
71
+ expect(getHkRuntimeEntry("test.core.upsert")!.status).toBe("disposed");
72
+ });
73
+
74
+ it("dispose drops the facets but keeps the record listed for debugging", () => {
75
+ const h = reportHkRuntime("test.core.dispose", {
76
+ kind: "hook",
77
+ meta: { a: 1 },
78
+ read: () => ({ a: 1 }),
79
+ write: () => undefined,
80
+ });
81
+ h.dispose();
82
+ const entry = getHkRuntimeEntry("test.core.dispose")!;
83
+ expect(entry.status).toBe("disposed");
84
+ // Record survives; facets do not.
85
+ expect(listHkRuntime().some((e) => e.id === "test.core.dispose" && e.status === "disposed")).toBe(true);
86
+ expect(readHkRuntime("test.core.dispose")).toBeUndefined();
87
+ expect(writeHkRuntime("test.core.dispose", { type: "x" })).toBe(false);
88
+ // Idempotent: disposing twice is safe.
89
+ h.dispose();
90
+ expect(entry.status).toBe("disposed");
91
+ });
92
+
93
+ it("late pulses from a disposed lifetime are no-ops", () => {
94
+ const h = reportHkRuntime("test.core.latePulse", { kind: "hook" });
95
+ h.pulse();
96
+ h.dispose();
97
+ h.pulse(); // a subsystem tearing down asynchronously must not resurrect
98
+ const entry = getHkRuntimeEntry("test.core.latePulse")!;
99
+ expect(entry.status).toBe("disposed");
100
+ expect(entry.pulses).toBe(1);
101
+ });
102
+
103
+ it("re-report after dispose starts a fresh lifetime; stale handles cannot touch it", async () => {
104
+ const stale = reportHkRuntime("test.core.rebirth", {
105
+ kind: "hook",
106
+ read: () => ({ gen: 1 }),
107
+ });
108
+ stale.pulse();
109
+ stale.dispose();
110
+
111
+ const fresh = reportHkRuntime("test.core.rebirth", {
112
+ kind: "hook",
113
+ read: () => ({ gen: 2 }),
114
+ });
115
+ const entry = getHkRuntimeEntry("test.core.rebirth")!;
116
+ expect(entry.status).toBe("active");
117
+ expect(entry.pulses).toBe(0);
118
+ expect(readHkRuntime("test.core.rebirth")).toEqual({ gen: 2 });
119
+
120
+ stale.pulse(); // previous epoch — must be ignored
121
+ expect(entry.pulses).toBe(0);
122
+ stale.dispose(); // previous epoch — must not kill the fresh entry
123
+ expect(entry.status).toBe("active");
124
+ fresh.dispose();
125
+ });
126
+ });
127
+
128
+ describe("pulse", () => {
129
+ it("bumps pulses, refreshes lastActiveAt and shallow-merges meta", async () => {
130
+ const h = reportHkRuntime("test.core.pulse", {
131
+ kind: "bus",
132
+ meta: { count: 0, extra: "keep" },
133
+ });
134
+ // getHkRuntimeEntry returns a LIVE readonly view — capture scalars,
135
+ // not the proxy, for before/after comparisons.
136
+ const before = getHkRuntimeEntry("test.core.pulse")!;
137
+ const pulsesBefore = before.pulses;
138
+ const lastActiveBefore = before.lastActiveAt;
139
+ await new Promise((r) => setTimeout(r, 2));
140
+ h.pulse({ count: 1 });
141
+ const after = getHkRuntimeEntry("test.core.pulse")!;
142
+ expect(after.pulses).toBe(pulsesBefore + 1);
143
+ expect(after.lastActiveAt).toBeGreaterThan(lastActiveBefore);
144
+ expect(after.meta).toEqual({ count: 1, extra: "keep" });
145
+ h.dispose();
146
+ });
147
+
148
+ it("setMeta merges without counting a pulse", () => {
149
+ const h = reportHkRuntime("test.core.setMeta", { kind: "bus", meta: { a: 1 } });
150
+ h.setMeta({ b: 2 });
151
+ const entry = getHkRuntimeEntry("test.core.setMeta")!;
152
+ expect(entry.meta).toEqual({ a: 1, b: 2 });
153
+ expect(entry.pulses).toBe(0);
154
+ h.dispose();
155
+ });
156
+ });
157
+
158
+ describe("cross-context read/write", () => {
159
+ it("reads a live facet and returns undefined for unknown ids", () => {
160
+ expect(readHkRuntime("test.core.neverRegistered")).toBeUndefined();
161
+ const h = reportHkRuntime("test.core.read", {
162
+ kind: "context",
163
+ read: () => ({ now: Date.now() >= 0 }),
164
+ });
165
+ expect(readHkRuntime("test.core.read")).toEqual({ now: true });
166
+ h.dispose();
167
+ });
168
+
169
+ it("a throwing read facet degrades to undefined instead of crashing", () => {
170
+ const h = reportHkRuntime("test.core.badRead", {
171
+ kind: "context",
172
+ read: () => {
173
+ throw new Error("boom");
174
+ },
175
+ });
176
+ expect(readHkRuntime("test.core.badRead")).toBeUndefined();
177
+ h.dispose();
178
+ });
179
+
180
+ it("applies write ops and reports false for unknown ids / missing facets / rejected ops", () => {
181
+ expect(writeHkRuntime("test.core.unknown", { type: "x" })).toBe(false);
182
+ let calls: string[] = [];
183
+ const h = reportHkRuntime("test.core.write", {
184
+ kind: "plugin",
185
+ write: (op) => {
186
+ if (op.type === "go") {
187
+ calls.push("go");
188
+ return;
189
+ }
190
+ throw new Error(`unknown op ${op.type}`);
191
+ },
192
+ });
193
+ expect(writeHkRuntime("test.core.write", { type: "go" })).toBe(true);
194
+ expect(calls).toEqual(["go"]);
195
+ expect(writeHkRuntime("test.core.write", { type: "nope" })).toBe(false);
196
+ expect(calls).toEqual(["go"]);
197
+ h.dispose();
198
+ // disposed → no facet anymore
199
+ expect(writeHkRuntime("test.core.write", { type: "go" })).toBe(false);
200
+ calls = [];
201
+ });
202
+ });
203
+
204
+ describe("list / snapshot", () => {
205
+ it("lists plain, stable-ordered clones that do not alias live state", () => {
206
+ const h1 = reportHkRuntime("test.core.listA", { kind: "hook" });
207
+ const h2 = reportHkRuntime("test.core.listB", { kind: "bus" });
208
+ const ids = listHkRuntime().map((e) => e.id);
209
+ expect(ids.indexOf("test.core.listA")).toBeLessThan(ids.indexOf("test.core.listB"));
210
+ const clone = listHkRuntime().find((e) => e.id === "test.core.listA")!;
211
+ h1.pulse();
212
+ expect(clone.pulses).toBe(0); // clone, not the reactive state
213
+ expect(getHkRuntimeEntry("test.core.listA")!.pulses).toBe(1);
214
+ h1.dispose();
215
+ h2.dispose();
216
+ });
217
+
218
+ it("snapshot embeds the live read facet under `state`", () => {
219
+ const h = reportHkRuntime("test.core.snap", {
220
+ kind: "context",
221
+ meta: { locale: "en" },
222
+ read: () => ({ deep: { live: true } }),
223
+ });
224
+ const snap = hkRuntimeSnapshot();
225
+ const entry = snap.entries.find((e) => e.id === "test.core.snap");
226
+ expect(entry).toBeDefined();
227
+ expect(entry!.meta).toEqual({ locale: "en" });
228
+ expect(entry!.state).toEqual({ deep: { live: true } });
229
+ expect(snap.generatedAt).toBeGreaterThan(0);
230
+ h.dispose();
231
+ });
232
+
233
+ it("snapshot stays JSON-serializable", () => {
234
+ const h = reportHkRuntime("test.core.json", {
235
+ kind: "hook",
236
+ meta: { n: 1, s: "x", b: true, arr: [1, 2] },
237
+ read: () => ({ ok: 1 }),
238
+ });
239
+ expect(() => JSON.stringify(hkRuntimeSnapshot())).not.toThrow();
240
+ h.dispose();
241
+ });
242
+ });
243
+
244
+ describe("useHkRegistry", () => {
245
+ it("exposes a reactive entries computed plus the plain accessors", () => {
246
+ const h = reportHkRuntime("test.core.vue", { kind: "context" });
247
+ const { entries, read, write, snapshot } = useHkRegistry();
248
+ expect(entries.value.some((e) => e.id === "test.core.vue")).toBe(true);
249
+ expect(read("test.core.vue")).toBeUndefined(); // no read facet declared
250
+ expect(write("test.core.vue", { type: "x" })).toBe(false);
251
+ expect(snapshot().entries.some((e) => e.id === "test.core.vue")).toBe(true);
252
+ h.dispose();
253
+ });
254
+
255
+ it("the entries computed sees BRAND-NEW registrations (structural version)", () => {
256
+ const { entries } = useHkRegistry();
257
+ const before = entries.value.some((e) => e.id === "test.core.lateArrival");
258
+ expect(before).toBe(false);
259
+ const h = reportHkRuntime("test.core.lateArrival", { kind: "hook" });
260
+ // The computed must invalidate on the structural change itself —
261
+ // before this fix it only tracked already-known reactive states, so
262
+ // a debug panel missed first-time registrations until something
263
+ // pulsed.
264
+ expect(entries.value.some((e) => e.id === "test.core.lateArrival")).toBe(true);
265
+ h.dispose();
266
+ expect(entries.value.find((e) => e.id === "test.core.lateArrival")?.status).toBe("disposed");
267
+ });
268
+
269
+ it("lists entries in true registration order (sequence, not clock)", () => {
270
+ const h1 = reportHkRuntime("test.core.seqZ", { kind: "hook" });
271
+ const h2 = reportHkRuntime("test.core.seqA", { kind: "hook" });
272
+ const ids = listHkRuntime().map((e) => e.id);
273
+ // seqZ registered FIRST — must stay first even though it sorts after
274
+ // seqA alphabetically (same-millisecond installs used to invert).
275
+ expect(ids.indexOf("test.core.seqZ")).toBeLessThan(ids.indexOf("test.core.seqA"));
276
+ h1.dispose();
277
+ h2.dispose();
278
+ });
279
+ });