@camstack/server 0.1.3
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/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
|
|
2
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
3
|
+
import { CapabilityRegistry, isInfraCapability } from '@camstack/kernel'
|
|
4
|
+
import type { IScopedLogger } from '@camstack/types'
|
|
5
|
+
|
|
6
|
+
// ─── Mock helpers ────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// PipelineOrchestrator phase-transition + result-flow tests used to live
|
|
9
|
+
// in this file (importing from the now-deleted server/backend/src/core/
|
|
10
|
+
// orchestrator/ directory). They have been migrated into the runner package
|
|
11
|
+
// (`packages/addon-pipeline-runner/src/__tests__/runner.spec.ts`) which
|
|
12
|
+
// is the canonical home of the scheduler core.
|
|
13
|
+
|
|
14
|
+
function createMockLogger(): IScopedLogger {
|
|
15
|
+
return {
|
|
16
|
+
info: vi.fn(),
|
|
17
|
+
warn: vi.fn(),
|
|
18
|
+
error: vi.fn(),
|
|
19
|
+
debug: vi.fn(),
|
|
20
|
+
} as unknown as IScopedLogger
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function createRegistry(configReader?: (cap: string) => string | undefined): CapabilityRegistry {
|
|
24
|
+
const registry = new CapabilityRegistry(createMockLogger())
|
|
25
|
+
if (configReader) {
|
|
26
|
+
registry.setConfigReader(configReader)
|
|
27
|
+
}
|
|
28
|
+
registry.ready()
|
|
29
|
+
return registry
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// ─── Tests ────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
describe('Full Lifecycle E2E (mock, no external services)', () => {
|
|
35
|
+
describe('CapabilityRegistry boot sequence', () => {
|
|
36
|
+
it('registers singleton and collection capabilities correctly', () => {
|
|
37
|
+
const registry = createRegistry()
|
|
38
|
+
|
|
39
|
+
registry.declareCapability({ name: 'storage', scope: 'system', mode: 'singleton', methods: {} })
|
|
40
|
+
registry.declareCapability({ name: 'log-destination', scope: 'system', mode: 'collection', methods: {} })
|
|
41
|
+
registry.declareCapability({ name: 'streaming-engine', scope: 'system', mode: 'singleton', methods: {} })
|
|
42
|
+
registry.declareCapability({ name: 'analysis-pipeline', scope: 'system', mode: 'singleton', methods: {} })
|
|
43
|
+
registry.declareCapability({ name: 'device-provider', scope: 'system', mode: 'collection', methods: {} })
|
|
44
|
+
registry.declareCapability({ name: 'admin-ui', scope: 'system', mode: 'singleton', methods: {} })
|
|
45
|
+
|
|
46
|
+
const mockStorage = { id: 'sqlite' }
|
|
47
|
+
const mockLogger = { id: 'winston' }
|
|
48
|
+
const mockStreaming = { id: 'go2rtc' }
|
|
49
|
+
const mockAnalysis = { id: 'pipeline-analysis' }
|
|
50
|
+
const mockProvider = { id: 'frigate' }
|
|
51
|
+
|
|
52
|
+
registry.registerProvider('storage', 'sqlite-storage', mockStorage)
|
|
53
|
+
registry.registerProvider('log-destination', 'winston-logging', mockLogger)
|
|
54
|
+
registry.registerProvider('streaming-engine', 'go2rtc', mockStreaming)
|
|
55
|
+
registry.registerProvider('analysis-pipeline', 'pipeline-analysis', mockAnalysis)
|
|
56
|
+
registry.registerProvider('device-provider', 'provider-frigate', mockProvider)
|
|
57
|
+
|
|
58
|
+
expect(registry.getSingleton('storage')).toBe(mockStorage)
|
|
59
|
+
expect(registry.getCollection('log-destination')).toEqual([mockLogger])
|
|
60
|
+
expect(registry.getSingleton('streaming-engine')).toBe(mockStreaming)
|
|
61
|
+
expect(registry.getSingleton('analysis-pipeline')).toBe(mockAnalysis)
|
|
62
|
+
expect(registry.getCollection('device-provider')).toEqual([mockProvider])
|
|
63
|
+
|
|
64
|
+
const caps = registry.listCapabilities()
|
|
65
|
+
expect(caps.length).toBeGreaterThanOrEqual(5)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('infra capabilities are identified correctly', () => {
|
|
69
|
+
expect(isInfraCapability('storage')).toBe(true)
|
|
70
|
+
expect(isInfraCapability('log-destination')).toBe(true)
|
|
71
|
+
expect(isInfraCapability('streaming-engine')).toBe(false)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
// Note: PipelineOrchestrator phase transition + detection result flow tests
|
|
76
|
+
// moved to packages/addon-pipeline-runner/src/__tests__/runner.spec.ts
|
|
77
|
+
// when the scheduler was extracted into the addon-pipeline-runner package.
|
|
78
|
+
|
|
79
|
+
describe('Singleton swap', () => {
|
|
80
|
+
it('swapping analysis provider changes getSingleton result', async () => {
|
|
81
|
+
const registry = createRegistry()
|
|
82
|
+
registry.declareCapability({ name: 'analysis-pipeline', scope: 'system', mode: 'singleton', methods: {} })
|
|
83
|
+
|
|
84
|
+
const analysisA = { id: 'analysis-a', processFrame: vi.fn() }
|
|
85
|
+
const analysisB = { id: 'analysis-b', processFrame: vi.fn() }
|
|
86
|
+
|
|
87
|
+
registry.registerProvider('analysis-pipeline', 'addon-a', analysisA)
|
|
88
|
+
expect(registry.getSingleton('analysis-pipeline')).toBe(analysisA)
|
|
89
|
+
|
|
90
|
+
registry.registerProvider('analysis-pipeline', 'addon-b', analysisB)
|
|
91
|
+
|
|
92
|
+
await registry.setActiveSingleton('analysis-pipeline', 'addon-b', true)
|
|
93
|
+
expect(registry.getSingleton('analysis-pipeline')).toBe(analysisB)
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe('Collection capability add/remove', () => {
|
|
98
|
+
it('adding and removing device providers', () => {
|
|
99
|
+
const registry = createRegistry()
|
|
100
|
+
registry.declareCapability({ name: 'device-provider', scope: 'system', mode: 'collection', methods: {} })
|
|
101
|
+
|
|
102
|
+
const frigate = { id: 'frigate', type: 'frigate' }
|
|
103
|
+
const onvif = { id: 'onvif', type: 'onvif' }
|
|
104
|
+
|
|
105
|
+
registry.registerProvider('device-provider', 'addon-frigate', frigate)
|
|
106
|
+
expect(registry.getCollection('device-provider')).toHaveLength(1)
|
|
107
|
+
|
|
108
|
+
registry.registerProvider('device-provider', 'addon-onvif', onvif)
|
|
109
|
+
expect(registry.getCollection('device-provider')).toHaveLength(2)
|
|
110
|
+
|
|
111
|
+
registry.unregisterProvider('device-provider', 'addon-frigate')
|
|
112
|
+
expect(registry.getCollection('device-provider')).toHaveLength(1)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('Capability introspection', () => {
|
|
117
|
+
it('listCapabilities returns full info', () => {
|
|
118
|
+
const registry = createRegistry()
|
|
119
|
+
registry.declareCapability({ name: 'storage', scope: 'system', mode: 'singleton', methods: {} })
|
|
120
|
+
registry.declareCapability({ name: 'device-provider', scope: 'system', mode: 'collection', methods: {} })
|
|
121
|
+
|
|
122
|
+
registry.registerProvider('storage', 'sqlite', { id: 'sqlite' })
|
|
123
|
+
registry.registerProvider('device-provider', 'frigate', { id: 'frigate' })
|
|
124
|
+
registry.registerProvider('device-provider', 'onvif', { id: 'onvif' })
|
|
125
|
+
|
|
126
|
+
const caps = registry.listCapabilities()
|
|
127
|
+
|
|
128
|
+
const storage = caps.find((c) => c.name === 'storage')
|
|
129
|
+
expect(storage).toBeDefined()
|
|
130
|
+
expect(storage!.mode).toBe('singleton')
|
|
131
|
+
expect(storage!.providers).toContain('sqlite')
|
|
132
|
+
expect(storage!.activeProvider).toBe('sqlite')
|
|
133
|
+
|
|
134
|
+
const providers = caps.find((c) => c.name === 'device-provider')
|
|
135
|
+
expect(providers).toBeDefined()
|
|
136
|
+
expect(providers!.mode).toBe('collection')
|
|
137
|
+
expect(providers!.providers).toHaveLength(2)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live events subscription — unit + in-process e2e test.
|
|
3
|
+
*
|
|
4
|
+
* Proves that an event emitted on `EventBusService` is delivered to a
|
|
5
|
+
* consumer that went through `live.onEvent` → `iterableSubscription`.
|
|
6
|
+
* This is the same path UI components use via the WS client (AlertBell,
|
|
7
|
+
* EngineTab). If this test passes but the UI still doesn't receive
|
|
8
|
+
* events, the bug is downstream in the WS adapter, not in the routing.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
11
|
+
import { SystemEventBus } from '@camstack/core'
|
|
12
|
+
import { createLiveEventsRouter } from '../api/core/live-events.router.js'
|
|
13
|
+
import { makeCtx } from './cap-routers/harness.js'
|
|
14
|
+
import type { EventBusService } from '../core/events/event-bus.service.js'
|
|
15
|
+
|
|
16
|
+
function sleep(ms: number): Promise<void> {
|
|
17
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeEvent(category: string, data: Record<string, unknown> = {}) {
|
|
21
|
+
return {
|
|
22
|
+
id: `evt-${Math.random().toString(36).slice(2, 8)}`,
|
|
23
|
+
timestamp: new Date(),
|
|
24
|
+
source: { type: 'pipeline' as const, id: 'benchmark' },
|
|
25
|
+
category,
|
|
26
|
+
data: { type: `pipeline.${category}`, ...data },
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('live events subscription', () => {
|
|
31
|
+
it('delivers events emitted AFTER subscription', async () => {
|
|
32
|
+
const bus = new SystemEventBus() as unknown as EventBusService
|
|
33
|
+
const router = createLiveEventsRouter(
|
|
34
|
+
bus,
|
|
35
|
+
{ getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
|
|
36
|
+
)
|
|
37
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
38
|
+
|
|
39
|
+
const iter = await caller.onEvent({ category: 'benchmark.progress' })
|
|
40
|
+
|
|
41
|
+
// Collect received events concurrently while emitting.
|
|
42
|
+
const received: Array<{ category: string; data: Record<string, unknown> }> = []
|
|
43
|
+
const collector = (async () => {
|
|
44
|
+
for await (const ev of iter) {
|
|
45
|
+
received.push({ category: ev.category, data: ev.data })
|
|
46
|
+
if (received.length >= 3) return
|
|
47
|
+
}
|
|
48
|
+
})()
|
|
49
|
+
|
|
50
|
+
// Give the async generator a tick to wire up its internal promise.
|
|
51
|
+
await sleep(10)
|
|
52
|
+
|
|
53
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: 1, totalMs: 50 }))
|
|
54
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: 2, totalMs: 48 }))
|
|
55
|
+
bus.emit(makeEvent('other.category', { ignored: true }))
|
|
56
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: 3, totalMs: 52 }))
|
|
57
|
+
|
|
58
|
+
await collector
|
|
59
|
+
|
|
60
|
+
expect(received).toHaveLength(3)
|
|
61
|
+
expect(received.map((r) => r.data.iteration)).toEqual([1, 2, 3])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('filters events that do not match the requested category', async () => {
|
|
65
|
+
const bus = new SystemEventBus() as unknown as EventBusService
|
|
66
|
+
const router = createLiveEventsRouter(
|
|
67
|
+
bus,
|
|
68
|
+
{ getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
|
|
69
|
+
)
|
|
70
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
71
|
+
|
|
72
|
+
const iter = await caller.onEvent({ category: 'benchmark.progress' })
|
|
73
|
+
|
|
74
|
+
const received: string[] = []
|
|
75
|
+
const stop = vi.fn()
|
|
76
|
+
const collector = (async () => {
|
|
77
|
+
for await (const ev of iter) {
|
|
78
|
+
received.push(ev.category)
|
|
79
|
+
if (received.length >= 2) { stop(); return }
|
|
80
|
+
}
|
|
81
|
+
})()
|
|
82
|
+
|
|
83
|
+
await sleep(10)
|
|
84
|
+
|
|
85
|
+
// Emit a mix — only 'benchmark.progress' should pass.
|
|
86
|
+
bus.emit(makeEvent('alert.created', {}))
|
|
87
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: 1 }))
|
|
88
|
+
bus.emit(makeEvent('benchmark.heartbeat', {}))
|
|
89
|
+
bus.emit(makeEvent('alert.updated', {}))
|
|
90
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: 2 }))
|
|
91
|
+
|
|
92
|
+
await collector
|
|
93
|
+
|
|
94
|
+
expect(received).toEqual(['benchmark.progress', 'benchmark.progress'])
|
|
95
|
+
expect(stop).toHaveBeenCalledOnce()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('receives recent events via recentSystemEvents query', async () => {
|
|
99
|
+
const bus = new SystemEventBus() as unknown as EventBusService
|
|
100
|
+
const router = createLiveEventsRouter(
|
|
101
|
+
bus,
|
|
102
|
+
{ getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
|
|
103
|
+
)
|
|
104
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
105
|
+
|
|
106
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: 1 }))
|
|
107
|
+
bus.emit(makeEvent('alert.created', {}))
|
|
108
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: 2 }))
|
|
109
|
+
|
|
110
|
+
const recent = await caller.recentSystemEvents({ category: 'benchmark.progress', limit: 10 })
|
|
111
|
+
expect(recent).toHaveLength(2)
|
|
112
|
+
// recentSystemEvents returns newest-first
|
|
113
|
+
const iterations = recent.map((e) => e.data.iteration as number).sort((a, b) => a - b)
|
|
114
|
+
expect(iterations).toEqual([1, 2])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('delivers bursts — multiple events queued before the consumer drains', async () => {
|
|
118
|
+
// Reproduces the benchmark scenario where runBenchmark emits 3 events
|
|
119
|
+
// back-to-back; the subscription should buffer them and deliver all 3.
|
|
120
|
+
const bus = new SystemEventBus() as unknown as EventBusService
|
|
121
|
+
const router = createLiveEventsRouter(
|
|
122
|
+
bus,
|
|
123
|
+
{ getDeviceRegistry: () => ({ getAll: () => [], getAllForAddon: () => [] }) } as never,
|
|
124
|
+
)
|
|
125
|
+
const caller = router.createCaller(makeCtx('admin'))
|
|
126
|
+
|
|
127
|
+
const iter = await caller.onEvent({ category: 'benchmark.progress' })
|
|
128
|
+
|
|
129
|
+
const received: number[] = []
|
|
130
|
+
const collector = (async () => {
|
|
131
|
+
for await (const ev of iter) {
|
|
132
|
+
received.push(ev.data.iteration as number)
|
|
133
|
+
if (received.length >= 5) return
|
|
134
|
+
}
|
|
135
|
+
})()
|
|
136
|
+
|
|
137
|
+
// Let the generator body run through its first `await`, registering the
|
|
138
|
+
// bus subscription. Without this tick subscribe() hasn't attached yet.
|
|
139
|
+
await sleep(20)
|
|
140
|
+
|
|
141
|
+
// Emit 5 events synchronously — no awaits between.
|
|
142
|
+
for (let i = 1; i <= 5; i++) {
|
|
143
|
+
bus.emit(makeEvent('benchmark.progress', { iteration: i }))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await collector
|
|
147
|
+
|
|
148
|
+
expect(received).toEqual([1, 2, 3, 4, 5])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MoleculerService.applyNodeManifest re-handshake idempotency (D3).
|
|
3
|
+
*
|
|
4
|
+
* Pre-existing bug: `applyNodeManifest` ran on EVERY `$hub.registerNode`
|
|
5
|
+
* and called `registry.registerProvider(cap, key, proxy)` unconditionally
|
|
6
|
+
* for every cap in the manifest. The D3 protocol legitimately re-handshakes
|
|
7
|
+
* (a node sends its COMPLETE manifest again — e.g. the post-device-restore
|
|
8
|
+
* `nativeCaps` re-handshake). `CapabilityRegistry.registerProvider` throws
|
|
9
|
+
* on a duplicate `(cap, addonId)` pair (the guard is CORRECT — it catches an
|
|
10
|
+
* addon double-`initialize()`), so the second handshake threw, the
|
|
11
|
+
* registering node's retry loop retried forever, and the cluster entered a
|
|
12
|
+
* registration storm.
|
|
13
|
+
*
|
|
14
|
+
* The fix makes `applyNodeManifest` diff-based: it honours the CLAUDE.md
|
|
15
|
+
* invariant "`registerNode` replaces the node's entire cap set atomically".
|
|
16
|
+
* A re-handshake with the SAME manifest is a no-op; a re-handshake that
|
|
17
|
+
* drops a cap unregisters exactly that cap; a re-handshake that adds a cap
|
|
18
|
+
* registers only the new one. No throw, no churn.
|
|
19
|
+
*
|
|
20
|
+
* These specs drive the genuine `MoleculerService` registration path twice
|
|
21
|
+
* for the same `nodeId` against a REAL `CapabilityRegistry` (with its real
|
|
22
|
+
* duplicate guard) and assert idempotency.
|
|
23
|
+
*/
|
|
24
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
25
|
+
import { z } from 'zod'
|
|
26
|
+
import { CapabilityRegistry } from '@camstack/kernel'
|
|
27
|
+
import type { RegisterNodeParams } from '@camstack/kernel'
|
|
28
|
+
import type { CapabilityDefinition, IScopedLogger, SystemEvent } from '@camstack/types'
|
|
29
|
+
import { MoleculerService } from '../core/moleculer/moleculer.service.js'
|
|
30
|
+
import type { EventBusService } from '../core/events/event-bus.service.js'
|
|
31
|
+
import type { ConfigService } from '../core/config/config.service.js'
|
|
32
|
+
import type { LoggingService } from '../core/logging/logging.service.js'
|
|
33
|
+
import type { CapabilityService } from '../core/capability/capability.service.js'
|
|
34
|
+
import type { StreamProbeService } from '../core/streaming/stream-probe.service.js'
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Test-only view of `MoleculerService` exposing:
|
|
38
|
+
* - the genuine private registration entrypoint `onRegisterNode` — the
|
|
39
|
+
* same closure the `$hub.registerNode` Moleculer action invokes in
|
|
40
|
+
* production. Driving it directly exercises the real
|
|
41
|
+
* `nodeRegistry.registerNode` + `applyNodeManifest` path without
|
|
42
|
+
* standing up a TCP broker.
|
|
43
|
+
* - the public `createCapabilityProxy` method used to assert whether a
|
|
44
|
+
* dropped capability's call-routing entry was correctly removed from
|
|
45
|
+
* `nodeCallFns` after a reduced re-handshake.
|
|
46
|
+
*/
|
|
47
|
+
interface RegisterNodeDriver {
|
|
48
|
+
onRegisterNode: (params: RegisterNodeParams) => void
|
|
49
|
+
createCapabilityProxy: (capabilityName: string, nodeId: string) => Record<string, (params: unknown) => Promise<unknown>> | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** A minimal real `CapabilityDefinition` so `getDefinition`/`expandCapMethods` resolve. */
|
|
53
|
+
function makeCapDef(name: string): CapabilityDefinition {
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
scope: 'system',
|
|
57
|
+
mode: 'collection',
|
|
58
|
+
methods: {
|
|
59
|
+
ping: {
|
|
60
|
+
input: z.object({}),
|
|
61
|
+
output: z.object({}),
|
|
62
|
+
kind: 'query',
|
|
63
|
+
auth: 'protected',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function makeLogger(): IScopedLogger {
|
|
70
|
+
const logger = {
|
|
71
|
+
info: () => undefined,
|
|
72
|
+
warn: () => undefined,
|
|
73
|
+
error: () => undefined,
|
|
74
|
+
debug: () => undefined,
|
|
75
|
+
trace: () => undefined,
|
|
76
|
+
fatal: () => undefined,
|
|
77
|
+
child: (() => logger) as IScopedLogger['child'],
|
|
78
|
+
}
|
|
79
|
+
return logger as unknown as IScopedLogger
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface Harness {
|
|
83
|
+
readonly driver: RegisterNodeDriver
|
|
84
|
+
readonly registry: CapabilityRegistry
|
|
85
|
+
readonly capNames: readonly string[]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a real `MoleculerService` (constructor only — no broker start) wired
|
|
90
|
+
* to a real `CapabilityRegistry` pre-declaring `capNames`. Returns the
|
|
91
|
+
* service viewed through `RegisterNodeDriver` plus the registry to assert on.
|
|
92
|
+
*
|
|
93
|
+
* The broker is never started, so there is nothing to stop in teardown —
|
|
94
|
+
* no `afterEach` teardown is needed.
|
|
95
|
+
*/
|
|
96
|
+
function createHarness(capNames: readonly string[]): Harness {
|
|
97
|
+
const registry = new CapabilityRegistry(makeLogger())
|
|
98
|
+
for (const name of capNames) {
|
|
99
|
+
registry.declareCapability(makeCapDef(name))
|
|
100
|
+
}
|
|
101
|
+
// Boot-complete state — `getAllProviders` returns [] until `ready()`.
|
|
102
|
+
registry.ready()
|
|
103
|
+
|
|
104
|
+
const fakeEventBus = {
|
|
105
|
+
emit: (_event: SystemEvent) => undefined,
|
|
106
|
+
// `ReadinessRegistry` (built in the MoleculerService constructor)
|
|
107
|
+
// subscribes to `system.ready-state` + `agent.offline`. A no-op
|
|
108
|
+
// subscription returning an unsubscribe fn keeps the constructor happy.
|
|
109
|
+
subscribe: () => () => undefined,
|
|
110
|
+
getRecent: () => [],
|
|
111
|
+
} as unknown as EventBusService
|
|
112
|
+
|
|
113
|
+
const fakeConfig = {
|
|
114
|
+
get: () => undefined,
|
|
115
|
+
getAddonConfig: () => ({}),
|
|
116
|
+
} as unknown as ConfigService
|
|
117
|
+
|
|
118
|
+
const fakeLogging = {
|
|
119
|
+
createLogger: () => makeLogger(),
|
|
120
|
+
writeFromWorker: () => undefined,
|
|
121
|
+
} as unknown as LoggingService
|
|
122
|
+
|
|
123
|
+
const fakeCapability = {
|
|
124
|
+
getRegistry: () => registry,
|
|
125
|
+
} as unknown as CapabilityService
|
|
126
|
+
|
|
127
|
+
const fakeStreamProbe = {} as unknown as StreamProbeService
|
|
128
|
+
|
|
129
|
+
const service = new MoleculerService(
|
|
130
|
+
fakeEventBus,
|
|
131
|
+
fakeConfig,
|
|
132
|
+
fakeLogging,
|
|
133
|
+
fakeCapability,
|
|
134
|
+
fakeStreamProbe,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
driver: service as unknown as RegisterNodeDriver,
|
|
139
|
+
registry,
|
|
140
|
+
capNames,
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Provider count for a cap on the registry — counts every registered key. */
|
|
145
|
+
function providerCount(registry: CapabilityRegistry, capName: string): number {
|
|
146
|
+
return registry.getAllProviders(capName).length
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe('MoleculerService.applyNodeManifest — re-handshake idempotency', () => {
|
|
150
|
+
let harness: Harness
|
|
151
|
+
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
harness = createHarness(['cap-alpha', 'cap-beta'])
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('a re-handshake with the IDENTICAL manifest does not throw and leaves each provider registered exactly once', () => {
|
|
157
|
+
const manifest: RegisterNodeParams = {
|
|
158
|
+
nodeId: 'hub/reolink',
|
|
159
|
+
addons: [{ addonId: 'reolink', capabilities: ['cap-alpha', 'cap-beta'] }],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// First handshake.
|
|
163
|
+
harness.driver.onRegisterNode(manifest)
|
|
164
|
+
expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
|
|
165
|
+
expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
|
|
166
|
+
|
|
167
|
+
// Re-handshake — the documented post-device-restore re-handshake.
|
|
168
|
+
// BEFORE the fix this threw `provider already registered for capability`.
|
|
169
|
+
expect(() => harness.driver.onRegisterNode(manifest)).not.toThrow()
|
|
170
|
+
|
|
171
|
+
// No duplicate, no loss — exactly one provider per cap.
|
|
172
|
+
expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
|
|
173
|
+
expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
|
|
174
|
+
expect(harness.registry.getProviderByAddon('cap-alpha', 'reolink')).not.toBeNull()
|
|
175
|
+
expect(harness.registry.getProviderByAddon('cap-beta', 'reolink')).not.toBeNull()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('three handshakes survive without throwing (registration storm regression guard)', () => {
|
|
179
|
+
const manifest: RegisterNodeParams = {
|
|
180
|
+
nodeId: 'hub/reolink',
|
|
181
|
+
addons: [{ addonId: 'reolink', capabilities: ['cap-alpha', 'cap-beta'] }],
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
expect(() => {
|
|
185
|
+
harness.driver.onRegisterNode(manifest)
|
|
186
|
+
harness.driver.onRegisterNode(manifest)
|
|
187
|
+
harness.driver.onRegisterNode(manifest)
|
|
188
|
+
}).not.toThrow()
|
|
189
|
+
|
|
190
|
+
expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
|
|
191
|
+
expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('a re-handshake that DROPS one capability unregisters that provider while the others remain', () => {
|
|
195
|
+
const fullManifest: RegisterNodeParams = {
|
|
196
|
+
nodeId: 'hub/reolink',
|
|
197
|
+
addons: [{ addonId: 'reolink', capabilities: ['cap-alpha', 'cap-beta'] }],
|
|
198
|
+
}
|
|
199
|
+
const reducedManifest: RegisterNodeParams = {
|
|
200
|
+
nodeId: 'hub/reolink',
|
|
201
|
+
addons: [{ addonId: 'reolink', capabilities: ['cap-alpha'] }],
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// First: full manifest — both caps registered.
|
|
205
|
+
harness.driver.onRegisterNode(fullManifest)
|
|
206
|
+
expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
|
|
207
|
+
expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
|
|
208
|
+
|
|
209
|
+
// Second: identical full manifest — still idempotent, no throw.
|
|
210
|
+
expect(() => harness.driver.onRegisterNode(fullManifest)).not.toThrow()
|
|
211
|
+
expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
|
|
212
|
+
expect(providerCount(harness.registry, 'cap-beta')).toBe(1)
|
|
213
|
+
|
|
214
|
+
// Third: reduced manifest — cap-beta dropped, cap-alpha kept.
|
|
215
|
+
expect(() => harness.driver.onRegisterNode(reducedManifest)).not.toThrow()
|
|
216
|
+
expect(providerCount(harness.registry, 'cap-alpha')).toBe(1)
|
|
217
|
+
expect(providerCount(harness.registry, 'cap-beta')).toBe(0)
|
|
218
|
+
expect(harness.registry.getProviderByAddon('cap-alpha', 'reolink')).not.toBeNull()
|
|
219
|
+
expect(harness.registry.getProviderByAddon('cap-beta', 'reolink')).toBeNull()
|
|
220
|
+
|
|
221
|
+
// Verify that the call-routing entry for the dropped cap was removed
|
|
222
|
+
// from `nodeCallFns`. `createCapabilityProxy` is the public seam that
|
|
223
|
+
// delegates to `findCallFn` internally — a null return means no entry
|
|
224
|
+
// exists for that (nodeId, cap) pair, confirming the delete ran.
|
|
225
|
+
expect(harness.driver.createCapabilityProxy('cap-beta', 'hub/reolink')).toBeNull()
|
|
226
|
+
// The still-present cap must remain routable.
|
|
227
|
+
expect(harness.driver.createCapabilityProxy('cap-alpha', 'hub/reolink')).not.toBeNull()
|
|
228
|
+
})
|
|
229
|
+
})
|