@pyreon/core 0.16.0 → 0.19.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.
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { makeReactiveProps, REACTIVE_PROP, _rp } from '../props'
2
+ import { makeReactiveProps, REACTIVE_PROP, _rp, _wrapSpread } from '../props'
3
3
 
4
4
  describe('makeReactiveProps', () => {
5
5
  it('returns raw object when no reactive props exist (fast path)', () => {
@@ -85,3 +85,73 @@ describe('_rp', () => {
85
85
  expect(branded()).toBe('hello')
86
86
  })
87
87
  })
88
+
89
+ describe('_wrapSpread', () => {
90
+ it('returns null/undefined unchanged (primitive guard)', () => {
91
+ expect(_wrapSpread(null)).toBe(null)
92
+ expect(_wrapSpread(undefined)).toBe(undefined)
93
+ })
94
+
95
+ it('returns source unchanged when no getter descriptors exist (fast path)', () => {
96
+ const source = { a: 1, b: 'x', c: true }
97
+ expect(_wrapSpread(source)).toBe(source)
98
+ })
99
+
100
+ it('returns source unchanged for empty objects', () => {
101
+ const source = {}
102
+ expect(_wrapSpread(source)).toBe(source)
103
+ })
104
+
105
+ it('wraps getter-shaped reactive props as _rp-branded thunks', () => {
106
+ let liveValue = 'a'
107
+ const source = {} as Record<string, unknown>
108
+ Object.defineProperty(source, 'x', {
109
+ get: () => liveValue,
110
+ enumerable: true,
111
+ configurable: true,
112
+ })
113
+
114
+ const result = _wrapSpread(source) as Record<string, unknown>
115
+ expect(result).not.toBe(source) // new object allocated
116
+
117
+ const wrappedX = result.x as () => unknown
118
+ expect(typeof wrappedX).toBe('function')
119
+ expect((wrappedX as unknown as Record<symbol, unknown>)[REACTIVE_PROP]).toBe(true)
120
+
121
+ // Lazy read — each call reads the current source[x] getter value
122
+ expect(wrappedX()).toBe('a')
123
+ liveValue = 'b'
124
+ expect(wrappedX()).toBe('b') // live re-read, not captured
125
+ })
126
+
127
+ it('preserves data properties as-is when mixed with getters', () => {
128
+ const source = { plain: 'data' } as Record<string, unknown>
129
+ Object.defineProperty(source, 'reactive', {
130
+ get: () => 'live',
131
+ enumerable: true,
132
+ configurable: true,
133
+ })
134
+
135
+ const result = _wrapSpread(source) as Record<string, unknown>
136
+ expect(result.plain).toBe('data') // copied through
137
+ expect(typeof result.reactive).toBe('function') // wrapped as thunk
138
+ })
139
+
140
+ it('preserves Reflect.ownKeys symbol-keyed properties', () => {
141
+ const sym = Symbol('marker')
142
+ const source = { regular: 'x' } as Record<string | symbol, unknown>
143
+ Object.defineProperty(source, 'reactive', {
144
+ get: () => 'live',
145
+ enumerable: true,
146
+ configurable: true,
147
+ })
148
+ source[sym] = 'symbol-value'
149
+
150
+ const result = _wrapSpread(source) as Record<string | symbol, unknown>
151
+ expect(result.regular).toBe('x')
152
+ // Note: symbol keys go through Reflect.ownKeys; the wrap path indexes
153
+ // via `key as string` for type narrowing but the runtime carries them
154
+ // forward as data properties.
155
+ expect(typeof result.reactive).toBe('function')
156
+ })
157
+ })
@@ -1,3 +1,4 @@
1
+ import { clearReactiveTrace, signal } from '@pyreon/reactivity'
1
2
  import type { ErrorContext } from '../telemetry'
2
3
  import { registerErrorHandler, reportError } from '../telemetry'
3
4
 
@@ -201,3 +202,96 @@ describe('registerErrorHandler — reactivity bridge (regression)', () => {
201
202
  unsub2()
202
203
  })
203
204
  })
205
+
206
+ describe('reportError — reactiveTrace enrichment', () => {
207
+ beforeEach(() => clearReactiveTrace())
208
+
209
+ test('attaches recent signal writes to the error context (dev)', () => {
210
+ const s = signal(0, { name: 'enrichTest' })
211
+ s.set(1)
212
+ s.set(2)
213
+
214
+ let captured: ErrorContext | undefined
215
+ const unsub = registerErrorHandler((ctx) => {
216
+ captured = ctx
217
+ })
218
+ reportError({
219
+ component: 'C',
220
+ phase: 'render',
221
+ error: new Error('boom'),
222
+ timestamp: Date.now(),
223
+ })
224
+ unsub()
225
+
226
+ expect(captured?.reactiveTrace).toBeDefined()
227
+ expect(captured!.reactiveTrace).toHaveLength(2)
228
+ expect(captured!.reactiveTrace![0]).toMatchObject({
229
+ name: 'enrichTest',
230
+ prev: '0',
231
+ next: '1',
232
+ })
233
+ expect(captured!.reactiveTrace![1]).toMatchObject({ prev: '1', next: '2' })
234
+ })
235
+
236
+ test('does not overwrite a caller-supplied reactiveTrace', () => {
237
+ const s = signal(0, { name: 'x' })
238
+ s.set(99)
239
+
240
+ let captured: ErrorContext | undefined
241
+ const unsub = registerErrorHandler((ctx) => {
242
+ captured = ctx
243
+ })
244
+ const supplied = [{ name: 'manual', prev: 'a', next: 'b', timestamp: 1 }]
245
+ reportError({
246
+ component: 'C',
247
+ phase: 'effect',
248
+ error: new Error('boom'),
249
+ timestamp: Date.now(),
250
+ reactiveTrace: supplied,
251
+ })
252
+ unsub()
253
+
254
+ expect(captured!.reactiveTrace).toBe(supplied)
255
+ })
256
+
257
+ test('no trace field when there were no signal writes', () => {
258
+ let captured: ErrorContext | undefined
259
+ const unsub = registerErrorHandler((ctx) => {
260
+ captured = ctx
261
+ })
262
+ reportError({
263
+ component: 'C',
264
+ phase: 'mount',
265
+ error: new Error('boom'),
266
+ timestamp: Date.now(),
267
+ })
268
+ unsub()
269
+
270
+ // Empty buffer → field stays undefined (don't attach a noisy []).
271
+ expect(captured?.reactiveTrace).toBeUndefined()
272
+ })
273
+
274
+ test('the effect-error bridge path is also enriched', () => {
275
+ const s = signal('idle', { name: 'phase' })
276
+ s.set('running')
277
+
278
+ let captured: ErrorContext | undefined
279
+ const unsub = registerErrorHandler((ctx) => {
280
+ captured = ctx
281
+ })
282
+ // Drive the reactivity → core bridge the same way an effect throw does.
283
+ const bridge = (
284
+ globalThis as { __pyreon_report_error__?: (e: unknown, p: 'effect') => void }
285
+ ).__pyreon_report_error__
286
+ bridge?.(new Error('effect boom'), 'effect')
287
+ unsub()
288
+
289
+ expect(captured?.component).toBe('Effect')
290
+ expect(captured?.reactiveTrace).toBeDefined()
291
+ expect(captured!.reactiveTrace![0]).toMatchObject({
292
+ name: 'phase',
293
+ prev: '"idle"',
294
+ next: '"running"',
295
+ })
296
+ })
297
+ })