@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.
- package/package.json +1 -1
- package/src/composables/useConnectionProbe.ts +19 -0
- package/src/composables/useProgressDialog.ts +24 -0
- package/src/errorReporting/state.ts +30 -0
- package/src/i18n/context.ts +32 -0
- package/src/index.ts +16 -0
- package/src/runtime/animationBus.ts +42 -1
- package/src/runtime/cronBus.ts +18 -0
- package/src/runtime/historySafetyNet.ts +39 -0
- package/src/runtime/imageFallback.ts +25 -1
- package/src/runtime/index.ts +18 -0
- package/src/runtime/intervalBus.ts +18 -0
- package/src/runtime/navigationGuard.ts +20 -0
- package/src/runtime/pageLifecycle.ts +21 -1
- package/src/runtime/registry.integration.test.ts +309 -0
- package/src/runtime/registry.test.ts +279 -0
- package/src/runtime/registry.ts +315 -0
- package/src/runtime/tooltipBridge.ts +23 -1
- package/src/runtime/useBlockingToast.ts +16 -0
- package/src/runtime/useOverlay.ts +27 -1
- package/src/runtime/usePopupManager.ts +32 -0
- package/src/runtime/useToast.ts +22 -0
|
@@ -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
|
+
});
|