@camstack/server 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/__tests__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +59 -6
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
- package/src/api/trpc/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +130 -0
- package/src/api/trpc/generated-cap-mounts.ts +19 -1
- package/src/api/trpc/generated-cap-routers.ts +180 -1
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +45 -0
- package/src/core/addon/addon-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +364 -105
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +380 -36
- package/src/main.ts +45 -12
package/package.json
CHANGED
|
@@ -23,6 +23,7 @@ import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.servi
|
|
|
23
23
|
import type { AuthService } from '../core/auth/auth.service.js'
|
|
24
24
|
import type { MoleculerService } from '../core/moleculer/moleculer.service.js'
|
|
25
25
|
import type { AddonRegistryService } from '../core/addon/addon-registry.service.js'
|
|
26
|
+
import type { AddonPackageService } from '../core/addon/addon-package.service.js'
|
|
26
27
|
import type { IScopedLogger } from '@camstack/types'
|
|
27
28
|
|
|
28
29
|
/**
|
|
@@ -129,6 +130,14 @@ function makeAddonRegistry(): AddonRegistryService {
|
|
|
129
130
|
} as unknown as AddonRegistryService
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
function makeAddonPackageService(): AddonPackageService {
|
|
134
|
+
// `restartServer` is exercised only by the framework-package branch; the
|
|
135
|
+
// default happy-path tests deploy a regular addon and never call it.
|
|
136
|
+
return {
|
|
137
|
+
restartServer: vi.fn((_requestedBy?: string) => {}),
|
|
138
|
+
} as unknown as AddonPackageService
|
|
139
|
+
}
|
|
140
|
+
|
|
132
141
|
function makeLogger(): IScopedLogger {
|
|
133
142
|
return {
|
|
134
143
|
debug: vi.fn(),
|
|
@@ -145,6 +154,7 @@ async function makeServer(opts: {
|
|
|
145
154
|
bridge: AddonBridgeService
|
|
146
155
|
moleculer: MoleculerService
|
|
147
156
|
addonRegistry?: AddonRegistryService
|
|
157
|
+
addonPackageService?: AddonPackageService
|
|
148
158
|
logger?: IScopedLogger
|
|
149
159
|
}): Promise<FastifyInstance> {
|
|
150
160
|
const fastify = Fastify({ logger: false })
|
|
@@ -154,6 +164,7 @@ async function makeServer(opts: {
|
|
|
154
164
|
opts.auth,
|
|
155
165
|
opts.moleculer,
|
|
156
166
|
opts.addonRegistry ?? makeAddonRegistry(),
|
|
167
|
+
opts.addonPackageService ?? makeAddonPackageService(),
|
|
157
168
|
opts.logger ?? makeLogger(),
|
|
158
169
|
)
|
|
159
170
|
await fastify.ready()
|
|
@@ -258,6 +269,53 @@ describe('POST /api/addons/upload', () => {
|
|
|
258
269
|
}
|
|
259
270
|
})
|
|
260
271
|
|
|
272
|
+
it('restarts the server (not an in-process reload) when a framework package is deployed', async () => {
|
|
273
|
+
// @camstack/core ships hub builtins that can't hot-reload in place — the
|
|
274
|
+
// upload path must call restartServer() and SKIP the per-addon restartAddon.
|
|
275
|
+
const tmpRoot = await import('node:fs/promises').then(fs => fs.mkdtemp('/tmp/camstack-upload-fw-'))
|
|
276
|
+
const previous = process.env['CAMSTACK_DATA']
|
|
277
|
+
process.env['CAMSTACK_DATA'] = tmpRoot
|
|
278
|
+
const fsSync = await import('node:fs')
|
|
279
|
+
fsSync.mkdirSync(`${tmpRoot}/addons/@camstack/core`, { recursive: true })
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const restartServer = vi.fn((_requestedBy?: string) => {})
|
|
283
|
+
const restartAddon = vi.fn(async (_id: string) => ({ success: true }))
|
|
284
|
+
const registry = {
|
|
285
|
+
listAddons: vi.fn(() => [{ manifest: { id: 'sqlite-settings', packageName: '@camstack/core' } }]),
|
|
286
|
+
loadNewAddons: vi.fn(async () => ({ loaded: [] as string[], failed: [] as string[] })),
|
|
287
|
+
restartAddon,
|
|
288
|
+
getCapabilityRegistry: vi.fn(() => ({ getSingleton: vi.fn((_n: string) => null) })),
|
|
289
|
+
} as unknown as AddonRegistryService
|
|
290
|
+
const pkgSvc = { restartServer } as unknown as AddonPackageService
|
|
291
|
+
|
|
292
|
+
fastify = await makeServer({
|
|
293
|
+
auth: makeAuth('admin'),
|
|
294
|
+
bridge: makeAddonBridge({ name: '@camstack/core', version: '9.9.9' }),
|
|
295
|
+
moleculer: makeMoleculer(vi.fn()),
|
|
296
|
+
addonRegistry: registry,
|
|
297
|
+
addonPackageService: pkgSvc,
|
|
298
|
+
})
|
|
299
|
+
const res = await fastify.inject({
|
|
300
|
+
method: 'POST',
|
|
301
|
+
url: '/api/addons/upload',
|
|
302
|
+
headers: { 'content-type': `multipart/form-data; boundary=${TGZ_BOUNDARY}`, authorization: 'Bearer t' },
|
|
303
|
+
payload: buildMultipart(TGZ_BOUNDARY, {
|
|
304
|
+
file: { filename: 'camstack-core-9.9.9.tgz', content: buildValidTarball({ name: '@camstack/core', version: '9.9.9' }) },
|
|
305
|
+
}),
|
|
306
|
+
})
|
|
307
|
+
expect(res.statusCode).toBe(200)
|
|
308
|
+
expect(res.json()).toMatchObject({ success: true, requiresRestart: true, restarting: true })
|
|
309
|
+
expect(restartServer).toHaveBeenCalledOnce()
|
|
310
|
+
// Critical: the in-process restart that breaks builtins must NOT run.
|
|
311
|
+
expect(restartAddon).not.toHaveBeenCalled()
|
|
312
|
+
} finally {
|
|
313
|
+
if (previous === undefined) delete process.env['CAMSTACK_DATA']
|
|
314
|
+
else process.env['CAMSTACK_DATA'] = previous
|
|
315
|
+
fsSync.rmSync(tmpRoot, { recursive: true, force: true })
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
|
|
261
319
|
it('routes to $agent.deploy when nodeId is provided', async () => {
|
|
262
320
|
const call = vi.fn(async () => ({ success: true, addonId: 'addon-x', path: '/agent/addons/addon-x' }))
|
|
263
321
|
fastify = await makeServer({
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
BulkUpdateCoordinator,
|
|
4
|
+
type BulkUpdateCoordinatorDeps,
|
|
5
|
+
} from '../api/core/bulk-update-coordinator.js'
|
|
6
|
+
import { EventCategory, type BulkUpdateState } from '@camstack/types'
|
|
7
|
+
|
|
8
|
+
interface TestRig {
|
|
9
|
+
readonly coordinator: BulkUpdateCoordinator
|
|
10
|
+
readonly events: BulkUpdateState[]
|
|
11
|
+
readonly updateAddon: ReturnType<typeof vi.fn>
|
|
12
|
+
readonly updateFrameworkPackage: ReturnType<typeof vi.fn>
|
|
13
|
+
readonly restartServer: ReturnType<typeof vi.fn>
|
|
14
|
+
readonly now: () => number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function makeRig(opts?: { failItems?: ReadonlySet<string> }): TestRig {
|
|
18
|
+
const events: BulkUpdateState[] = []
|
|
19
|
+
const fail = opts?.failItems ?? new Set<string>()
|
|
20
|
+
let clock = 1_000_000
|
|
21
|
+
|
|
22
|
+
const updateAddon = vi.fn(async (input: { name: string; version: string }) => {
|
|
23
|
+
if (fail.has(input.name)) throw new Error(`mock fail ${input.name}`)
|
|
24
|
+
})
|
|
25
|
+
const updateFrameworkPackage = vi.fn(async (input: { packageName: string; version: string; deferRestart: boolean }) => {
|
|
26
|
+
if (fail.has(input.packageName)) throw new Error(`mock fail fw ${input.packageName}`)
|
|
27
|
+
})
|
|
28
|
+
const restartServer = vi.fn(async () => {
|
|
29
|
+
// simulates the real restartServer: in a real run the process dies
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
const deps: BulkUpdateCoordinatorDeps = {
|
|
33
|
+
eventBus: {
|
|
34
|
+
emit: (category, payload) => {
|
|
35
|
+
if (category === EventCategory.AddonsBulkUpdateProgress) {
|
|
36
|
+
events.push(structuredClone(payload as BulkUpdateState))
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
} as unknown as BulkUpdateCoordinatorDeps['eventBus'],
|
|
40
|
+
updateAddon,
|
|
41
|
+
updateFrameworkPackage,
|
|
42
|
+
restartServer,
|
|
43
|
+
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as BulkUpdateCoordinatorDeps['logger'],
|
|
44
|
+
clock: () => clock++,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
coordinator: new BulkUpdateCoordinator(deps),
|
|
49
|
+
events,
|
|
50
|
+
updateAddon,
|
|
51
|
+
updateFrameworkPackage,
|
|
52
|
+
restartServer,
|
|
53
|
+
now: () => clock,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('BulkUpdateCoordinator', () => {
|
|
58
|
+
beforeEach(() => { vi.useFakeTimers() })
|
|
59
|
+
afterEach(() => { vi.useRealTimers() })
|
|
60
|
+
|
|
61
|
+
it('regular-only happy path: processes 3 addons in order, no restart', async () => {
|
|
62
|
+
const rig = makeRig()
|
|
63
|
+
const { id } = rig.coordinator.start({
|
|
64
|
+
nodeId: 'hub',
|
|
65
|
+
items: [
|
|
66
|
+
{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
|
|
67
|
+
{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
|
|
68
|
+
{ name: '@camstack/addon-c', version: '3.0.1', isSystem: false },
|
|
69
|
+
],
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await vi.runAllTimersAsync()
|
|
73
|
+
|
|
74
|
+
const final = rig.coordinator.get(id)!
|
|
75
|
+
expect(final.completed).toBe(3)
|
|
76
|
+
expect(final.failed).toBe(0)
|
|
77
|
+
expect(final.phase).toBe('finalizing')
|
|
78
|
+
expect(final.items.every(i => i.status === 'done')).toBe(true)
|
|
79
|
+
expect(rig.restartServer).not.toHaveBeenCalled()
|
|
80
|
+
expect(rig.updateAddon).toHaveBeenCalledTimes(3)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('mixed happy path: regular first, system after, single restart at end', async () => {
|
|
84
|
+
const rig = makeRig()
|
|
85
|
+
const { id } = rig.coordinator.start({
|
|
86
|
+
nodeId: 'hub',
|
|
87
|
+
items: [
|
|
88
|
+
{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
|
|
89
|
+
{ name: '@camstack/types', version: '0.1.40', isSystem: true },
|
|
90
|
+
{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
|
|
91
|
+
{ name: '@camstack/kernel', version: '0.1.30', isSystem: true },
|
|
92
|
+
],
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await vi.runAllTimersAsync()
|
|
96
|
+
|
|
97
|
+
const final = rig.coordinator.get(id)!
|
|
98
|
+
expect(final.completed).toBe(4)
|
|
99
|
+
expect(rig.updateAddon).toHaveBeenCalledTimes(2)
|
|
100
|
+
expect(rig.updateFrameworkPackage).toHaveBeenCalledTimes(2)
|
|
101
|
+
expect(rig.updateFrameworkPackage).toHaveBeenCalledWith(expect.objectContaining({ deferRestart: true }))
|
|
102
|
+
expect(rig.restartServer).toHaveBeenCalledOnce()
|
|
103
|
+
|
|
104
|
+
// Verify ordering via event sequence: regular items reach 'updating' before any system item
|
|
105
|
+
const updatingNames = rig.events
|
|
106
|
+
.map(s => s.current)
|
|
107
|
+
.filter((n): n is string => n !== null)
|
|
108
|
+
const firstSystemIdx = updatingNames.findIndex(n => n === '@camstack/types' || n === '@camstack/kernel')
|
|
109
|
+
const lastRegularIdx = updatingNames.findLastIndex(n => n === '@camstack/addon-a' || n === '@camstack/addon-b')
|
|
110
|
+
expect(firstSystemIdx).toBeGreaterThan(lastRegularIdx)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('failure isolation: failed item does not block others', async () => {
|
|
114
|
+
const rig = makeRig({ failItems: new Set(['@camstack/addon-b']) })
|
|
115
|
+
const { id } = rig.coordinator.start({
|
|
116
|
+
nodeId: 'hub',
|
|
117
|
+
items: [
|
|
118
|
+
{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
|
|
119
|
+
{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
|
|
120
|
+
{ name: '@camstack/addon-c', version: '3.0.1', isSystem: false },
|
|
121
|
+
],
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
await vi.runAllTimersAsync()
|
|
125
|
+
|
|
126
|
+
const final = rig.coordinator.get(id)!
|
|
127
|
+
expect(final.failed).toBe(1)
|
|
128
|
+
expect(final.items.find(i => i.name === '@camstack/addon-b')?.status).toBe('failed')
|
|
129
|
+
expect(final.items.find(i => i.name === '@camstack/addon-b')?.error).toContain('mock fail')
|
|
130
|
+
expect(final.items.find(i => i.name === '@camstack/addon-a')?.status).toBe('done')
|
|
131
|
+
expect(final.items.find(i => i.name === '@camstack/addon-c')?.status).toBe('done')
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('cancel pre-restart: loop exits, no restart, queued items remain queued', async () => {
|
|
135
|
+
const rig = makeRig()
|
|
136
|
+
rig.updateAddon.mockImplementation(async () => {
|
|
137
|
+
// Slow enough that cancel fires before item 2
|
|
138
|
+
await new Promise<void>(resolve => { setTimeout(resolve, 50) })
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
const { id } = rig.coordinator.start({
|
|
142
|
+
nodeId: 'hub',
|
|
143
|
+
items: [
|
|
144
|
+
{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
|
|
145
|
+
{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
|
|
146
|
+
{ name: '@camstack/addon-c', version: '3.0.1', isSystem: false },
|
|
147
|
+
],
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Advance time so item 1 starts updating
|
|
151
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
152
|
+
const cancel = rig.coordinator.cancel(id)
|
|
153
|
+
expect(cancel.cancelled).toBe(true)
|
|
154
|
+
await vi.runAllTimersAsync()
|
|
155
|
+
|
|
156
|
+
const final = rig.coordinator.get(id)!
|
|
157
|
+
expect(final.cancelled).toBe(true)
|
|
158
|
+
expect(rig.restartServer).not.toHaveBeenCalled()
|
|
159
|
+
// At least one item should still be queued (we cancelled before item 2 ran)
|
|
160
|
+
expect(final.items.filter(i => i.status === 'queued').length).toBeGreaterThanOrEqual(1)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('cancel ignored during restarting phase', async () => {
|
|
164
|
+
const rig = makeRig()
|
|
165
|
+
rig.restartServer.mockImplementation(async () => {
|
|
166
|
+
// Hang briefly so we can attempt cancel during restart
|
|
167
|
+
await new Promise<void>(resolve => { setTimeout(resolve, 50) })
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const { id } = rig.coordinator.start({
|
|
171
|
+
nodeId: 'hub',
|
|
172
|
+
items: [
|
|
173
|
+
{ name: '@camstack/types', version: '0.1.40', isSystem: true },
|
|
174
|
+
],
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Let it reach restarting
|
|
178
|
+
await vi.advanceTimersByTimeAsync(10)
|
|
179
|
+
const state = rig.coordinator.get(id)
|
|
180
|
+
if (state?.phase === 'restarting') {
|
|
181
|
+
const cancel = rig.coordinator.cancel(id)
|
|
182
|
+
expect(cancel.cancelled).toBe(false)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
await vi.runAllTimersAsync()
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('restart failure: pending-restart items get promoted to done with caveat error', async () => {
|
|
189
|
+
const rig = makeRig()
|
|
190
|
+
rig.restartServer.mockRejectedValueOnce(new Error('restart crashed'))
|
|
191
|
+
|
|
192
|
+
const { id } = rig.coordinator.start({
|
|
193
|
+
nodeId: 'hub',
|
|
194
|
+
items: [
|
|
195
|
+
{ name: '@camstack/types', version: '0.1.40', isSystem: true },
|
|
196
|
+
],
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
await vi.runAllTimersAsync()
|
|
200
|
+
|
|
201
|
+
const final = rig.coordinator.get(id)!
|
|
202
|
+
const typesItem = final.items.find(i => i.name === '@camstack/types')!
|
|
203
|
+
expect(typesItem.status).toBe('done')
|
|
204
|
+
expect(typesItem.error).toContain('Restart failed')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('concurrent start for same nodeId is rejected', () => {
|
|
208
|
+
const rig = makeRig()
|
|
209
|
+
rig.coordinator.start({
|
|
210
|
+
nodeId: 'hub',
|
|
211
|
+
items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
expect(() => rig.coordinator.start({
|
|
215
|
+
nodeId: 'hub',
|
|
216
|
+
items: [{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false }],
|
|
217
|
+
})).toThrow(/already in progress/i)
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('auto-cleanup: state purged 5 min after completedAt', async () => {
|
|
221
|
+
const rig = makeRig()
|
|
222
|
+
const { id } = rig.coordinator.start({
|
|
223
|
+
nodeId: 'hub',
|
|
224
|
+
items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
await vi.runAllTimersAsync()
|
|
228
|
+
expect(rig.coordinator.get(id)).not.toBeNull()
|
|
229
|
+
|
|
230
|
+
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 100)
|
|
231
|
+
expect(rig.coordinator.get(id)).toBeNull()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('list excludes stale states after cleanup window (lazy cleanup symmetry)', async () => {
|
|
235
|
+
const rig = makeRig()
|
|
236
|
+
const { id } = rig.coordinator.start({
|
|
237
|
+
nodeId: 'hub',
|
|
238
|
+
items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
await vi.runAllTimersAsync()
|
|
242
|
+
|
|
243
|
+
// Right after completion: both get() and list() see the state
|
|
244
|
+
expect(rig.coordinator.get(id)).not.toBeNull()
|
|
245
|
+
expect(rig.coordinator.list()).toHaveLength(1)
|
|
246
|
+
|
|
247
|
+
// After cleanup window: both should agree that the state is gone
|
|
248
|
+
await vi.advanceTimersByTimeAsync(5 * 60 * 1000 + 100)
|
|
249
|
+
expect(rig.coordinator.get(id)).toBeNull()
|
|
250
|
+
expect(rig.coordinator.list()).toHaveLength(0)
|
|
251
|
+
expect(rig.coordinator.list('hub')).toHaveLength(0)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('list filters by nodeId', () => {
|
|
255
|
+
const rig = makeRig()
|
|
256
|
+
rig.coordinator.start({
|
|
257
|
+
nodeId: 'hub',
|
|
258
|
+
items: [{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false }],
|
|
259
|
+
})
|
|
260
|
+
expect(rig.coordinator.list('hub')).toHaveLength(1)
|
|
261
|
+
expect(rig.coordinator.list('agent-1')).toHaveLength(0)
|
|
262
|
+
expect(rig.coordinator.list()).toHaveLength(1)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('emits AddonsBulkUpdateProgress event on every status transition', async () => {
|
|
266
|
+
const rig = makeRig()
|
|
267
|
+
rig.coordinator.start({
|
|
268
|
+
nodeId: 'hub',
|
|
269
|
+
items: [
|
|
270
|
+
{ name: '@camstack/addon-a', version: '1.0.1', isSystem: false },
|
|
271
|
+
{ name: '@camstack/addon-b', version: '2.0.1', isSystem: false },
|
|
272
|
+
],
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
await vi.runAllTimersAsync()
|
|
276
|
+
|
|
277
|
+
// At minimum: phase transition + 2 items × 2 transitions (updating → done)
|
|
278
|
+
expect(rig.events.length).toBeGreaterThanOrEqual(5)
|
|
279
|
+
// Every payload must be a complete BulkUpdateState (not a partial diff)
|
|
280
|
+
for (const evt of rig.events) {
|
|
281
|
+
expect(evt.id).toBeDefined()
|
|
282
|
+
expect(evt.nodeId).toBe('hub')
|
|
283
|
+
expect(Array.isArray(evt.items)).toBe(true)
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
})
|