@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.
- package/package.json +3 -3
- 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/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -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/broker-routing.router.spec.ts +169 -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__/cap-routers/device-link-overlay.spec.ts +132 -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 +329 -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/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -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 +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- 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 +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- 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
|
+
})
|