@camstack/server 0.1.6 → 0.1.8

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 (60) hide show
  1. package/package.json +3 -3
  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/cap-providers-location-import.spec.ts +186 -0
  6. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  7. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  8. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  9. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  10. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  11. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  12. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  13. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  14. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  15. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  16. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  17. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  18. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
  19. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  20. package/src/__tests__/native-cap-route.spec.ts +404 -0
  21. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  22. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  23. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  24. package/src/api/addon-upload.ts +27 -1
  25. package/src/api/capabilities.router.ts +1 -1
  26. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  27. package/src/api/core/bulk-update-coordinator.ts +302 -0
  28. package/src/api/core/cap-providers.ts +211 -9
  29. package/src/api/core/capabilities.router.ts +26 -3
  30. package/src/api/core/logs.router.ts +4 -0
  31. package/src/api/oauth2/oauth2-routes.ts +5 -1
  32. package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
  33. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  34. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  35. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  36. package/src/api/trpc/client-ip.ts +147 -0
  37. package/src/api/trpc/generated-cap-mounts.ts +299 -8
  38. package/src/api/trpc/generated-cap-routers.ts +2384 -302
  39. package/src/api/trpc/trpc.middleware.ts +5 -1
  40. package/src/api/trpc/trpc.router.ts +84 -3
  41. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  42. package/src/boot/integration-id-backfill.ts +109 -0
  43. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  44. package/src/core/addon/addon-call-gateway.ts +157 -0
  45. package/src/core/addon/addon-package.service.ts +9 -0
  46. package/src/core/addon/addon-registry.service.ts +453 -107
  47. package/src/core/addon/addon-row-manifest.ts +29 -0
  48. package/src/core/addon/addon-settings-provider.ts +40 -116
  49. package/src/core/capability/capability.service.ts +9 -0
  50. package/src/core/logging/logging.service.ts +7 -2
  51. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  52. package/src/core/moleculer/cap-call-fn.ts +103 -0
  53. package/src/core/moleculer/cap-route-authority.ts +182 -0
  54. package/src/core/moleculer/moleculer.service.ts +408 -36
  55. package/src/core/network/network-quality.service.spec.ts +2 -1
  56. package/src/main.ts +137 -12
  57. package/src/core/storage/settings-store.spec.ts +0 -213
  58. package/src/core/storage/settings-store.ts +0 -2
  59. package/src/core/storage/sql-schema.spec.ts +0 -140
  60. package/src/core/storage/sql-schema.ts +0 -3
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Tests that `requireDeviceScoped` overlays `getStatus` results via
3
+ * `device-manager`'s `resolveLinkedStatus` when the device has linked
4
+ * properties for the requested cap.
5
+ *
6
+ * The overlay is transparent (null return = no-op) for the vast majority
7
+ * of reads; only devices with active links for the cap get the merged
8
+ * status back.
9
+ */
10
+ import { describe, it, expect, vi } from 'vitest'
11
+ import type { CapabilityRegistry } from '@camstack/kernel'
12
+ import { requireDeviceScoped } from '../../api/trpc/cap-mount-helpers.js'
13
+
14
+ // ── Fake registry ────────────────────────────────────────────────────────────
15
+
16
+ type ResolveLinkedStatus = (i: {
17
+ deviceId: number
18
+ cap: string
19
+ baseStatus: unknown
20
+ }) => Promise<Record<string, unknown> | null>
21
+
22
+ interface FakeDeviceManager {
23
+ resolveLinkedStatus: ResolveLinkedStatus
24
+ }
25
+
26
+ function makeRegistry(opts: {
27
+ nativeGetStatus: () => Promise<Record<string, unknown>>
28
+ nativeSetFanSpeed?: (i: unknown) => Promise<void>
29
+ resolveLinkedStatus: ResolveLinkedStatus
30
+ }): CapabilityRegistry {
31
+ const nativeProvider = {
32
+ getStatus: opts.nativeGetStatus,
33
+ setFanSpeed: opts.nativeSetFanSpeed ?? vi.fn(async () => undefined),
34
+ }
35
+ const deviceManager: FakeDeviceManager = {
36
+ resolveLinkedStatus: opts.resolveLinkedStatus,
37
+ }
38
+ return {
39
+ getNativeProvider<T>(_capName: string, _deviceId: number): T | null {
40
+ return nativeProvider as unknown as T
41
+ },
42
+ getSingleton<T>(capability: string): T | null {
43
+ if (capability === 'device-manager') {
44
+ return deviceManager as unknown as T
45
+ }
46
+ return null
47
+ },
48
+ // Minimal no-op stubs for the rest of the CapabilityRegistry surface
49
+ // so TypeScript is satisfied without pulling in the real kernel class.
50
+ listCapabilities: vi.fn(() => []),
51
+ registerProvider: vi.fn(),
52
+ unregisterProvider: vi.fn(),
53
+ getCollection: vi.fn(() => []),
54
+ getCollectionEntries: vi.fn(() => []),
55
+ registerNativeProvider: vi.fn(),
56
+ unregisterNativeProvider: vi.fn(),
57
+ getProviderForDevice: vi.fn(() => null),
58
+ getBindings: vi.fn(() => ({ entries: [] })),
59
+ setActiveSingleton: vi.fn(),
60
+ getSingletonAddonId: vi.fn(() => null),
61
+ getAddonIdForProvider: vi.fn(() => null),
62
+ on: vi.fn(),
63
+ off: vi.fn(),
64
+ dispose: vi.fn(),
65
+ } as unknown as CapabilityRegistry
66
+ }
67
+
68
+ // ── Tests ────────────────────────────────────────────────────────────────────
69
+
70
+ const DEVICE_ID = 668
71
+ const CAP_NAME = 'vacuum-control' as Parameters<typeof requireDeviceScoped>[1]
72
+
73
+ describe('requireDeviceScoped — getStatus overlay via resolveLinkedStatus', () => {
74
+ it('returns the overlaid status when resolveLinkedStatus returns a non-null object', async () => {
75
+ const base = { state: 'idle', battery: 80 }
76
+ const overlaid = { state: 'paused', cleanWater: { status: 'low', level: null } }
77
+
78
+ const registry = makeRegistry({
79
+ nativeGetStatus: vi.fn(async () => base),
80
+ resolveLinkedStatus: vi.fn(async () => overlaid),
81
+ })
82
+
83
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
84
+ expect(dispatcher).not.toBeNull()
85
+
86
+ // Call via the Proxy — method is resolved lazily
87
+ const result = await (dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> })
88
+ .getStatus({ deviceId: DEVICE_ID })
89
+
90
+ expect(result).toEqual(overlaid)
91
+ expect(result).not.toEqual(base)
92
+ })
93
+
94
+ it('returns the base provider result unchanged when resolveLinkedStatus returns null (no links)', async () => {
95
+ const base = { state: 'cleaning', battery: 60 }
96
+
97
+ const registry = makeRegistry({
98
+ nativeGetStatus: vi.fn(async () => base),
99
+ resolveLinkedStatus: vi.fn(async () => null),
100
+ })
101
+
102
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
103
+ expect(dispatcher).not.toBeNull()
104
+
105
+ const result = await (dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> })
106
+ .getStatus({ deviceId: DEVICE_ID })
107
+
108
+ expect(result).toEqual(base)
109
+ })
110
+
111
+ it('does NOT call resolveLinkedStatus for non-getStatus methods', async () => {
112
+ const base = { state: 'idle', battery: 90 }
113
+ const resolveLinkedStatus = vi.fn(async () => null)
114
+ const nativeSetFanSpeed = vi.fn(async () => undefined)
115
+
116
+ const registry = makeRegistry({
117
+ nativeGetStatus: vi.fn(async () => base),
118
+ nativeSetFanSpeed,
119
+ resolveLinkedStatus,
120
+ })
121
+
122
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
123
+ expect(dispatcher).not.toBeNull()
124
+
125
+ await (dispatcher as unknown as { setFanSpeed: (i: { deviceId: number; speed: string }) => Promise<void> })
126
+ .setFanSpeed({ deviceId: DEVICE_ID, speed: 'high' })
127
+
128
+ expect(nativeSetFanSpeed).toHaveBeenCalledOnce()
129
+ // The overlay path must NOT be consulted for mutations
130
+ expect(resolveLinkedStatus).not.toHaveBeenCalled()
131
+ })
132
+ })
@@ -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
+ })