@excom/neutron 0.1.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.
Files changed (63) hide show
  1. package/.rush/temp/chunked-rush-logs/neutron.apply-exports.chunks.jsonl +1 -0
  2. package/.rush/temp/chunked-rush-logs/neutron.build_package-metas.chunks.jsonl +1 -0
  3. package/.rush/temp/operation/apply-exports/all.log +1 -0
  4. package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
  5. package/.rush/temp/operation/apply-exports/state.json +3 -0
  6. package/.rush/temp/operation/build_package-metas/all.log +1 -0
  7. package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
  8. package/.rush/temp/operation/build_package-metas/state.json +3 -0
  9. package/.rush/temp/shrinkwrap-deps.json +3 -0
  10. package/config/rig.json +6 -0
  11. package/index.ts +9 -0
  12. package/package.json +45 -0
  13. package/rush-logs/neutron.apply-exports.cache.log +1 -0
  14. package/rush-logs/neutron.apply-exports.log +1 -0
  15. package/rush-logs/neutron.build_package-metas.cache.log +1 -0
  16. package/rush-logs/neutron.build_package-metas.log +1 -0
  17. package/src/command.ts +102 -0
  18. package/src/common-element.ts +377 -0
  19. package/src/constants.ts +101 -0
  20. package/src/devtools-hook.ts +93 -0
  21. package/src/lifecycle-configs.ts +304 -0
  22. package/src/neutron-element.ts +72 -0
  23. package/src/neutron-error.ts +6 -0
  24. package/src/neutron-internal.ts +550 -0
  25. package/src/neutron.ts +36 -0
  26. package/src/types/effect.types.ts +104 -0
  27. package/src/types/element.types.ts +263 -0
  28. package/src/types/index.ts +4 -0
  29. package/src/types/new.types.ts +159 -0
  30. package/src/types/shared.types.ts +25 -0
  31. package/src/utils/effect.ts +357 -0
  32. package/src/utils/element.ts +382 -0
  33. package/src/utils/index.ts +2 -0
  34. package/support/docs/COMMANDS.md +58 -0
  35. package/support/docs/COMPOSE.md +32 -0
  36. package/support/docs/DEBUG.md +11 -0
  37. package/support/docs/DEFINE.md +20 -0
  38. package/support/docs/EFFECTS.md +49 -0
  39. package/support/docs/EVENTS.md +57 -0
  40. package/support/docs/LIFECYCLES.md +69 -0
  41. package/support/docs/METHODS.md +49 -0
  42. package/support/docs/PROMISE_PROPS.md +29 -0
  43. package/support/docs/PROPS.md +64 -0
  44. package/support/docs/PROP_REACTIONS.md +35 -0
  45. package/support/docs/PROVISION.md +31 -0
  46. package/support/docs/README.md +118 -0
  47. package/support/docs/RECOMPOSE.md +70 -0
  48. package/support/docs/TYPESCRIPT.md +51 -0
  49. package/support/docs-sections.json +42 -0
  50. package/support/package-meta.json +129 -0
  51. package/support/tests/commands.test.ts +330 -0
  52. package/support/tests/common-element.test.ts +342 -0
  53. package/support/tests/devtools-hook.test.ts +209 -0
  54. package/support/tests/devtools-renderer.test.ts +125 -0
  55. package/support/tests/effects.test.ts +253 -0
  56. package/support/tests/element-config.test.ts +331 -0
  57. package/support/tests/entry.test.ts +68 -0
  58. package/support/tests/lifecycles.test.ts +489 -0
  59. package/support/tests/loop-guard.test.ts +162 -0
  60. package/support/tests/neutron.test.ts +1286 -0
  61. package/support/tests/recompose.test.ts +129 -0
  62. package/support/tests/utils.test.ts +75 -0
  63. package/tsconfig.json +5 -0
@@ -0,0 +1,342 @@
1
+ import {
2
+ BROADCAST_CHANNEL,
3
+ buildEventType,
4
+ CommonElement,
5
+ deref,
6
+ getNamespace,
7
+ } from "../../src/common-element";
8
+ import { Neutron } from "../../src/neutron";
9
+ import {
10
+ afterEach,
11
+ describe,
12
+ expect,
13
+ fixture,
14
+ it,
15
+ vi,
16
+ wait,
17
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
18
+ import { KitLogger } from "@excom/kit-logger";
19
+
20
+ const onPing = vi.fn();
21
+ const onToast = vi.fn();
22
+
23
+ const ListenerHost = Neutron({
24
+ tag: "listener-host",
25
+ props: { hitCount: Number },
26
+ events: { ping: { prefixWithTag: true } },
27
+ broadcasts: { toast: { prefixWithTag: true } },
28
+ })
29
+ .defineMethods({
30
+ // lets a test apply an arbitrary effect to the element
31
+ applyEffect: (_el, effect) => effect,
32
+ })
33
+ .onEvent("ping", onPing)
34
+ .onBroadcast("toast", onToast);
35
+ ListenerHost.define();
36
+
37
+ type ListenerHostElement = HTMLElement & {
38
+ _n_: any;
39
+ applyEffect: (effect: unknown) => unknown;
40
+ };
41
+
42
+ const mount = async () => {
43
+ const el = fixture<ListenerHostElement>(`<listener-host></listener-host>`);
44
+ await wait(0);
45
+ return el;
46
+ };
47
+
48
+ const cleanup = () => {
49
+ document.body.innerHTML = "";
50
+ vi.restoreAllMocks();
51
+ onPing.mockClear();
52
+ onToast.mockClear();
53
+ };
54
+
55
+ describe("CommonElement: event types and namespaces", () => {
56
+ it("prefixes configured events and broadcasts with the tag", () => {
57
+ const ctr = ListenerHost as any;
58
+ expect(buildEventType("ping", ctr)).toBe("listener-host-ping");
59
+ expect(buildEventType("toast", ctr, { _broadcast: true })).toBe(
60
+ "listener-host-toast"
61
+ );
62
+ // a name configured only as an event is not prefixed on the broadcast
63
+ // channel, and vice versa
64
+ expect(buildEventType("ping", ctr, { _broadcast: true })).toBe("ping");
65
+ expect(buildEventType("toast", ctr)).toBe("toast");
66
+ expect(buildEventType("other", ctr)).toBe("other");
67
+ expect(buildEventType("other", undefined)).toBe("other");
68
+ });
69
+
70
+ it("getNamespace fills in missing registries; deref unwraps WeakRefs", () => {
71
+ const div = document.createElement("div") as any;
72
+ div._n_ = { element: div };
73
+ const ns = getNamespace(div);
74
+ expect(ns.eventListeners).toEqual([]);
75
+ expect(ns.broadcastListeners).toEqual([]);
76
+ expect(getNamespace(div)).toBe(ns);
77
+ const fresh = document.createElement("div");
78
+ expect(getNamespace(fresh).element).toBe(fresh);
79
+ expect(deref(new WeakRef(div))).toBe(div);
80
+ expect(deref(div)).toBe(div);
81
+ });
82
+ });
83
+
84
+ describe("CommonElement: emit / broadcast", () => {
85
+ afterEach(cleanup);
86
+
87
+ it("emit defaults bubbles/cancelable/composed to true and honours overrides", async () => {
88
+ const el = await mount();
89
+ const seen: CustomEvent[] = [];
90
+ document.body.addEventListener("listener-host-ping", (e) => {
91
+ seen.push(e as CustomEvent);
92
+ });
93
+ const ev = CommonElement.emit.apply(el, [
94
+ "ping",
95
+ { detail: { n: 1 } },
96
+ ]) as CustomEvent;
97
+ expect(ev.type).toBe("listener-host-ping");
98
+ expect([ev.bubbles, ev.cancelable, ev.composed]).toEqual([
99
+ true,
100
+ true,
101
+ true,
102
+ ]);
103
+ expect(ev.detail).toEqual({ n: 1 });
104
+ expect(seen).toEqual([ev]);
105
+ expect(onPing).toHaveBeenCalledTimes(1);
106
+ expect(onPing.mock.calls[0][0]).toBe(el);
107
+ expect(onPing.mock.calls[0][1]).toBe(ev);
108
+
109
+ const quiet = CommonElement.emit.apply(el, [
110
+ "ping",
111
+ { bubbles: false, cancelable: false, composed: false },
112
+ ]) as CustomEvent;
113
+ expect([quiet.bubbles, quiet.cancelable, quiet.composed]).toEqual([
114
+ false,
115
+ false,
116
+ false,
117
+ ]);
118
+ // did not bubble to the body
119
+ expect(seen).toHaveLength(1);
120
+ expect(onPing).toHaveBeenCalledTimes(2);
121
+ });
122
+
123
+ it("emit dispatches from another target and rejects a missing type / target", async () => {
124
+ const el = await mount();
125
+ const other = document.createElement("div");
126
+ document.body.append(other);
127
+ const fn = vi.fn();
128
+ other.addEventListener("ping", fn);
129
+ el.applyEffect({ emit: ["ping", { target: other }] });
130
+ expect(fn).toHaveBeenCalledTimes(1);
131
+ expect(fn.mock.calls[0][0].target).toBe(other);
132
+ // `other` has no Neutron config, so nothing is prefixed
133
+ expect(onPing).not.toHaveBeenCalled();
134
+
135
+ expect(() => CommonElement.emit.apply(el, ["" as any])).toThrow(
136
+ "Event target and type are required"
137
+ );
138
+ expect(() => CommonElement.emit.call(undefined, "ping")).toThrow(
139
+ "Event target and type are required"
140
+ );
141
+ });
142
+
143
+ it("warns when emitting or broadcasting from a disconnected element", () => {
144
+ const warn = vi.spyOn(KitLogger, "warn").mockImplementation(() => {});
145
+ const el = document.createElement("listener-host") as ListenerHostElement;
146
+ CommonElement.emit.apply(el, ["ping"]);
147
+ expect(warn).toHaveBeenCalledTimes(1);
148
+ expect(warn.mock.calls[0][0]).toMatch(
149
+ /not connected: "listener-host"\. Dispatched event "listener-host-ping"/
150
+ );
151
+ CommonElement.broadcast.apply(el, ["toast"]);
152
+ expect(warn).toHaveBeenCalledTimes(2);
153
+ expect(warn.mock.calls[1][0]).toMatch(
154
+ /Dispatched broadcast "listener-host-toast"/
155
+ );
156
+ });
157
+
158
+ it("broadcast is non-bubbling, goes through the shared channel, and reaches every instance", async () => {
159
+ const a = await mount();
160
+ const b = fixture<ListenerHostElement>(`<listener-host></listener-host>`);
161
+ await wait(0);
162
+ const onElement = vi.fn();
163
+ a.addEventListener("listener-host-toast", onElement);
164
+ const onChannel = vi.fn();
165
+ BROADCAST_CHANNEL.addEventListener("listener-host-toast", onChannel);
166
+ a.applyEffect({
167
+ broadcast: ["toast", { detail: { msg: "hi" }, bubbles: true }],
168
+ });
169
+ BROADCAST_CHANNEL.removeEventListener("listener-host-toast", onChannel);
170
+
171
+ expect(onElement).not.toHaveBeenCalled();
172
+ expect(onChannel).toHaveBeenCalledTimes(1);
173
+ const ev = onChannel.mock.calls[0][0] as CustomEvent;
174
+ // forced off, even when the init asks for bubbling
175
+ expect(ev.bubbles).toBe(false);
176
+ expect(ev.detail).toEqual({ msg: "hi" });
177
+ expect(onToast).toHaveBeenCalledTimes(2);
178
+ expect(onToast.mock.calls.map(([el]) => el)).toEqual([a, b]);
179
+ });
180
+ });
181
+
182
+ describe("CommonElement: listener registries", () => {
183
+ afterEach(cleanup);
184
+
185
+ it("dedupes identical listeners and tracks foreign targets weakly", async () => {
186
+ const el = await mount();
187
+ const fn = vi.fn();
188
+ el.applyEffect({ addListener: ["custom-hit", fn] });
189
+ el.applyEffect({ addListener: ["custom-hit", fn] });
190
+ el.dispatchEvent(new CustomEvent("custom-hit"));
191
+ expect(fn).toHaveBeenCalledTimes(1);
192
+ expect(
193
+ el._n_.eventListeners.filter(([t]) => t === "custom-hit")
194
+ ).toHaveLength(1);
195
+
196
+ const other = document.createElement("div");
197
+ document.body.append(other);
198
+ const onOther = vi.fn();
199
+ el.applyEffect({ addListener: ["other-hit", onOther, { target: other }] });
200
+ const entry = el._n_.eventListeners.find(([t]) => t === "other-hit");
201
+ expect(entry[2].target).toBeInstanceOf(WeakRef);
202
+ expect(entry[2].target.deref()).toBe(other);
203
+ other.dispatchEvent(new CustomEvent("other-hit"));
204
+ expect(onOther).toHaveBeenCalledTimes(1);
205
+
206
+ el.applyEffect({
207
+ removeListener: ["other-hit", onOther, { target: other }],
208
+ });
209
+ other.dispatchEvent(new CustomEvent("other-hit"));
210
+ expect(onOther).toHaveBeenCalledTimes(1);
211
+ expect(
212
+ el._n_.eventListeners.find(([t]) => t === "other-hit")
213
+ ).toBeUndefined();
214
+ });
215
+
216
+ it("disconnect detaches every listener (foreign targets included); reconnect restores all but `once`", async () => {
217
+ const el = await mount();
218
+ const other = document.createElement("div");
219
+ document.body.append(other);
220
+ const onOther = vi.fn();
221
+ const onOnce = vi.fn();
222
+ const onPlain = vi.fn();
223
+ el.applyEffect({
224
+ addListeners: [
225
+ ["other-hit", onOther, { target: other }],
226
+ ["once-hit", onOnce, { once: true }],
227
+ ["plain-hit", onPlain],
228
+ ],
229
+ });
230
+
231
+ el.remove();
232
+ await wait(0);
233
+ expect(getEventListeners(el)).toEqual({});
234
+ other.dispatchEvent(new CustomEvent("other-hit"));
235
+ expect(onOther).not.toHaveBeenCalled();
236
+ expect(el._n_.disconnectedEventListeners.map(([t]) => t)).toEqual([
237
+ "listener-host-ping",
238
+ "other-hit",
239
+ "once-hit",
240
+ "plain-hit",
241
+ ]);
242
+
243
+ document.body.append(el);
244
+ expect(el._n_.disconnectedEventListeners).toEqual([]);
245
+ other.dispatchEvent(new CustomEvent("other-hit"));
246
+ el.dispatchEvent(new CustomEvent("once-hit"));
247
+ el.dispatchEvent(new CustomEvent("plain-hit"));
248
+ el.dispatchEvent(new CustomEvent("listener-host-ping"));
249
+ expect(onOther).toHaveBeenCalledTimes(1);
250
+ expect(onPlain).toHaveBeenCalledTimes(1);
251
+ expect(onOnce).not.toHaveBeenCalled();
252
+ expect(onPing).toHaveBeenCalledTimes(1);
253
+ });
254
+
255
+ it("removeAllListeners clears the element registry and skips collected targets", async () => {
256
+ const el = await mount();
257
+ const fn = vi.fn();
258
+ el.applyEffect({
259
+ addListeners: [
260
+ ["a-hit", fn],
261
+ ["b-hit", fn, { target: document.body }],
262
+ ],
263
+ });
264
+ // a foreign target that has since been garbage-collected is skipped
265
+ // instead of being dereferenced
266
+ const collected = Object.create(WeakRef.prototype, {
267
+ deref: { value: () => undefined },
268
+ });
269
+ el._n_.eventListeners.push(["ghost-hit", fn, { target: collected }]);
270
+
271
+ el.applyEffect({ removeAllListeners: [] });
272
+ expect(el._n_.eventListeners).toEqual([
273
+ ["ghost-hit", fn, { target: collected }],
274
+ ]);
275
+ el.dispatchEvent(new CustomEvent("a-hit"));
276
+ document.body.dispatchEvent(new CustomEvent("b-hit"));
277
+ el.dispatchEvent(new CustomEvent("listener-host-ping"));
278
+ expect(fn).not.toHaveBeenCalled();
279
+ expect(onPing).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("manages broadcast listeners through the plural / toggle / removeAll variants", async () => {
283
+ const el = await mount();
284
+ const fnA = vi.fn();
285
+ const fnB = vi.fn();
286
+ const fnC = vi.fn();
287
+ el.applyEffect({
288
+ addBroadcastListeners: [
289
+ ["bc-a", fnA],
290
+ ["bc-b", fnB, { once: true }],
291
+ ],
292
+ });
293
+ el.applyEffect({ broadcasts: [["bc-a"], ["bc-b", { detail: 2 }]] });
294
+ expect(fnA).toHaveBeenCalledTimes(1);
295
+ expect(fnB).toHaveBeenCalledTimes(1);
296
+ expect((fnB.mock.calls[0][0] as CustomEvent).detail).toBe(2);
297
+
298
+ el.applyEffect({
299
+ toggleBroadcastListeners: [
300
+ ["bc-a", fnA, false],
301
+ ["bc-c", fnC, true],
302
+ ],
303
+ });
304
+ el.applyEffect({ broadcasts: [["bc-a"], ["bc-c"]] });
305
+ expect(fnA).toHaveBeenCalledTimes(1);
306
+ expect(fnC).toHaveBeenCalledTimes(1);
307
+
308
+ el.applyEffect({ removeBroadcastListener: ["bc-c", fnC] });
309
+ el.applyEffect({ removeBroadcastListeners: [["bc-b", fnB]] });
310
+ expect(el._n_.broadcastListeners.map(([t]) => t)).toEqual([
311
+ "listener-host-toast",
312
+ ]);
313
+
314
+ el.applyEffect({ removeAllBroadcastListeners: [] });
315
+ expect(el._n_.broadcastListeners).toEqual([]);
316
+ el.applyEffect({ broadcast: ["toast"] });
317
+ expect(onToast).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it("disconnect detaches broadcast listeners and reconnect restores them", async () => {
321
+ const el = await mount();
322
+ el.remove();
323
+ await wait(0);
324
+ expect(el._n_.broadcastListeners).toEqual([]);
325
+ expect(el._n_.disconnectedBroadcastListeners.map(([t]) => t)).toEqual([
326
+ "listener-host-toast",
327
+ ]);
328
+
329
+ const other = await mount();
330
+ other.applyEffect({ broadcast: ["toast"] });
331
+ expect(onToast).toHaveBeenCalledTimes(1);
332
+ expect(onToast.mock.calls[0][0]).toBe(other);
333
+
334
+ document.body.append(el);
335
+ expect(el._n_.disconnectedBroadcastListeners).toEqual([]);
336
+ other.applyEffect({ broadcast: ["toast"] });
337
+ expect(onToast).toHaveBeenCalledTimes(3);
338
+ expect(onToast.mock.calls.slice(1).map(([target]) => target)).toEqual(
339
+ expect.arrayContaining([el, other])
340
+ );
341
+ });
342
+ });
@@ -0,0 +1,209 @@
1
+ import {
2
+ afterEach,
3
+ describe,
4
+ expect,
5
+ fixture,
6
+ it,
7
+ wait,
8
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
9
+ import {
10
+ attachDevtools,
11
+ NUCLEUS_DEVTOOLS_HOOK_KEY,
12
+ pathMatches,
13
+ type DevtoolsHook,
14
+ type NeutronRenderer,
15
+ type PublicizeMeta,
16
+ type PublicizePath,
17
+ } from "../../index";
18
+ import { Neutron } from "../../index";
19
+
20
+ const TestProbeEl = Neutron({
21
+ tag: "test-probe-el",
22
+ props: {
23
+ label: String,
24
+ },
25
+ })
26
+ .onPropSet("label", () => ({}))
27
+ .onError(() => ({}));
28
+
29
+ TestProbeEl.define();
30
+
31
+ const ErroringEl = Neutron({
32
+ tag: "test-probe-error-el",
33
+ props: {
34
+ boom: Boolean,
35
+ },
36
+ }).onPropSet("boom", () => {
37
+ throw new Error("boom-from-probe");
38
+ });
39
+
40
+ ErroringEl.define();
41
+
42
+ type Publication = { path: PublicizePath; meta: PublicizeMeta };
43
+
44
+ describe("Nucleus DevTools hook", () => {
45
+ const publications: Publication[] = [];
46
+ let injectedRenderer: NeutronRenderer | null = null;
47
+
48
+ const ofPath = (...tokens: string[]) =>
49
+ publications.filter((p) => pathMatches(p.path, tokens));
50
+
51
+ const installHook = () => {
52
+ const hook: DevtoolsHook = {
53
+ version: 1,
54
+ inject: (renderer) => {
55
+ injectedRenderer = renderer as NeutronRenderer;
56
+ },
57
+ publicize: (path, meta) => {
58
+ publications.push({ path, meta });
59
+ },
60
+ };
61
+ attachDevtools(hook);
62
+ return hook;
63
+ };
64
+
65
+ afterEach(() => {
66
+ document.body.innerHTML = "";
67
+ delete (globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY];
68
+ publications.length = 0;
69
+ injectedRenderer = null;
70
+ });
71
+
72
+ it("pathMatches supports prefix and wildcard", () => {
73
+ expect(pathMatches(["neutron", "constructed"], ["neutron"])).toBe(true);
74
+ expect(pathMatches(["neutron", "constructed"], ["neutron", "constructed"])).toBe(
75
+ true,
76
+ );
77
+ expect(pathMatches(["neutron", "constructed"], ["quark"])).toBe(false);
78
+ expect(pathMatches(["neutron", "constructed"], ["*", "constructed"])).toBe(
79
+ true,
80
+ );
81
+ expect(pathMatches(["neutron"], ["neutron", "constructed"])).toBe(false);
82
+ });
83
+
84
+ it("is dormant when no hook is installed", () => {
85
+ const el = fixture<HTMLElement>(`<test-probe-el></test-probe-el>`);
86
+ expect(el.isConnected).toBe(true);
87
+ expect(publications).toHaveLength(0);
88
+ });
89
+
90
+ it("injects the renderer and publicizes constructed/connected", async () => {
91
+ installHook();
92
+ // define() already ran above; re-attach forces inject for late hooks
93
+ attachDevtools((globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY]);
94
+
95
+ const el = fixture<HTMLElement>(`<test-probe-el label="hi"></test-probe-el>`);
96
+ await wait(0);
97
+
98
+ expect(injectedRenderer).not.toBeNull();
99
+ expect(injectedRenderer!.version).toBe(1);
100
+ expect(injectedRenderer!.isNeutronElement(el)).toBe(true);
101
+
102
+ const constructed = ofPath("neutron", "constructed");
103
+ expect(constructed).toHaveLength(1);
104
+ expect(constructed[0].meta.tag).toBe("test-probe-el");
105
+ expect((constructed[0].meta.weakElement as WeakRef<Element>).deref()).toBe(
106
+ el,
107
+ );
108
+
109
+ const connected = ofPath("neutron", "connected");
110
+ expect(connected.length).toBeGreaterThanOrEqual(1);
111
+ expect(connected[0].meta.tag).toBe("test-probe-el");
112
+ expect(connected[0].meta.isFirstMount).toBe(true);
113
+ expect((connected[0].meta.weakElement as WeakRef<Element>).deref()).toBe(el);
114
+ });
115
+
116
+ it("publicizes disconnected when removed", async () => {
117
+ installHook();
118
+ attachDevtools((globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY]);
119
+
120
+ const el = fixture<HTMLElement>(`<test-probe-el></test-probe-el>`);
121
+ await wait(0);
122
+ publications.length = 0;
123
+
124
+ el.remove();
125
+ await wait(0);
126
+
127
+ const disconnected = ofPath("neutron", "disconnected");
128
+ expect(disconnected).toHaveLength(1);
129
+ expect(disconnected[0].meta.tag).toBe("test-probe-el");
130
+ expect(disconnected[0].meta.isMoving).toBeFalsy();
131
+ });
132
+
133
+ it("publicizes commit with changed prop names on batch unlock", async () => {
134
+ installHook();
135
+ attachDevtools((globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY]);
136
+
137
+ const el = fixture<HTMLElement>(`<test-probe-el></test-probe-el>`);
138
+ await wait(0);
139
+ publications.length = 0;
140
+
141
+ (el as any).label = "updated";
142
+ await wait(0);
143
+
144
+ const labelCommits = ofPath("neutron", "commit").filter((p) =>
145
+ (p.meta.changedProps as string[]).includes("label"),
146
+ );
147
+ expect(labelCommits.length).toBeGreaterThanOrEqual(1);
148
+ expect((labelCommits[0].meta.weakElement as WeakRef<Element>).deref()).toBe(
149
+ el,
150
+ );
151
+ });
152
+
153
+ it("inspect() returns a primitive-safe snapshot", async () => {
154
+ installHook();
155
+ attachDevtools((globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY]);
156
+
157
+ const el = fixture<HTMLElement>(
158
+ `<test-probe-el id="probe-1" label="snap"></test-probe-el>`,
159
+ );
160
+ await wait(0);
161
+
162
+ const snap = injectedRenderer!.inspect(el);
163
+ expect(snap).not.toBeNull();
164
+ expect(snap!.tag).toBe("test-probe-el");
165
+ expect(snap!.id).toBe("probe-1");
166
+ expect(snap!.isMounted).toBe(true);
167
+ expect(snap!.props.label).toBe("snap");
168
+ expect(snap!.propNames).toContain("label");
169
+ });
170
+
171
+ it("publicizes effect with effector signature and output", async () => {
172
+ installHook();
173
+ attachDevtools((globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY]);
174
+
175
+ const el = fixture<HTMLElement>(`<test-probe-el></test-probe-el>`);
176
+ await wait(0);
177
+ publications.length = 0;
178
+
179
+ (el as any).label = "updated";
180
+ await wait(0);
181
+
182
+ const effects = ofPath("neutron", "effect").filter(
183
+ (p) => p.meta.signature === 'onPropSet("label")',
184
+ );
185
+ expect(effects.length).toBeGreaterThanOrEqual(1);
186
+ expect(effects[0].meta.tag).toBe("test-probe-el");
187
+ expect(effects[0].meta.effect).toEqual({});
188
+ expect(effects[0].meta.lockDepth).toBeGreaterThanOrEqual(1);
189
+ expect((effects[0].meta.weakElement as WeakRef<Element>).deref()).toBe(el);
190
+ });
191
+
192
+ it("publicizes error when an effector throws", async () => {
193
+ installHook();
194
+ attachDevtools((globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY]);
195
+
196
+ const el = fixture<HTMLElement>(`<test-probe-error-el></test-probe-error-el>`);
197
+ await wait(0);
198
+ publications.length = 0;
199
+
200
+ expect(() => {
201
+ (el as any).boom = true;
202
+ }).toThrow();
203
+
204
+ const errors = ofPath("neutron", "error");
205
+ expect(errors.length).toBeGreaterThanOrEqual(1);
206
+ expect(String(errors[0].meta.errorMessage)).toContain("boom-from-probe");
207
+ expect(errors[0].meta.tag).toBe("test-probe-error-el");
208
+ });
209
+ });
@@ -0,0 +1,125 @@
1
+ import {
2
+ type DevtoolsHook,
3
+ injectRendererIfNeeded,
4
+ Neutron,
5
+ NUCLEUS_DEVTOOLS_HOOK_KEY,
6
+ type NeutronRenderer,
7
+ type PublicizeMeta,
8
+ type PublicizePath,
9
+ } from "../../index";
10
+ import {
11
+ afterEach,
12
+ beforeEach,
13
+ describe,
14
+ expect,
15
+ fixture,
16
+ it,
17
+ wait,
18
+ } from "@excom/heft-rig/profiles/default/config/test-utils";
19
+
20
+ Neutron({
21
+ tag: "render-probe",
22
+ props: {
23
+ labelText: String,
24
+ brokenProp: {
25
+ type: Object,
26
+ get: () => {
27
+ throw new Error("unreadable");
28
+ },
29
+ },
30
+ isBroken: Boolean,
31
+ },
32
+ })
33
+ .onPropSet("isBroken", () => {
34
+ // a non-Error throw: no `.message` / `.name` to report
35
+ throw "plain-failure";
36
+ })
37
+ .define();
38
+
39
+ type Publication = { path: PublicizePath; meta: PublicizeMeta };
40
+
41
+ describe("Neutron devtools renderer", () => {
42
+ let renderer: NeutronRenderer | null = null;
43
+ const injects: unknown[] = [];
44
+ const publications: Publication[] = [];
45
+ const hook: DevtoolsHook = {
46
+ version: 1,
47
+ inject: (r) => {
48
+ injects.push(r);
49
+ renderer = r as NeutronRenderer;
50
+ },
51
+ publicize: (path, meta) => {
52
+ publications.push({ path, meta });
53
+ },
54
+ };
55
+
56
+ beforeEach(() => {
57
+ injects.length = 0;
58
+ publications.length = 0;
59
+ renderer = null;
60
+ Neutron.attachDevtools(hook);
61
+ });
62
+
63
+ afterEach(() => {
64
+ document.body.innerHTML = "";
65
+ delete (globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY];
66
+ });
67
+
68
+ it("injects the neutron renderer once per hook", () => {
69
+ expect(injects).toHaveLength(1);
70
+ expect(renderer!.kind).toBe("neutron");
71
+ expect(renderer!.walkRoot()).toBe(document.body);
72
+ injectRendererIfNeeded();
73
+ expect(injects).toHaveLength(1);
74
+ delete (globalThis as any)[NUCLEUS_DEVTOOLS_HOOK_KEY];
75
+ expect(() => injectRendererIfNeeded()).not.toThrow();
76
+ expect(injects).toHaveLength(1);
77
+ });
78
+
79
+ it("recognises only Neutron elements and snapshots their props safely", async () => {
80
+ const div = document.createElement("div");
81
+ expect(renderer!.isNeutronElement(div)).toBe(false);
82
+ expect(renderer!.inspect(div)).toBe(null);
83
+
84
+ const el = fixture<HTMLElement>(
85
+ `<render-probe label-text="hi"></render-probe>`
86
+ );
87
+ await wait(0);
88
+ expect(renderer!.isNeutronElement(el)).toBe(true);
89
+ const snap = renderer!.inspect(el)!;
90
+ expect(snap).toMatchObject({
91
+ tag: "render-probe",
92
+ id: null,
93
+ isMounted: true,
94
+ wasMounted: false,
95
+ isMoving: false,
96
+ isAdopted: false,
97
+ });
98
+ expect(snap.props.labelText).toBe("hi");
99
+ expect(snap.props.brokenProp).toBe("[unreadable]");
100
+ expect(snap.propNames).toEqual(
101
+ expect.arrayContaining(["labelText", "brokenProp", "isMounted"])
102
+ );
103
+ });
104
+
105
+ it("publishes non-Error throws with a stringified message and no name", async () => {
106
+ const el = fixture<any>(`<render-probe></render-probe>`);
107
+ await wait(0);
108
+ publications.length = 0;
109
+ let caught: unknown;
110
+ try {
111
+ el.isBroken = true;
112
+ } catch (e) {
113
+ caught = e;
114
+ }
115
+ expect(caught).toBe("plain-failure");
116
+ const errors = publications.filter((p) => p.path[1] === "error");
117
+ expect(errors).toHaveLength(1);
118
+ expect(errors[0].meta).toMatchObject({
119
+ tag: "render-probe",
120
+ errorMessage: "plain-failure",
121
+ });
122
+ expect(errors[0].meta.errorName).toBeUndefined();
123
+ expect((errors[0].meta.weakElement as WeakRef<Element>).deref()).toBe(el);
124
+ });
125
+ });