@camstack/server 0.1.6 → 0.1.7

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 (42) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. package/src/main.ts +45 -12
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Unit tests for the cap-route-error-formatter.
3
+ *
4
+ * `formatTrpcError` is a pure function — no tRPC plumbing required.
5
+ * We hand-craft minimal TRPCError-like / DefaultErrorShape-like objects
6
+ * to verify the augmentation logic.
7
+ */
8
+ import { describe, it, expect } from 'vitest'
9
+ import { TRPCError } from '@trpc/server'
10
+ import { CapRouteError } from '@camstack/kernel'
11
+ import { formatTrpcError } from '../../api/trpc/cap-route-error-formatter.js'
12
+
13
+ // ── Helpers ──────────────────────────────────────────────────────────────────
14
+
15
+ /** Minimal DefaultErrorShape stub. */
16
+ function makeShape(overrides: Partial<{ message: string }> = {}): Parameters<typeof formatTrpcError>[0]['shape'] {
17
+ return {
18
+ message: overrides.message ?? 'Something went wrong',
19
+ code: -32603, // INTERNAL_SERVER_ERROR code number
20
+ data: {
21
+ code: 'INTERNAL_SERVER_ERROR',
22
+ httpStatus: 500,
23
+ },
24
+ }
25
+ }
26
+
27
+ /** Build a TRPCError-like object that formatTrpcError accepts. */
28
+ function makeTrpcError(
29
+ message: string,
30
+ cause?: Error,
31
+ ): Parameters<typeof formatTrpcError>[0]['error'] {
32
+ return new TRPCError({
33
+ code: 'PRECONDITION_FAILED',
34
+ message,
35
+ cause,
36
+ })
37
+ }
38
+
39
+ // ── Tests ─────────────────────────────────────────────────────────────────────
40
+
41
+ describe('formatTrpcError', () => {
42
+ it('returns shape unchanged when error is not a CapRouteError', () => {
43
+ const shape = makeShape({ message: 'Boom' })
44
+ const error = makeTrpcError('Some generic error')
45
+ const result = formatTrpcError({ error, shape })
46
+ expect(result).toStrictEqual(shape)
47
+ expect(result.data).not.toHaveProperty('capRouteReason')
48
+ })
49
+
50
+ it('augments shape with capRouteReason when error.cause is a CapRouteError', () => {
51
+ const capRouteErr = new CapRouteError('my-cap', undefined, {
52
+ reason: 'no-provider',
53
+ rejected: [{ kind: 'hub-in-process', why: 'no provider bound' }],
54
+ })
55
+ const shape = makeShape()
56
+ const error = makeTrpcError('Capability "my-cap" provider not available', capRouteErr)
57
+ const result = formatTrpcError({ error, shape })
58
+
59
+ expect(result.data.capRouteReason).toBe('no-provider')
60
+ expect(result.data.capRouteRejected).toStrictEqual([
61
+ { kind: 'hub-in-process', why: 'no provider bound' },
62
+ ])
63
+ expect(result.data.capRouteNodeId).toBeUndefined()
64
+ // Original shape fields preserved
65
+ expect(result.message).toBe(shape.message)
66
+ expect(result.code).toBe(shape.code)
67
+ })
68
+
69
+ it('augments shape with capRouteReason when error itself is a CapRouteError', () => {
70
+ const capRouteErr = new CapRouteError('some-cap', undefined, {
71
+ reason: 'node-offline',
72
+ nodeId: 'node-abc',
73
+ rejected: [{ kind: 'remote-moleculer', why: 'node node-abc is offline' }],
74
+ })
75
+ const shape = makeShape()
76
+ // Wrap as TRPCError with the CapRouteError AS the cause
77
+ const error = makeTrpcError('Transport failed', capRouteErr)
78
+ const result = formatTrpcError({ error, shape })
79
+
80
+ expect(result.data.capRouteReason).toBe('node-offline')
81
+ expect(result.data.capRouteNodeId).toBe('node-abc')
82
+ expect(result.data.capRouteRejected).toHaveLength(1)
83
+ })
84
+
85
+ it('surfaces transport-failed reason correctly (not absent provider)', () => {
86
+ const capRouteErr = new CapRouteError('stream-params', 'getStatus', {
87
+ reason: 'transport-failed',
88
+ rejected: [{ kind: 'hub-local-uds', why: 'socket closed' }],
89
+ })
90
+ const shape = makeShape()
91
+ const error = makeTrpcError('Transport failed', capRouteErr)
92
+ const result = formatTrpcError({ error, shape })
93
+
94
+ expect(result.data.capRouteReason).toBe('transport-failed')
95
+ expect(result.data.capRouteRejected).toStrictEqual([
96
+ { kind: 'hub-local-uds', why: 'socket closed' },
97
+ ])
98
+ })
99
+
100
+ it('walks a nested cause chain to find a CapRouteError', () => {
101
+ const capRouteErr = new CapRouteError('ptz', undefined, {
102
+ reason: 'cap-unknown',
103
+ rejected: [],
104
+ })
105
+ // Wrap two levels deep
106
+ const innerError = new Error('dispatch failed', { cause: capRouteErr })
107
+ const shape = makeShape()
108
+ const error = makeTrpcError('Outer error', innerError)
109
+ const result = formatTrpcError({ error, shape })
110
+
111
+ expect(result.data.capRouteReason).toBe('cap-unknown')
112
+ })
113
+
114
+ it('returns shape unchanged for a plain Error (non-CapRouteError cause)', () => {
115
+ const plainErr = new Error('plain cause')
116
+ const shape = makeShape()
117
+ const error = makeTrpcError('Wrapper', plainErr)
118
+ const result = formatTrpcError({ error, shape })
119
+
120
+ expect(result.data).not.toHaveProperty('capRouteReason')
121
+ expect(result).toStrictEqual(shape)
122
+ })
123
+ })
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Spec for the per-node singleton extension in capabilities.router.
3
+ *
4
+ * Exercises:
5
+ * - setActiveSingleton with nodeId → calls registry with (cap, addonId, nodeId)
6
+ * + persists `capabilities.singletonNode.<cap>.<nodeId>`
7
+ * - setActiveSingleton without nodeId → persists `capabilities.singleton.<cap>` (global key)
8
+ * - clearSingletonNodeOverride → calls registry.clearSingletonNodeOverride(cap, nodeId)
9
+ * + persists the per-node key as null
10
+ */
11
+ import { describe, it, expect, vi } from 'vitest'
12
+ import type { CapabilityRegistry } from '@camstack/kernel'
13
+ import type { ConfigService } from '../../core/config/config.service.js'
14
+ import { createCapabilitiesRouter } from '../../api/core/capabilities.router.js'
15
+ import { makeCtx } from './harness.js'
16
+
17
+ function harness() {
18
+ const calls: { setActiveSingleton: unknown[][]; clear: unknown[][] } = { setActiveSingleton: [], clear: [] }
19
+ const sets: Record<string, unknown> = {}
20
+ const registry = {
21
+ setActiveSingleton: vi.fn(async (...a: unknown[]) => { calls.setActiveSingleton.push(a) }),
22
+ clearSingletonNodeOverride: vi.fn((...a: unknown[]) => { calls.clear.push(a) }),
23
+ listCapabilities: () => [],
24
+ } as unknown as CapabilityRegistry
25
+ const config = { set: (k: string, v: unknown) => { sets[k] = v } } as unknown as ConfigService
26
+ const router = createCapabilitiesRouter(registry, config)
27
+ return { router, calls, sets, registry }
28
+ }
29
+
30
+ describe('capabilities.router — per-node singleton', () => {
31
+ it('setActiveSingleton with nodeId persists the per-node key', async () => {
32
+ const { router, calls, sets } = harness()
33
+ const caller = router.createCaller(makeCtx('admin'))
34
+ await caller.setActiveSingleton({
35
+ capability: 'webrtc-session', addonId: 'webrtc-native', nodeId: 'dev-agent-0',
36
+ })
37
+ expect(calls.setActiveSingleton[0]).toEqual(['webrtc-session', 'webrtc-native', 'dev-agent-0'])
38
+ expect(sets['capabilities.singletonNode.webrtc-session.dev-agent-0']).toBe('webrtc-native')
39
+ })
40
+
41
+ it('setActiveSingleton without nodeId persists the global key', async () => {
42
+ const { router, sets } = harness()
43
+ const caller = router.createCaller(makeCtx('admin'))
44
+ await caller.setActiveSingleton({ capability: 'webrtc-session', addonId: 'stream-broker' })
45
+ expect(sets['capabilities.singleton.webrtc-session']).toBe('stream-broker')
46
+ })
47
+
48
+ it('clearSingletonNodeOverride clears the per-node key', async () => {
49
+ const { router, calls, sets } = harness()
50
+ const caller = router.createCaller(makeCtx('admin'))
51
+ await caller.clearSingletonNodeOverride({ capability: 'webrtc-session', nodeId: 'dev-agent-0' })
52
+ expect(calls.clear[0]).toEqual(['webrtc-session', 'dev-agent-0'])
53
+ expect(sets['capabilities.singletonNode.webrtc-session.dev-agent-0']).toBe(null)
54
+ })
55
+ })
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { existsSync, readdirSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ describe('dev bootstrap — shm-ring prebuild availability', () => {
6
+ it('has at least one .node prebuild for the current platform-arch', () => {
7
+ const platform = process.platform
8
+ const arch = process.arch
9
+ const triple = `${platform}-${arch}`
10
+ const prebuildsDir = join(__dirname, '..', '..', '..', '..', 'packages', 'shm-ring', 'prebuilds', triple)
11
+ expect(existsSync(prebuildsDir)).toBe(true)
12
+ const files = readdirSync(prebuildsDir)
13
+ const nodeBinaries = files.filter(f => f.endsWith('.node'))
14
+ expect(nodeBinaries.length).toBeGreaterThan(0)
15
+ })
16
+
17
+ it('@camstack/shm-ring resolves and loads (smoke test)', () => {
18
+ // If this require() throws "No native build was found...", the
19
+ // dev bootstrap is broken — the regression Bug-2 is back.
20
+ let mod: unknown
21
+ expect(() => {
22
+ mod = require('@camstack/shm-ring')
23
+ }).not.toThrow()
24
+ expect(mod).toBeDefined()
25
+ // Should expose at least FrameRingReader/Writer (per the published API)
26
+ const m = mod as Record<string, unknown>
27
+ expect(typeof m['FrameRingReader']).toBe('function')
28
+ expect(typeof m['FrameRingWriter']).toBe('function')
29
+ })
30
+ })
@@ -0,0 +1,249 @@
1
+ /**
2
+ * device-settings-contribution-dispatch.spec.ts
3
+ *
4
+ * Regression tests for Bug-3: snapshot `getDeviceSettingsContribution` not
5
+ * found on native provider.
6
+ *
7
+ * Root cause: device-manager.addon.ts used `getProviderForDevice` for the
8
+ * three `exposesDeviceSettings` contribution methods. `getProviderForDevice`
9
+ * returns the native first when present (e.g. Reolink/ONVIF); the native
10
+ * only implements camera-specific operations (getSnapshot, invalidateCache),
11
+ * NOT the contribution methods. The cross-process dispatch then throws
12
+ * "native-provider: method 'getDeviceSettingsContribution' not found on
13
+ * 'snapshot'".
14
+ *
15
+ * Fix: device-manager now uses `getProvider(capName)` — the active system
16
+ * singleton (the wrapper, e.g. SnapshotAddon) — for all contribution method
17
+ * calls. The native is never touched for these methods.
18
+ *
19
+ * Spec: docs/superpowers/plans/2026-05-21-addons-bulk-update-progress.md (Bug-3)
20
+ */
21
+ import { describe, it, expect, vi } from 'vitest'
22
+ import { DeviceManagerAddon } from '@camstack/core'
23
+ import { CapabilityRegistry, DeviceRegistry } from '@camstack/kernel'
24
+ import { snapshotCapability } from '@camstack/types'
25
+ import type {
26
+ AddonContext, ConfigUISchemaWithValues,
27
+ IScopedLogger, IEventBus, ProviderRegistration,
28
+ IDeviceManagerProvider,
29
+ } from '@camstack/types'
30
+
31
+ // ── Fakes ────────────────────────────────────────────────────────────────────
32
+
33
+ function makeLogger(): IScopedLogger {
34
+ const l: IScopedLogger = {
35
+ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(),
36
+ child: vi.fn(() => l), withTags: vi.fn(() => l),
37
+ }
38
+ return l
39
+ }
40
+
41
+ function makeEventBus(): IEventBus {
42
+ return { emit: vi.fn(), subscribe: () => () => {}, getRecent: () => [] } as unknown as IEventBus
43
+ }
44
+
45
+ function makeSettings() {
46
+ return {
47
+ readAddonStore: vi.fn(async () => ({})),
48
+ writeAddonStore: vi.fn(async () => {}),
49
+ readDeviceStore: vi.fn(async () => ({})),
50
+ writeDeviceStore: vi.fn(async () => {}),
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Wrapper provider (SnapshotAddon equivalent): registered as a system
56
+ * singleton via `registerProvider`. Implements ALL three contribution
57
+ * methods plus the cap-specific operations (getSnapshot, invalidateCache).
58
+ */
59
+ function makeSnapshotWrapperProvider() {
60
+ return {
61
+ // Cap-specific methods
62
+ getSnapshot: vi.fn(async () => null),
63
+ invalidateCache: vi.fn(async () => undefined),
64
+
65
+ // Contribution methods — owned by the wrapper
66
+ getDeviceSettingsContribution: vi.fn(async (_input: { deviceId: number }): Promise<ConfigUISchemaWithValues | null> => ({
67
+ sections: [{
68
+ id: 'snapshot-settings',
69
+ title: 'Snapshot',
70
+ tab: 'general',
71
+ order: 99,
72
+ fields: [
73
+ { type: 'text' as const, key: 'preferredStreamId', label: 'Preferred Stream', default: '', value: '' },
74
+ ],
75
+ }],
76
+ })),
77
+ getDeviceLiveContribution: vi.fn(async (_input: { deviceId: number }): Promise<ConfigUISchemaWithValues | null> => null),
78
+ applyDeviceSettingsPatch: vi.fn(async () => ({ success: true as const })),
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Native provider (Reolink/ONVIF/Hikvision equivalent): registered per-device
84
+ * via `registerNativeProvider`. Implements ONLY the camera-specific operations
85
+ * (getSnapshot, invalidateCache). Does NOT implement contribution methods.
86
+ *
87
+ * Calling any of the three contribution methods on this object simulates the
88
+ * "method not found" scenario that was Bug-3.
89
+ */
90
+ function makeSnapshotNativeProvider() {
91
+ return {
92
+ getSnapshot: vi.fn(async () => ({
93
+ base64: 'abc123',
94
+ contentType: 'image/jpeg',
95
+ })),
96
+ invalidateCache: vi.fn(async () => undefined),
97
+ // Deliberately omit getDeviceSettingsContribution / getDeviceLiveContribution /
98
+ // applyDeviceSettingsPatch — these don't exist on native providers.
99
+ }
100
+ }
101
+
102
+ // ── Scenario setup ────────────────────────────────────────────────────────────
103
+
104
+ async function setupBug3Scenario() {
105
+ const addon = new DeviceManagerAddon()
106
+ const settings = makeSettings()
107
+ const deviceRegistry = new DeviceRegistry()
108
+ const capabilityRegistry = new CapabilityRegistry(makeLogger())
109
+
110
+ const wrapperProvider = makeSnapshotWrapperProvider()
111
+ const nativeProvider = makeSnapshotNativeProvider()
112
+
113
+ // Declare the snapshot capability (kind:'wrapper', defaultActive:true,
114
+ // exposesDeviceSettings:true, mode:'singleton').
115
+ capabilityRegistry.declareCapability(snapshotCapability)
116
+
117
+ // Register the WRAPPER as the system singleton — this is what SnapshotAddon does.
118
+ capabilityRegistry.registerProvider(snapshotCapability.name, 'snapshot-addon', wrapperProvider)
119
+
120
+ // Register the default wrapper so auto-bind picks it up in getBindings step 2.
121
+ capabilityRegistry.registerWrapper(snapshotCapability.name, 'snapshot-addon', { defaultActive: true })
122
+
123
+ const ctx = {
124
+ id: 'device-manager',
125
+ logger: makeLogger(),
126
+ eventBus: makeEventBus(),
127
+ addonConfig: {},
128
+ dataDir: '/tmp',
129
+ settings,
130
+ kernel: { deviceRegistry, capabilityRegistry },
131
+ }
132
+
133
+ const initResult = await addon.initialize(ctx as unknown as AddonContext)
134
+ let providers: readonly ProviderRegistration[] = []
135
+ if (Array.isArray(initResult)) {
136
+ providers = initResult as readonly ProviderRegistration[]
137
+ } else if (initResult && typeof initResult === 'object' && 'providers' in initResult) {
138
+ providers = (initResult.providers ?? []) as readonly ProviderRegistration[]
139
+ }
140
+ const provider = providers[0]!.provider as IDeviceManagerProvider
141
+
142
+ // Allocate and register the device.
143
+ const { id: deviceId } = await provider.allocateDeviceId({ addonId: 'reolink-addon', stableId: 'cam-reolink-1' })
144
+ await provider.registerDevice({
145
+ addonId: 'reolink-addon', stableId: 'cam-reolink-1', id: deviceId,
146
+ type: 'camera', name: 'Reolink Camera', parentDeviceId: null, config: {},
147
+ })
148
+
149
+ // Register the NATIVE provider for the device — this is what a Reolink/ONVIF
150
+ // driver does. The native does NOT implement contribution methods.
151
+ capabilityRegistry.registerNativeProvider(snapshotCapability.name, deviceId, 'reolink-addon', nativeProvider)
152
+
153
+ capabilityRegistry.ready()
154
+
155
+ return { provider, wrapperProvider, nativeProvider, deviceId }
156
+ }
157
+
158
+ // ── Tests ────────────────────────────────────────────────────────────────────
159
+
160
+ describe('device-manager contribution dispatch — Bug-3 regression (snapshot wrapper vs native)', () => {
161
+ it('routes getDeviceSettingsContribution to the wrapper, never to the native', async () => {
162
+ const { provider, wrapperProvider, nativeProvider, deviceId } = await setupBug3Scenario()
163
+
164
+ // This MUST NOT throw "method not found on native" — the fix routes to the wrapper.
165
+ const aggregate = await provider.getDeviceSettingsAggregate({ deviceId })
166
+
167
+ expect(aggregate).not.toBeNull()
168
+ // The wrapper's contribution section must be present.
169
+ const snapshotSection = aggregate!.sections.find(s => s.id === 'snapshot-settings')
170
+ expect(snapshotSection).toBeDefined()
171
+ expect(snapshotSection!.title).toBe('Snapshot')
172
+
173
+ // Wrapper was called exactly once.
174
+ expect(wrapperProvider.getDeviceSettingsContribution).toHaveBeenCalledTimes(1)
175
+ expect(wrapperProvider.getDeviceSettingsContribution).toHaveBeenCalledWith({ deviceId })
176
+
177
+ // Native was NOT called — it doesn't implement contribution methods.
178
+ expect(nativeProvider.getSnapshot).not.toHaveBeenCalled()
179
+ })
180
+
181
+ it('routes getDeviceLiveContribution to the wrapper, never to the native', async () => {
182
+ const { provider, wrapperProvider, nativeProvider, deviceId } = await setupBug3Scenario()
183
+
184
+ // This MUST NOT throw "method not found on native".
185
+ const liveAggregate = await provider.getDeviceLiveInfoAggregate({ deviceId })
186
+
187
+ // The wrapper returns null for live contributions in our fake (acceptable).
188
+ // Key assertion: the wrapper was invoked, and no error was thrown.
189
+ expect(wrapperProvider.getDeviceLiveContribution).toHaveBeenCalledTimes(1)
190
+ expect(wrapperProvider.getDeviceLiveContribution).toHaveBeenCalledWith({ deviceId })
191
+
192
+ // Native was NOT called for live contributions.
193
+ expect(nativeProvider.getSnapshot).not.toHaveBeenCalled()
194
+ // liveAggregate may be null (wrapper returned null for live) — that's fine.
195
+ // The important thing is no throw occurred.
196
+ expect(() => liveAggregate).not.toThrow()
197
+ })
198
+
199
+ it('routes applyDeviceSettingsPatch to the wrapper, never to the native', async () => {
200
+ const { provider, wrapperProvider, nativeProvider, deviceId } = await setupBug3Scenario()
201
+
202
+ // First get the aggregate to confirm the contribution fields are tagged correctly.
203
+ const aggregate = await provider.getDeviceSettingsAggregate({ deviceId })
204
+ expect(aggregate).not.toBeNull()
205
+
206
+ const field = aggregate!.sections
207
+ .find(s => s.id === 'snapshot-settings')
208
+ ?.fields
209
+ .find(f => (f as Record<string, unknown>)['key'] === 'preferredStreamId') as Record<string, unknown> | undefined
210
+
211
+ expect(field).toBeDefined()
212
+ expect(field!['writerCapName']).toBe('snapshot')
213
+
214
+ // Now apply a settings patch — must go to the wrapper, NOT the native.
215
+ await provider.updateDeviceField({
216
+ deviceId,
217
+ writerCapName: 'snapshot',
218
+ writerAddonId: field!['writerAddonId'] as string,
219
+ key: 'preferredStreamId',
220
+ value: 'high',
221
+ })
222
+
223
+ // Wrapper's applyDeviceSettingsPatch was called with the correct patch.
224
+ expect(wrapperProvider.applyDeviceSettingsPatch).toHaveBeenCalledWith({
225
+ deviceId,
226
+ patch: { preferredStreamId: 'high' },
227
+ })
228
+
229
+ // Native was NOT called for the settings write.
230
+ expect(nativeProvider.getSnapshot).not.toHaveBeenCalled()
231
+ })
232
+
233
+ it('does not throw when both a native and a wrapper provider are registered for the same cap', async () => {
234
+ // This is the exact Bug-3 scenario: native registered for device, wrapper
235
+ // registered as system singleton. Before the fix, calling
236
+ // getDeviceSettingsAggregate would throw because getProviderForDevice
237
+ // returned the native which lacks contribution methods.
238
+ const { provider, deviceId } = await setupBug3Scenario()
239
+
240
+ // Must complete without throwing.
241
+ await expect(
242
+ provider.getDeviceSettingsAggregate({ deviceId }),
243
+ ).resolves.not.toBeNull()
244
+
245
+ await expect(
246
+ provider.getDeviceLiveInfoAggregate({ deviceId }),
247
+ ).resolves.toBeDefined()
248
+ })
249
+ })
@@ -0,0 +1,165 @@
1
+ /**
2
+ * deferRestart flag on AddonPackageService.updateFrameworkPackage
3
+ *
4
+ * Spec: docs/superpowers/specs/2026-05-21-addons-bulk-update-progress-design.md
5
+ *
6
+ * Invariants:
7
+ * - deferRestart: true → npm install runs normally; marker write AND
8
+ * scheduleSelfRestart are SKIPPED; restartingAt is 0 (sentinel).
9
+ * - deferRestart omitted (default) → marker write AND scheduleSelfRestart
10
+ * both fire; restartingAt > 0.
11
+ */
12
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
13
+ import * as os from 'node:os'
14
+ import * as path from 'node:path'
15
+ import * as fs from 'node:fs'
16
+ import type { IScopedLogger } from '@camstack/types'
17
+
18
+ // ── Module-level mocks ──────────────────────────────────────────────────────
19
+ // Must be hoisted before any import that loads the module under test.
20
+ // vi.mock is statically hoisted by vitest's transform, so the factories
21
+ // run before the module graph is resolved.
22
+
23
+ vi.mock('@camstack/kernel', async (importOriginal) => {
24
+ const real = await importOriginal<typeof import('@camstack/kernel')>()
25
+ return {
26
+ ...real,
27
+ scheduleSelfRestart: vi.fn(),
28
+ writePendingRestart: vi.fn(),
29
+ // resolveNpmVersion and resolveFrameworkPackageAppRoot are private module
30
+ // functions inside addon-package.service.ts — they are NOT exported from
31
+ // @camstack/kernel, so we don't need to stub them here.
32
+ }
33
+ })
34
+
35
+ // Mock node:child_process so no actual `npm install` / `npm view` is executed.
36
+ vi.mock('node:child_process', async (importOriginal) => {
37
+ const real = await importOriginal<typeof import('node:child_process')>()
38
+ return {
39
+ ...real,
40
+ execFile: vi.fn(
41
+ (
42
+ _cmd: string,
43
+ _args: string[],
44
+ _opts: unknown,
45
+ callback: (err: null, result: { stdout: string; stderr: string }) => void,
46
+ ) => {
47
+ // Simulate `npm view @camstack/types@0.1.40 version` → '0.1.40'
48
+ callback(null, { stdout: '0.1.40\n', stderr: '' })
49
+ return { kill: () => undefined }
50
+ },
51
+ ),
52
+ }
53
+ })
54
+
55
+ // ── Imports that depend on the mocked modules ───────────────────────────────
56
+ import { scheduleSelfRestart, writePendingRestart } from '@camstack/kernel'
57
+ import { AddonPackageService } from '../core/addon/addon-package.service.js'
58
+ import { EventCategory } from '@camstack/types'
59
+
60
+ // ── Helpers ──────────────────────────────────────────────────────────────────
61
+
62
+ function makeLogger(): IScopedLogger {
63
+ const logger: IScopedLogger = {
64
+ info: vi.fn(),
65
+ warn: vi.fn(),
66
+ error: vi.fn(),
67
+ debug: vi.fn(),
68
+ trace: vi.fn(),
69
+ fatal: vi.fn(),
70
+ child: (() => logger) as IScopedLogger['child'],
71
+ } as unknown as IScopedLogger
72
+ return logger
73
+ }
74
+
75
+ interface ServiceEnv {
76
+ readonly service: AddonPackageService
77
+ readonly tmpDir: string
78
+ readonly scheduleSelfRestartMock: ReturnType<typeof vi.fn>
79
+ readonly writePendingRestartMock: ReturnType<typeof vi.fn>
80
+ }
81
+
82
+ function createEnv(): ServiceEnv {
83
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'defer-restart-spec-'))
84
+
85
+ const logger = makeLogger()
86
+
87
+ const loggingService = {
88
+ createLogger: () => logger,
89
+ }
90
+
91
+ const eventBusService = {
92
+ emit: vi.fn(),
93
+ }
94
+
95
+ const configService = {}
96
+ const addonRegistry = {}
97
+ const notificationService = {}
98
+ const toastService = {}
99
+
100
+ const service = new AddonPackageService(
101
+ loggingService as never,
102
+ eventBusService as never,
103
+ configService as never,
104
+ addonRegistry as never,
105
+ notificationService as never,
106
+ toastService as never,
107
+ )
108
+
109
+ // Point the framework-package app-root resolver at our tmp dir so
110
+ // `npm install --prefix <appRoot>` (which is mocked anyway) has a
111
+ // valid-looking path without requiring the real workspace layout.
112
+ process.env['CAMSTACK_FRAMEWORK_APP_ROOT_OVERRIDE'] = tmpDir
113
+
114
+ return {
115
+ service,
116
+ tmpDir,
117
+ scheduleSelfRestartMock: scheduleSelfRestart as ReturnType<typeof vi.fn>,
118
+ writePendingRestartMock: writePendingRestart as ReturnType<typeof vi.fn>,
119
+ }
120
+ }
121
+
122
+ // ── Tests ────────────────────────────────────────────────────────────────────
123
+
124
+ describe('AddonPackageService.updateFrameworkPackage — deferRestart flag', () => {
125
+ let env: ServiceEnv
126
+
127
+ beforeEach(() => {
128
+ vi.clearAllMocks()
129
+ env = createEnv()
130
+ })
131
+
132
+ afterEach(() => {
133
+ fs.rmSync(env.tmpDir, { recursive: true, force: true })
134
+ delete process.env['CAMSTACK_FRAMEWORK_APP_ROOT_OVERRIDE']
135
+ vi.restoreAllMocks()
136
+ })
137
+
138
+ it('skips restart seam (writePendingRestart + scheduleSelfRestart) when deferRestart is true', async () => {
139
+ const result = await env.service.updateFrameworkPackage({
140
+ packageName: '@camstack/types',
141
+ version: '0.1.40',
142
+ deferRestart: true,
143
+ })
144
+
145
+ expect(env.writePendingRestartMock).not.toHaveBeenCalled()
146
+ expect(env.scheduleSelfRestartMock).not.toHaveBeenCalled()
147
+ expect(result.packageName).toBe('@camstack/types')
148
+ expect(result.toVersion).toBe('0.1.40')
149
+ // Sentinel: 0 signals "no restart scheduled"
150
+ expect(result.restartingAt).toBe(0)
151
+ })
152
+
153
+ it('fires writePendingRestart + scheduleSelfRestart when deferRestart is omitted', async () => {
154
+ const result = await env.service.updateFrameworkPackage({
155
+ packageName: '@camstack/types',
156
+ version: '0.1.40',
157
+ })
158
+
159
+ expect(env.writePendingRestartMock).toHaveBeenCalledOnce()
160
+ expect(env.scheduleSelfRestartMock).toHaveBeenCalledOnce()
161
+ expect(result.packageName).toBe('@camstack/types')
162
+ expect(result.toVersion).toBe('0.1.40')
163
+ expect(result.restartingAt).toBeGreaterThan(0)
164
+ })
165
+ })