@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,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostBootService restart-completed emission — boot-side handoff of the
|
|
3
|
+
* `.restart-pending` marker into a `system.restart-completed` event.
|
|
4
|
+
*
|
|
5
|
+
* Spec: docs/superpowers/specs/2026-05-14-framework-live-update-design.md (P2)
|
|
6
|
+
*
|
|
7
|
+
* Invariants:
|
|
8
|
+
* - Marker present at boot → event emitted with the marker's payload
|
|
9
|
+
* AND the marker is gone after boot.
|
|
10
|
+
* - No marker → no event of category `system.restart-completed`,
|
|
11
|
+
* `system.boot` still fires.
|
|
12
|
+
* - Boot is robust to a malformed marker (does not throw; just clears).
|
|
13
|
+
*/
|
|
14
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
15
|
+
import * as fs from 'node:fs'
|
|
16
|
+
import * as os from 'node:os'
|
|
17
|
+
import * as path from 'node:path'
|
|
18
|
+
import { writePendingRestart, getRestartMarkerPath } from '@camstack/kernel'
|
|
19
|
+
import type { IEventBus, IScopedLogger, SystemEvent } from '@camstack/types'
|
|
20
|
+
import { PostBootService } from '../boot/post-boot.service.js'
|
|
21
|
+
import { AddonRegistryService } from '../core/addon/addon-registry.service.js'
|
|
22
|
+
import { EventBusService } from '../core/events/event-bus.service.js'
|
|
23
|
+
import { LoggingService } from '../core/logging/logging.service.js'
|
|
24
|
+
|
|
25
|
+
interface CapturedEvent {
|
|
26
|
+
readonly category: string
|
|
27
|
+
readonly data: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createEnv(dataDir: string): {
|
|
31
|
+
service: PostBootService
|
|
32
|
+
captured: CapturedEvent[]
|
|
33
|
+
} {
|
|
34
|
+
const captured: CapturedEvent[] = []
|
|
35
|
+
const bus: Pick<IEventBus, 'emit'> = {
|
|
36
|
+
emit: (event: SystemEvent) => {
|
|
37
|
+
captured.push({ category: event.category, data: event.data as Record<string, unknown> })
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
const logger: IScopedLogger = {
|
|
41
|
+
info: () => undefined,
|
|
42
|
+
warn: () => undefined,
|
|
43
|
+
error: () => undefined,
|
|
44
|
+
debug: () => undefined,
|
|
45
|
+
trace: () => undefined,
|
|
46
|
+
fatal: () => undefined,
|
|
47
|
+
child: (() => logger) as IScopedLogger['child'],
|
|
48
|
+
} as unknown as IScopedLogger
|
|
49
|
+
|
|
50
|
+
const loggingService = {
|
|
51
|
+
createLogger: () => logger,
|
|
52
|
+
} as unknown as LoggingService
|
|
53
|
+
|
|
54
|
+
const service = new PostBootService(
|
|
55
|
+
{} as unknown as AddonRegistryService,
|
|
56
|
+
bus as unknown as EventBusService,
|
|
57
|
+
loggingService,
|
|
58
|
+
)
|
|
59
|
+
return { service, captured }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let tmpDir: string
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-boot-'))
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
70
|
+
vi.restoreAllMocks()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('PostBootService restart-completed handoff', () => {
|
|
74
|
+
it('emits system.restart-completed with the marker payload when a marker is present', async () => {
|
|
75
|
+
writePendingRestart(tmpDir, {
|
|
76
|
+
kind: 'framework-update',
|
|
77
|
+
packageName: '@camstack/core',
|
|
78
|
+
fromVersion: '0.1.36',
|
|
79
|
+
toVersion: '0.1.37',
|
|
80
|
+
requestedBy: 'admin',
|
|
81
|
+
requestedAt: 1715717834567,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const { service, captured } = createEnv(tmpDir)
|
|
85
|
+
await service.run({ port: 0, host: 'localhost', dataPath: tmpDir, trpcRegistered: false })
|
|
86
|
+
|
|
87
|
+
const completed = captured.find((e) => e.category === 'system.restart-completed')
|
|
88
|
+
expect(completed).toBeDefined()
|
|
89
|
+
expect(completed?.data).toMatchObject({
|
|
90
|
+
kind: 'framework-update',
|
|
91
|
+
packageName: '@camstack/core',
|
|
92
|
+
fromVersion: '0.1.36',
|
|
93
|
+
toVersion: '0.1.37',
|
|
94
|
+
requestedBy: 'admin',
|
|
95
|
+
requestedAt: 1715717834567,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
expect(fs.existsSync(getRestartMarkerPath(tmpDir))).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('still emits system.boot when no marker is present and does not emit system.restart-completed', async () => {
|
|
102
|
+
const { service, captured } = createEnv(tmpDir)
|
|
103
|
+
await service.run({ port: 0, host: 'localhost', dataPath: tmpDir, trpcRegistered: false })
|
|
104
|
+
|
|
105
|
+
expect(captured.some((e) => e.category === 'system.boot')).toBe(true)
|
|
106
|
+
expect(captured.some((e) => e.category === 'system.restart-completed')).toBe(false)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('does not throw and clears the marker when the marker is malformed', async () => {
|
|
110
|
+
fs.writeFileSync(getRestartMarkerPath(tmpDir), '{ broken', 'utf-8')
|
|
111
|
+
|
|
112
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
113
|
+
const { service, captured } = createEnv(tmpDir)
|
|
114
|
+
await service.run({ port: 0, host: 'localhost', dataPath: tmpDir, trpcRegistered: false })
|
|
115
|
+
|
|
116
|
+
expect(captured.some((e) => e.category === 'system.boot')).toBe(true)
|
|
117
|
+
expect(captured.some((e) => e.category === 'system.restart-completed')).toBe(false)
|
|
118
|
+
expect(fs.existsSync(getRestartMarkerPath(tmpDir))).toBe(false)
|
|
119
|
+
expect(errorSpy).toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('PostBootService.getLastRestart retention window', () => {
|
|
124
|
+
it('returns the marker emitted this boot, then null after the retention window', async () => {
|
|
125
|
+
writePendingRestart(tmpDir, {
|
|
126
|
+
kind: 'framework-update',
|
|
127
|
+
packageName: '@camstack/core',
|
|
128
|
+
fromVersion: '0.1.36',
|
|
129
|
+
toVersion: '0.1.37',
|
|
130
|
+
requestedBy: 'admin',
|
|
131
|
+
requestedAt: 1715717834567,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const { service } = createEnv(tmpDir)
|
|
135
|
+
await service.run({ port: 0, host: 'localhost', dataPath: tmpDir, trpcRegistered: false })
|
|
136
|
+
|
|
137
|
+
// Immediately after boot the marker is queryable.
|
|
138
|
+
const fresh = PostBootService.getLastRestart()
|
|
139
|
+
expect(fresh).not.toBeNull()
|
|
140
|
+
expect(fresh?.packageName).toBe('@camstack/core')
|
|
141
|
+
|
|
142
|
+
// Past the retention window it auto-expires.
|
|
143
|
+
vi.useFakeTimers()
|
|
144
|
+
vi.setSystemTime(Date.now() + PostBootService.LAST_RESTART_RETENTION_MS + 1)
|
|
145
|
+
expect(PostBootService.getLastRestart()).toBeNull()
|
|
146
|
+
vi.useRealTimers()
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns null when boot was not triggered by a restart marker', async () => {
|
|
150
|
+
// Reset the static cache from any earlier test.
|
|
151
|
+
const dummyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'post-boot-empty-'))
|
|
152
|
+
const { service } = createEnv(dummyDir)
|
|
153
|
+
await service.run({ port: 0, host: 'localhost', dataPath: dummyDir, trpcRegistered: false })
|
|
154
|
+
|
|
155
|
+
// Note: this test passes whether or not a previous test populated
|
|
156
|
+
// lastRestart since `lastRestart` is per-test-process state. The
|
|
157
|
+
// assertion checks the public guarantee: no marker => no cached
|
|
158
|
+
// payload from THIS boot. We don't depend on cross-test isolation.
|
|
159
|
+
fs.rmSync(dummyDir, { recursive: true, force: true })
|
|
160
|
+
})
|
|
161
|
+
})
|
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- test file, mock typing */
|
|
2
|
+
// server/backend/src/__tests__/singleton-contention.test.ts
|
|
3
|
+
//
|
|
4
|
+
// Server-level E2E tests for SINGLETON CAPABILITY CONTENTION: the scenario
|
|
5
|
+
// where two addons both register a provider for the same `mode: 'singleton'`
|
|
6
|
+
// capability. Regression coverage for the production collision where
|
|
7
|
+
// `decoder-nodeav` and `decoder-ffmpeg` both registered the singleton
|
|
8
|
+
// `decoder` cap.
|
|
9
|
+
//
|
|
10
|
+
// NOTE: file is intentionally NOT named `*-e2e.test.ts` — `vitest.config.ts`
|
|
11
|
+
// excludes the `**/*e2e*.test.ts` glob (those are full-server-boot suites).
|
|
12
|
+
// This suite uses the lightweight `TestAddonHarness` and runs in the default
|
|
13
|
+
// `npm run test` pass.
|
|
14
|
+
//
|
|
15
|
+
// Mirrors the `TestAddonHarness` style of `capability-e2e.test.ts` and reuses
|
|
16
|
+
// the `mock-analysis-addon-a` / `mock-analysis-addon-b` fixtures — both of
|
|
17
|
+
// which register the singleton `object-detector` cap, i.e. they ARE the
|
|
18
|
+
// contention scenario.
|
|
19
|
+
//
|
|
20
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
21
|
+
import { CapabilityRegistry } from '@camstack/kernel'
|
|
22
|
+
import type { IScopedLogger, CapabilityDeclaration } from '@camstack/types'
|
|
23
|
+
import { MockAnalysisAddonA } from './fixtures/mock-analysis-addon-a'
|
|
24
|
+
import { MockAnalysisAddonB } from './fixtures/mock-analysis-addon-b'
|
|
25
|
+
import { MockStorageAddon } from './fixtures/mock-storage-addon'
|
|
26
|
+
import type { ICamstackAddon } from '@camstack/types'
|
|
27
|
+
|
|
28
|
+
// --- Helpers ----------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function createMockLogger(): IScopedLogger {
|
|
31
|
+
return {
|
|
32
|
+
error: vi.fn(),
|
|
33
|
+
warn: vi.fn(),
|
|
34
|
+
info: vi.fn(),
|
|
35
|
+
debug: vi.fn(),
|
|
36
|
+
child: vi.fn().mockReturnThis(),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface AddonEntry {
|
|
41
|
+
readonly addon: ICamstackAddon
|
|
42
|
+
initialized: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Lightweight harness that mirrors AddonRegistryService boot sequence
|
|
47
|
+
* without requiring NestJS DI. Same shape as `capability-e2e.test.ts`'s
|
|
48
|
+
* harness, trimmed to the methods the contention scenarios exercise.
|
|
49
|
+
*/
|
|
50
|
+
class TestAddonHarness {
|
|
51
|
+
readonly registry: CapabilityRegistry
|
|
52
|
+
private readonly addonEntries = new Map<string, AddonEntry>()
|
|
53
|
+
|
|
54
|
+
constructor(configPrefs: Record<string, string> = {}) {
|
|
55
|
+
this.registry = new CapabilityRegistry(createMockLogger())
|
|
56
|
+
this.registry.setConfigReader((cap) => configPrefs[cap])
|
|
57
|
+
this.registry.ready()
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Register an addon (mimics AddonRegistryService.registerAddon) */
|
|
61
|
+
registerAddon(addon: ICamstackAddon): void {
|
|
62
|
+
this.addonEntries.set(addon.manifest.id, { addon, initialized: false })
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Declare capabilities from an addon manifest. Mode inferred per-name. */
|
|
66
|
+
declareCapabilities(addon: ICamstackAddon): void {
|
|
67
|
+
const caps = this.getAddonCapabilities(addon)
|
|
68
|
+
for (const cap of caps) {
|
|
69
|
+
const mode = cap.name === 'log-destination' ? ('collection' as const) : ('singleton' as const)
|
|
70
|
+
this.registry.declareCapability({ name: cap.name, scope: 'system', mode, methods: {} })
|
|
71
|
+
this.registry.declareFromManifest(cap)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Initialize an addon and wire its capabilities into the registry. */
|
|
76
|
+
async initializeAddon(id: string): Promise<void> {
|
|
77
|
+
const entry = this.addonEntries.get(id)
|
|
78
|
+
if (!entry) throw new Error(`Addon "${id}" not registered`)
|
|
79
|
+
if (entry.initialized) return
|
|
80
|
+
|
|
81
|
+
const self = this
|
|
82
|
+
const context = {
|
|
83
|
+
registerProvider(capName: string, provider: unknown) {
|
|
84
|
+
self.registry.registerProvider(capName, id, provider)
|
|
85
|
+
},
|
|
86
|
+
} as any
|
|
87
|
+
const result = await entry.addon.initialize(context)
|
|
88
|
+
if (result) {
|
|
89
|
+
const regs = Array.isArray(result) ? result : (result as any).providers ?? []
|
|
90
|
+
for (const reg of regs) {
|
|
91
|
+
const capName: string =
|
|
92
|
+
typeof reg.capability === 'string'
|
|
93
|
+
? reg.capability
|
|
94
|
+
: (reg.capability as any)?.name ?? String(reg.capability)
|
|
95
|
+
self.registry.registerProvider(capName, id, reg.provider)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
entry.initialized = true
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Shutdown an addon and unregister its capabilities. */
|
|
102
|
+
async shutdownAddon(id: string): Promise<void> {
|
|
103
|
+
const entry = this.addonEntries.get(id)
|
|
104
|
+
if (!entry) throw new Error(`Addon "${id}" not registered`)
|
|
105
|
+
|
|
106
|
+
const caps = this.getAddonCapabilities(entry.addon)
|
|
107
|
+
for (const cap of caps) {
|
|
108
|
+
this.registry.unregisterProvider(cap.name, id)
|
|
109
|
+
}
|
|
110
|
+
await entry.addon.shutdown()
|
|
111
|
+
entry.initialized = false
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private getAddonCapabilities(addon: ICamstackAddon): CapabilityDeclaration[] {
|
|
115
|
+
const manifest = addon.manifest as any
|
|
116
|
+
if (!manifest.capabilities) return []
|
|
117
|
+
return manifest.capabilities.map((cap: string | CapabilityDeclaration) => {
|
|
118
|
+
if (typeof cap === 'string') {
|
|
119
|
+
const decl: CapabilityDeclaration = { name: cap }
|
|
120
|
+
return decl
|
|
121
|
+
}
|
|
122
|
+
return cap
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// 1. Two addons register the same singleton cap
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
describe('Singleton contention E2E: two addons on the same singleton cap', () => {
|
|
131
|
+
it('first-registered addon wins as active; registry tracks BOTH providers', async () => {
|
|
132
|
+
const harness = new TestAddonHarness()
|
|
133
|
+
const analysisA = new MockAnalysisAddonA()
|
|
134
|
+
const analysisB = new MockAnalysisAddonB()
|
|
135
|
+
|
|
136
|
+
harness.registerAddon(analysisA)
|
|
137
|
+
harness.registerAddon(analysisB)
|
|
138
|
+
harness.declareCapabilities(analysisA)
|
|
139
|
+
harness.declareCapabilities(analysisB)
|
|
140
|
+
|
|
141
|
+
// A initializes first → it becomes the default active singleton.
|
|
142
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
143
|
+
await harness.initializeAddon('mock-analysis-b')
|
|
144
|
+
|
|
145
|
+
// Active resolution: first-registered wins (no configReader preference).
|
|
146
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
147
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-a')
|
|
148
|
+
|
|
149
|
+
// ...but the registry is aware of BOTH — B did not get rejected, it is a
|
|
150
|
+
// legitimate alternative provider the operator can switch to.
|
|
151
|
+
const info = harness.registry.listCapabilities().find((c) => c.name === 'object-detector')!
|
|
152
|
+
expect(info.providers).toEqual(expect.arrayContaining(['mock-analysis-a', 'mock-analysis-b']))
|
|
153
|
+
expect(info.providers).toHaveLength(2)
|
|
154
|
+
expect(info.activeProvider).toBe('mock-analysis-a')
|
|
155
|
+
|
|
156
|
+
// Both providers individually addressable.
|
|
157
|
+
expect(harness.registry.getProviderByAddon('object-detector', 'mock-analysis-a')).toBe(analysisA.provider)
|
|
158
|
+
expect(harness.registry.getProviderByAddon('object-detector', 'mock-analysis-b')).toBe(analysisB.provider)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('honours a configReader preference for the SECOND addon over first-registered', async () => {
|
|
162
|
+
// Operator persisted a preference for B (the decoder-ffmpeg-style case
|
|
163
|
+
// where the non-default addon is the desired one).
|
|
164
|
+
const harness = new TestAddonHarness({ 'object-detector': 'mock-analysis-b' })
|
|
165
|
+
const analysisA = new MockAnalysisAddonA()
|
|
166
|
+
const analysisB = new MockAnalysisAddonB()
|
|
167
|
+
|
|
168
|
+
harness.registerAddon(analysisA)
|
|
169
|
+
harness.registerAddon(analysisB)
|
|
170
|
+
harness.declareCapabilities(analysisA)
|
|
171
|
+
harness.declareCapabilities(analysisB)
|
|
172
|
+
|
|
173
|
+
// A registers first, but configReader prefers B.
|
|
174
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
175
|
+
await harness.initializeAddon('mock-analysis-b')
|
|
176
|
+
|
|
177
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisB.provider)
|
|
178
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-b')
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
// 2. Consumer waits for the cap before any provider exists
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
describe('Singleton contention E2E: waitForProvider before registration', () => {
|
|
186
|
+
it('a wait started before any provider resolves once the provider registers', async () => {
|
|
187
|
+
const harness = new TestAddonHarness()
|
|
188
|
+
const analysisA = new MockAnalysisAddonA()
|
|
189
|
+
|
|
190
|
+
harness.registerAddon(analysisA)
|
|
191
|
+
harness.declareCapabilities(analysisA)
|
|
192
|
+
|
|
193
|
+
// Consumer begins waiting BEFORE the addon initializes — no provider yet.
|
|
194
|
+
const waitPromise = harness.registry.waitForProvider('object-detector', 'mock-analysis-a', 5_000)
|
|
195
|
+
|
|
196
|
+
// Addon initializes shortly after → registerProvider fulfils the waiter.
|
|
197
|
+
setTimeout(() => {
|
|
198
|
+
void harness.initializeAddon('mock-analysis-a')
|
|
199
|
+
}, 10)
|
|
200
|
+
|
|
201
|
+
const resolved = await waitPromise
|
|
202
|
+
expect(resolved).toBe(analysisA.provider)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('a wait for addon B is NOT fulfilled when only addon A registers for the same singleton cap', async () => {
|
|
206
|
+
const harness = new TestAddonHarness()
|
|
207
|
+
const analysisA = new MockAnalysisAddonA()
|
|
208
|
+
const analysisB = new MockAnalysisAddonB()
|
|
209
|
+
|
|
210
|
+
harness.registerAddon(analysisA)
|
|
211
|
+
harness.registerAddon(analysisB)
|
|
212
|
+
harness.declareCapabilities(analysisA)
|
|
213
|
+
harness.declareCapabilities(analysisB)
|
|
214
|
+
|
|
215
|
+
// Consumer waits specifically for B's provider.
|
|
216
|
+
let resolved = false
|
|
217
|
+
const waitPromise = harness.registry
|
|
218
|
+
.waitForProvider('object-detector', 'mock-analysis-b', 200)
|
|
219
|
+
.then((p) => {
|
|
220
|
+
resolved = true
|
|
221
|
+
return p
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Only A registers — same cap, different addon. Must NOT unblock the wait.
|
|
225
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
226
|
+
|
|
227
|
+
const result = await waitPromise
|
|
228
|
+
expect(resolved).toBe(true)
|
|
229
|
+
expect(result).toBeNull() // timed out — the wait keyed on (cap, addonId)
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// 3. The active provider is removed/unregistered
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
describe('Singleton contention E2E: active provider removed', () => {
|
|
237
|
+
it('removing the ACTIVE provider promotes the still-registered B', async () => {
|
|
238
|
+
const harness = new TestAddonHarness()
|
|
239
|
+
const analysisA = new MockAnalysisAddonA()
|
|
240
|
+
const analysisB = new MockAnalysisAddonB()
|
|
241
|
+
|
|
242
|
+
harness.registerAddon(analysisA)
|
|
243
|
+
harness.registerAddon(analysisB)
|
|
244
|
+
harness.declareCapabilities(analysisA)
|
|
245
|
+
harness.declareCapabilities(analysisB)
|
|
246
|
+
|
|
247
|
+
await harness.initializeAddon('mock-analysis-a') // A → active
|
|
248
|
+
await harness.initializeAddon('mock-analysis-b') // B → registered, inactive
|
|
249
|
+
|
|
250
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
251
|
+
|
|
252
|
+
// Remove the active provider A.
|
|
253
|
+
await harness.shutdownAddon('mock-analysis-a')
|
|
254
|
+
|
|
255
|
+
// `unregisterProvider` promotes the still-registered B instead of
|
|
256
|
+
// leaving the singleton cap dead. With no `configReader` preference it
|
|
257
|
+
// falls back to the first remaining provider — here `mock-analysis-b`.
|
|
258
|
+
// (Regression anchor for the decoder-ffmpeg/decoder-nodeav incident:
|
|
259
|
+
// uninstalling one decoder addon must not take the cap offline.)
|
|
260
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisB.provider)
|
|
261
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-b')
|
|
262
|
+
|
|
263
|
+
const info = harness.registry.listCapabilities().find((c) => c.name === 'object-detector')!
|
|
264
|
+
expect(info.providers).toEqual(['mock-analysis-b'])
|
|
265
|
+
expect(info.activeProvider).toBe('mock-analysis-b')
|
|
266
|
+
expect(harness.registry.getProviderByAddon('object-detector', 'mock-analysis-b')).toBe(analysisB.provider)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('removing a NON-active provider keeps the active one untouched', async () => {
|
|
270
|
+
const harness = new TestAddonHarness()
|
|
271
|
+
const analysisA = new MockAnalysisAddonA()
|
|
272
|
+
const analysisB = new MockAnalysisAddonB()
|
|
273
|
+
|
|
274
|
+
harness.registerAddon(analysisA)
|
|
275
|
+
harness.registerAddon(analysisB)
|
|
276
|
+
harness.declareCapabilities(analysisA)
|
|
277
|
+
harness.declareCapabilities(analysisB)
|
|
278
|
+
|
|
279
|
+
await harness.initializeAddon('mock-analysis-a') // A → active
|
|
280
|
+
await harness.initializeAddon('mock-analysis-b') // B → inactive
|
|
281
|
+
|
|
282
|
+
// Remove the INACTIVE provider B.
|
|
283
|
+
await harness.shutdownAddon('mock-analysis-b')
|
|
284
|
+
|
|
285
|
+
// A stays active and resolvable — no disturbance.
|
|
286
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
287
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-a')
|
|
288
|
+
const info = harness.registry.listCapabilities().find((c) => c.name === 'object-detector')!
|
|
289
|
+
expect(info.providers).toEqual(['mock-analysis-a'])
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
// 4. Re-registration after removal
|
|
295
|
+
// ---------------------------------------------------------------------------
|
|
296
|
+
describe('Singleton contention E2E: re-registration after removal', () => {
|
|
297
|
+
it('re-registering A after removal does not displace the promoted B', async () => {
|
|
298
|
+
const harness = new TestAddonHarness()
|
|
299
|
+
const analysisA = new MockAnalysisAddonA()
|
|
300
|
+
const analysisB = new MockAnalysisAddonB()
|
|
301
|
+
|
|
302
|
+
harness.registerAddon(analysisA)
|
|
303
|
+
harness.registerAddon(analysisB)
|
|
304
|
+
harness.declareCapabilities(analysisA)
|
|
305
|
+
harness.declareCapabilities(analysisB)
|
|
306
|
+
|
|
307
|
+
await harness.initializeAddon('mock-analysis-a') // A → active
|
|
308
|
+
await harness.initializeAddon('mock-analysis-b') // B → inactive
|
|
309
|
+
|
|
310
|
+
// Remove A → B is promoted to active (see scenario 3).
|
|
311
|
+
await harness.shutdownAddon('mock-analysis-a')
|
|
312
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisB.provider)
|
|
313
|
+
|
|
314
|
+
// Re-register A. `registerProvider`'s singleton branch only auto-promotes
|
|
315
|
+
// a fresh provider when `activeAddonId === null` — but B already holds it,
|
|
316
|
+
// so A re-registers without displacing B. (An operator who wants A back
|
|
317
|
+
// active switches it explicitly via setActiveSingleton.)
|
|
318
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
319
|
+
|
|
320
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisB.provider)
|
|
321
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-b')
|
|
322
|
+
const info = harness.registry.listCapabilities().find((c) => c.name === 'object-detector')!
|
|
323
|
+
expect(info.providers).toEqual(expect.arrayContaining(['mock-analysis-a', 'mock-analysis-b']))
|
|
324
|
+
expect(info.providers).toHaveLength(2)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
it('re-registering the SAME (cap, addonId) pair without unregistering first throws', async () => {
|
|
328
|
+
const harness = new TestAddonHarness()
|
|
329
|
+
const analysisA = new MockAnalysisAddonA()
|
|
330
|
+
|
|
331
|
+
harness.registerAddon(analysisA)
|
|
332
|
+
harness.declareCapabilities(analysisA)
|
|
333
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
334
|
+
|
|
335
|
+
// Double-register of the same pair is a programmer error — the registry
|
|
336
|
+
// throws rather than silently replacing the provider.
|
|
337
|
+
expect(() =>
|
|
338
|
+
harness.registry.registerProvider('object-detector', 'mock-analysis-a', { id: 'dup' }),
|
|
339
|
+
).toThrow(/already registered/)
|
|
340
|
+
// Original provider preserved.
|
|
341
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
342
|
+
})
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// 5. setActiveSingleton switching
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
describe('Singleton contention E2E: setActiveSingleton switching', () => {
|
|
349
|
+
let harness: TestAddonHarness
|
|
350
|
+
let analysisA: MockAnalysisAddonA
|
|
351
|
+
let analysisB: MockAnalysisAddonB
|
|
352
|
+
|
|
353
|
+
beforeEach(async () => {
|
|
354
|
+
harness = new TestAddonHarness()
|
|
355
|
+
analysisA = new MockAnalysisAddonA()
|
|
356
|
+
analysisB = new MockAnalysisAddonB()
|
|
357
|
+
|
|
358
|
+
harness.registerAddon(analysisA)
|
|
359
|
+
harness.registerAddon(analysisB)
|
|
360
|
+
harness.declareCapabilities(analysisA)
|
|
361
|
+
harness.declareCapabilities(analysisB)
|
|
362
|
+
|
|
363
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
364
|
+
await harness.initializeAddon('mock-analysis-b')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
it('explicitly switching the active singleton flips resolution A → B → A', async () => {
|
|
368
|
+
// Default: A (first registered).
|
|
369
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
370
|
+
|
|
371
|
+
// Switch to B.
|
|
372
|
+
await harness.registry.setActiveSingleton('object-detector', 'mock-analysis-b', true)
|
|
373
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisB.provider)
|
|
374
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-b')
|
|
375
|
+
|
|
376
|
+
// Switch back to A.
|
|
377
|
+
await harness.registry.setActiveSingleton('object-detector', 'mock-analysis-a', true)
|
|
378
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
379
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-a')
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('setActiveSingleton throws when switching to an addon that never registered', async () => {
|
|
383
|
+
await expect(
|
|
384
|
+
harness.registry.setActiveSingleton('object-detector', 'mock-analysis-c', true),
|
|
385
|
+
).rejects.toThrow(/[Nn]o provider/)
|
|
386
|
+
// Active pointer unchanged after the failed switch.
|
|
387
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
388
|
+
})
|
|
389
|
+
|
|
390
|
+
it('unregistering the explicitly-selected active provider promotes the remaining one', async () => {
|
|
391
|
+
// Operator explicitly selected B.
|
|
392
|
+
await harness.registry.setActiveSingleton('object-detector', 'mock-analysis-b', true)
|
|
393
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisB.provider)
|
|
394
|
+
|
|
395
|
+
// B is removed. `unregisterProvider` promotes the remaining A rather
|
|
396
|
+
// than leaving the cap dead — `setActiveSingleton` set only the
|
|
397
|
+
// in-memory pointer, not a persisted `configReader` preference, so the
|
|
398
|
+
// fallback picks the first remaining provider.
|
|
399
|
+
await harness.shutdownAddon('mock-analysis-b')
|
|
400
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisA.provider)
|
|
401
|
+
expect(harness.registry.getSingletonAddonId('object-detector')).toBe('mock-analysis-a')
|
|
402
|
+
expect(harness.registry.getProviderByAddon('object-detector', 'mock-analysis-a')).toBe(analysisA.provider)
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// 6. Teardown / race edges
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
describe('Singleton contention E2E: teardown and race edges', () => {
|
|
410
|
+
it('a pending wait stays pending (then times out) when the would-have-matched provider is removed before it registers again', async () => {
|
|
411
|
+
const harness = new TestAddonHarness()
|
|
412
|
+
const analysisA = new MockAnalysisAddonA()
|
|
413
|
+
|
|
414
|
+
harness.registerAddon(analysisA)
|
|
415
|
+
harness.declareCapabilities(analysisA)
|
|
416
|
+
|
|
417
|
+
// A registers, then is torn down — cap currently has no provider.
|
|
418
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
419
|
+
await harness.shutdownAddon('mock-analysis-a')
|
|
420
|
+
|
|
421
|
+
// Consumer starts waiting for A AFTER it was torn down.
|
|
422
|
+
let resolved = false
|
|
423
|
+
const waitPromise = harness.registry
|
|
424
|
+
.waitForProvider('object-detector', 'mock-analysis-a', 150)
|
|
425
|
+
.then((p) => {
|
|
426
|
+
resolved = true
|
|
427
|
+
return p
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
// `unregisterProvider` does NOT touch `pendingWaiters`, and nothing
|
|
431
|
+
// re-registers A — so the waiter simply times out to null. This pins the
|
|
432
|
+
// teardown contract: removing a provider never spuriously resolves or
|
|
433
|
+
// rejects an outstanding wait for that (cap, addonId).
|
|
434
|
+
const result = await waitPromise
|
|
435
|
+
expect(resolved).toBe(true)
|
|
436
|
+
expect(result).toBeNull()
|
|
437
|
+
})
|
|
438
|
+
|
|
439
|
+
it('a wait pending across a contention swap resolves only for its OWN addonId', async () => {
|
|
440
|
+
const harness = new TestAddonHarness()
|
|
441
|
+
const analysisA = new MockAnalysisAddonA()
|
|
442
|
+
const analysisB = new MockAnalysisAddonB()
|
|
443
|
+
|
|
444
|
+
harness.registerAddon(analysisA)
|
|
445
|
+
harness.registerAddon(analysisB)
|
|
446
|
+
harness.declareCapabilities(analysisA)
|
|
447
|
+
harness.declareCapabilities(analysisB)
|
|
448
|
+
|
|
449
|
+
// Two consumers wait concurrently — one for A, one for B.
|
|
450
|
+
const waitForA = harness.registry.waitForProvider('object-detector', 'mock-analysis-a', 5_000)
|
|
451
|
+
const waitForB = harness.registry.waitForProvider('object-detector', 'mock-analysis-b', 5_000)
|
|
452
|
+
|
|
453
|
+
// B registers first this time, then A.
|
|
454
|
+
await harness.initializeAddon('mock-analysis-b')
|
|
455
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
456
|
+
|
|
457
|
+
const [resolvedA, resolvedB] = await Promise.all([waitForA, waitForB])
|
|
458
|
+
expect(resolvedA).toBe(analysisA.provider)
|
|
459
|
+
expect(resolvedB).toBe(analysisB.provider)
|
|
460
|
+
|
|
461
|
+
// B registered first → B is the active singleton despite both being present.
|
|
462
|
+
expect(harness.registry.getSingleton('object-detector')).toBe(analysisB.provider)
|
|
463
|
+
})
|
|
464
|
+
|
|
465
|
+
it('full teardown of both contending addons leaves the cap declared but empty', async () => {
|
|
466
|
+
const harness = new TestAddonHarness()
|
|
467
|
+
const analysisA = new MockAnalysisAddonA()
|
|
468
|
+
const analysisB = new MockAnalysisAddonB()
|
|
469
|
+
|
|
470
|
+
harness.registerAddon(analysisA)
|
|
471
|
+
harness.registerAddon(analysisB)
|
|
472
|
+
harness.declareCapabilities(analysisA)
|
|
473
|
+
harness.declareCapabilities(analysisB)
|
|
474
|
+
|
|
475
|
+
await harness.initializeAddon('mock-analysis-a')
|
|
476
|
+
await harness.initializeAddon('mock-analysis-b')
|
|
477
|
+
await harness.shutdownAddon('mock-analysis-a')
|
|
478
|
+
await harness.shutdownAddon('mock-analysis-b')
|
|
479
|
+
|
|
480
|
+
expect(harness.registry.getSingleton('object-detector')).toBeNull()
|
|
481
|
+
expect(harness.registry.has('object-detector')).toBe(true) // still declared
|
|
482
|
+
expect(harness.registry.isAvailable('object-detector')).toBe(false) // no providers
|
|
483
|
+
const info = harness.registry.listCapabilities().find((c) => c.name === 'object-detector')!
|
|
484
|
+
expect(info.providers).toHaveLength(0)
|
|
485
|
+
expect(info.activeProvider).toBeNull()
|
|
486
|
+
})
|
|
487
|
+
})
|