@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.
- package/package.json +1 -1
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +59 -6
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +130 -0
- package/src/api/trpc/generated-cap-mounts.ts +19 -1
- package/src/api/trpc/generated-cap-routers.ts +180 -1
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +45 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +364 -105
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +380 -36
- 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
|
+
})
|