@cat-factory/app 0.132.0 → 0.134.0

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.
@@ -0,0 +1,479 @@
1
+ import { defineModule } from '@modular-vue/core'
2
+ import type { AppSlots } from './slots'
3
+
4
+ // Re-exported for the slice-1 importers that reach `AppSlots` through this
5
+ // module (`useNavContributions`, `registry`, the nav specs); its canonical home
6
+ // is now `./slots`, where all slot keys are aggregated (slice 2 added
7
+ // `resultViews` / `agentKinds`).
8
+ export type { AppSlots } from './slots'
9
+
10
+ /**
11
+ * The single nav/command catalog for the layer (slice 1 of the modular-vue
12
+ * adoption — docs/initiatives/modular-vue-adoption.md).
13
+ *
14
+ * Every destination is declared ONCE here as data and rendered three ways —
15
+ * `SideBar`, `CommandBar`, `BoardToolbar` — instead of each shell hand-rolling
16
+ * its own item list + RBAC gating (the pre-slice-1 triple-maintenance). RBAC /
17
+ * availability gating is a reactive `slotFilter` ({@link navSlotFilter}) over a
18
+ * reactive `gates` service (see `nav-gates.ts`), read through `useReactiveSlots`
19
+ * so an item shows/hides the instant a permission or connection flips — no
20
+ * `recalculateSlots()` call. A consumer deployment contributes its own items to
21
+ * the same `nav` slot via `registerAppModule`, so they light up in all three
22
+ * shells with zero shell edits.
23
+ */
24
+
25
+ /** Which shell(s) render a contribution. */
26
+ export type NavSurface = 'sidebar' | 'command' | 'toolbar'
27
+
28
+ /** Sidebar section a contribution lands in (its i18n header is `nav.<group>`). */
29
+ export type NavSidebarGroup =
30
+ | 'create'
31
+ | 'repositories'
32
+ | 'integrations'
33
+ | 'infrastructure'
34
+ | 'workspaceContext'
35
+ | 'configuration'
36
+
37
+ /** Command-palette group (its i18n label is `layout.commandBar.groups.<group>`). */
38
+ export type NavCommandGroup = 'create' | 'repositories' | 'integrations' | 'workspace' | 'account'
39
+
40
+ /**
41
+ * The reactive gate inputs a contribution's `gate` predicate reads. Backed by a
42
+ * plain service object whose getters read the host's reactive RBAC/availability
43
+ * state (see `createNavGates`), so reading them inside the `useReactiveSlots`
44
+ * computed tracks them.
45
+ */
46
+ export interface NavGates {
47
+ /** `board.write` — create pipelines, add repos. */
48
+ canWriteBoard: boolean
49
+ /** `integrations.manage` — bootstrap, connection/infra management, sandbox. */
50
+ canManageIntegrations: boolean
51
+ /** `settings.manage` — workspace/model config, fragment library. */
52
+ canManageSettings: boolean
53
+ /** The GitHub (source-control) integration is enabled on the backend. */
54
+ githubAvailable: boolean
55
+ /** The prompt-fragment library integration is enabled. */
56
+ libraryAvailable: boolean
57
+ /** An execution/test-env backend is reported (runner pool / environment / local). */
58
+ infrastructureAvailable: boolean
59
+ /** Accounts (auth) are enabled on the deployment. */
60
+ accountsEnabled: boolean
61
+ /** The caller is an admin of the active account. */
62
+ isAccountAdmin: boolean
63
+ }
64
+
65
+ /** Command-palette placement + copy for a contribution that appears in the palette. */
66
+ export interface NavCommandSpec {
67
+ group: NavCommandGroup
68
+ order: number
69
+ /** Palette label key; falls back to the contribution's `labelKey`. */
70
+ labelKey?: string
71
+ /** Extra fuzzy-match keywords (i18n key). */
72
+ keywordsKey?: string
73
+ }
74
+
75
+ /**
76
+ * The first-party action ids, each resolved to a host `ui`-store handler in
77
+ * `useNavContributions`. Typing {@link NavContribution.action} against this union
78
+ * (and the handler map as an exhaustive `Record<NavActionId, …>`) makes a drift
79
+ * between the catalog and the handler map a compile error instead of a silently
80
+ * dead button. Consumer modules don't use these — they carry their own `run`.
81
+ */
82
+ export const NAV_ACTIONS = [
83
+ 'buildPipeline',
84
+ 'addFromRepo',
85
+ 'bootstrapRepo',
86
+ 'integrationsHub',
87
+ 'sandbox',
88
+ 'kaizen',
89
+ 'infrastructure',
90
+ 'environmentSetup',
91
+ 'fragmentLibrary',
92
+ 'mergeThresholds',
93
+ 'workspaceSettings',
94
+ 'modelConfiguration',
95
+ 'serviceFragmentDefaults',
96
+ 'localModels',
97
+ 'accountSettings',
98
+ 'operatorDashboard',
99
+ 'shortcuts',
100
+ ] as const
101
+
102
+ export type NavActionId = (typeof NAV_ACTIONS)[number]
103
+
104
+ /** One destination, declared once and rendered per surface. */
105
+ export interface NavContribution {
106
+ id: string
107
+ /** Default (sidebar) label i18n key. */
108
+ labelKey: string
109
+ icon: string
110
+ surfaces: readonly NavSurface[]
111
+ /** Reactive predicate over {@link NavGates}; absent = always visible. */
112
+ gate?: (g: NavGates) => boolean
113
+ /**
114
+ * First-party action id, resolved to a `run()` against the host `ui` store by
115
+ * `useNavContributions`. A consumer module that has its own stores instead
116
+ * supplies {@link run} directly.
117
+ */
118
+ action?: NavActionId
119
+ /** Direct handler (consumer modules); takes precedence over {@link action}. */
120
+ run?: () => void
121
+ /** Stable selector for e2e / existing specs. */
122
+ testId?: string
123
+ /** Sidebar placement (present when `surfaces` includes `'sidebar'`). */
124
+ sidebar?: { group: NavSidebarGroup; order: number }
125
+ /** Command-palette placement (present when `surfaces` includes `'command'`). */
126
+ command?: NavCommandSpec
127
+ /** Toolbar placement (present when `surfaces` includes `'toolbar'`). */
128
+ toolbar?: { order: number }
129
+ }
130
+
131
+ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
132
+
133
+ /**
134
+ * The first-party catalog. Mapped 1:1 from the pre-slice-1 `SideBar` + `CommandBar`
135
+ * gating. Two deliberate consistency unifications noted in the tracker:
136
+ * - `account-settings` gates on `accountsEnabled` in BOTH shells (the palette
137
+ * previously showed it unconditionally; the account modal only makes sense
138
+ * with accounts enabled).
139
+ * - one icon per destination across shells.
140
+ */
141
+ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
142
+ {
143
+ id: 'build-pipeline',
144
+ labelKey: 'nav.buildPipeline',
145
+ icon: 'i-lucide-workflow',
146
+ surfaces: S('sidebar', 'command'),
147
+ gate: (g) => g.canWriteBoard,
148
+ action: 'buildPipeline',
149
+ testId: 'nav-build-pipeline',
150
+ sidebar: { group: 'create', order: 10 },
151
+ command: {
152
+ group: 'create',
153
+ order: 10,
154
+ labelKey: 'layout.commandBar.cmd.newPipeline',
155
+ keywordsKey: 'layout.commandBar.keywords.newPipeline',
156
+ },
157
+ },
158
+ {
159
+ id: 'add-from-repo',
160
+ labelKey: 'nav.addFromRepo',
161
+ icon: 'i-lucide-folder-git-2',
162
+ surfaces: S('sidebar', 'command'),
163
+ gate: (g) => g.githubAvailable && g.canWriteBoard,
164
+ action: 'addFromRepo',
165
+ testId: 'nav-add-from-repo',
166
+ sidebar: { group: 'repositories', order: 10 },
167
+ command: {
168
+ group: 'repositories',
169
+ order: 10,
170
+ labelKey: 'layout.commandBar.cmd.addFromRepo',
171
+ keywordsKey: 'layout.commandBar.keywords.addFromRepo',
172
+ },
173
+ },
174
+ {
175
+ id: 'bootstrap-repo',
176
+ labelKey: 'nav.bootstrapRepo',
177
+ icon: 'i-lucide-git-branch-plus',
178
+ surfaces: S('sidebar', 'command'),
179
+ gate: (g) => g.canManageIntegrations,
180
+ action: 'bootstrapRepo',
181
+ testId: 'nav-bootstrap-repo',
182
+ sidebar: { group: 'repositories', order: 20 },
183
+ command: {
184
+ group: 'repositories',
185
+ order: 20,
186
+ labelKey: 'layout.commandBar.cmd.bootstrapRepo',
187
+ keywordsKey: 'layout.commandBar.keywords.bootstrapRepo',
188
+ },
189
+ },
190
+ {
191
+ id: 'integrations-hub',
192
+ labelKey: 'nav.integrations',
193
+ icon: 'i-lucide-blocks',
194
+ surfaces: S('sidebar'),
195
+ gate: (g) => g.canManageIntegrations,
196
+ action: 'integrationsHub',
197
+ testId: 'nav-integrations',
198
+ sidebar: { group: 'integrations', order: 10 },
199
+ },
200
+ {
201
+ id: 'sandbox',
202
+ labelKey: 'nav.sandbox',
203
+ icon: 'i-lucide-flask-conical',
204
+ surfaces: S('sidebar', 'command'),
205
+ gate: (g) => g.canManageIntegrations,
206
+ action: 'sandbox',
207
+ testId: 'nav-sandbox',
208
+ sidebar: { group: 'integrations', order: 20 },
209
+ command: {
210
+ group: 'workspace',
211
+ order: 70,
212
+ labelKey: 'layout.commandBar.cmd.sandbox',
213
+ keywordsKey: 'layout.commandBar.keywords.sandbox',
214
+ },
215
+ },
216
+ {
217
+ id: 'kaizen',
218
+ labelKey: 'nav.kaizen',
219
+ icon: 'i-lucide-sparkles',
220
+ surfaces: S('sidebar'),
221
+ action: 'kaizen',
222
+ testId: 'nav-kaizen',
223
+ sidebar: { group: 'integrations', order: 30 },
224
+ },
225
+ {
226
+ id: 'infrastructure',
227
+ labelKey: 'nav.infrastructure',
228
+ icon: 'i-lucide-server-cog',
229
+ surfaces: S('sidebar'),
230
+ gate: (g) => g.infrastructureAvailable,
231
+ action: 'infrastructure',
232
+ testId: 'nav-infrastructure',
233
+ sidebar: { group: 'infrastructure', order: 10 },
234
+ },
235
+ {
236
+ id: 'environment-setup',
237
+ labelKey: 'nav.environmentSetup',
238
+ icon: 'i-lucide-flask-conical',
239
+ surfaces: S('sidebar'),
240
+ gate: (g) => g.infrastructureAvailable,
241
+ action: 'environmentSetup',
242
+ testId: 'nav-environment-setup',
243
+ sidebar: { group: 'infrastructure', order: 20 },
244
+ },
245
+ {
246
+ id: 'fragments',
247
+ labelKey: 'nav.contextFragments',
248
+ icon: 'i-lucide-book-marked',
249
+ surfaces: S('sidebar', 'command'),
250
+ gate: (g) => g.libraryAvailable && g.canManageSettings,
251
+ action: 'fragmentLibrary',
252
+ testId: 'nav-fragments',
253
+ sidebar: { group: 'workspaceContext', order: 10 },
254
+ command: {
255
+ group: 'workspace',
256
+ order: 10,
257
+ labelKey: 'layout.commandBar.cmd.fragments',
258
+ keywordsKey: 'layout.commandBar.keywords.fragments',
259
+ },
260
+ },
261
+ {
262
+ id: 'merge-thresholds',
263
+ labelKey: 'layout.commandBar.cmd.mergeThresholds',
264
+ icon: 'i-lucide-git-merge',
265
+ surfaces: S('command'),
266
+ gate: (g) => g.canManageSettings,
267
+ action: 'mergeThresholds',
268
+ command: {
269
+ group: 'workspace',
270
+ order: 20,
271
+ keywordsKey: 'layout.commandBar.keywords.mergeThresholds',
272
+ },
273
+ },
274
+ {
275
+ id: 'workspace-settings',
276
+ labelKey: 'nav.workspaceSettings',
277
+ icon: 'i-lucide-sliders-horizontal',
278
+ surfaces: S('sidebar', 'command'),
279
+ gate: (g) => g.canManageSettings,
280
+ action: 'workspaceSettings',
281
+ testId: 'nav-workspace-settings',
282
+ sidebar: { group: 'configuration', order: 10 },
283
+ command: {
284
+ group: 'workspace',
285
+ order: 30,
286
+ labelKey: 'layout.commandBar.cmd.workspaceSettings',
287
+ keywordsKey: 'layout.commandBar.keywords.workspaceSettings',
288
+ },
289
+ },
290
+ {
291
+ id: 'model-config',
292
+ labelKey: 'nav.modelConfiguration',
293
+ icon: 'i-lucide-cpu',
294
+ surfaces: S('sidebar', 'command'),
295
+ gate: (g) => g.canManageSettings,
296
+ action: 'modelConfiguration',
297
+ testId: 'nav-model-config',
298
+ sidebar: { group: 'configuration', order: 20 },
299
+ command: {
300
+ group: 'workspace',
301
+ order: 40,
302
+ labelKey: 'layout.commandBar.cmd.modelConfiguration',
303
+ keywordsKey: 'layout.commandBar.keywords.modelConfiguration',
304
+ },
305
+ },
306
+ {
307
+ id: 'service-fragment-defaults',
308
+ labelKey: 'layout.commandBar.cmd.serviceFragmentDefaults',
309
+ icon: 'i-lucide-book-open-check',
310
+ surfaces: S('command'),
311
+ gate: (g) => g.canManageSettings,
312
+ action: 'serviceFragmentDefaults',
313
+ command: {
314
+ group: 'workspace',
315
+ order: 50,
316
+ keywordsKey: 'layout.commandBar.keywords.serviceFragmentDefaults',
317
+ },
318
+ },
319
+ {
320
+ id: 'local-models',
321
+ labelKey: 'layout.commandBar.cmd.localModels',
322
+ icon: 'i-lucide-server',
323
+ surfaces: S('command'),
324
+ action: 'localModels',
325
+ command: {
326
+ group: 'workspace',
327
+ order: 60,
328
+ keywordsKey: 'layout.commandBar.keywords.localModels',
329
+ },
330
+ },
331
+ {
332
+ id: 'account-settings',
333
+ labelKey: 'nav.accountSettings',
334
+ icon: 'i-lucide-users',
335
+ surfaces: S('sidebar', 'command'),
336
+ gate: (g) => g.accountsEnabled,
337
+ action: 'accountSettings',
338
+ testId: 'nav-account-settings',
339
+ sidebar: { group: 'configuration', order: 30 },
340
+ command: {
341
+ group: 'account',
342
+ order: 10,
343
+ labelKey: 'layout.commandBar.cmd.accountSettings',
344
+ keywordsKey: 'layout.commandBar.keywords.accountSettings',
345
+ },
346
+ },
347
+ {
348
+ id: 'operator-dashboard',
349
+ labelKey: 'nav.operatorDashboard',
350
+ icon: 'i-lucide-gauge',
351
+ surfaces: S('sidebar'),
352
+ gate: (g) => g.accountsEnabled && g.isAccountAdmin,
353
+ action: 'operatorDashboard',
354
+ testId: 'nav-operator-dashboard',
355
+ sidebar: { group: 'configuration', order: 40 },
356
+ },
357
+ {
358
+ id: 'keyboard-shortcuts',
359
+ labelKey: 'layout.commandBar.cmd.shortcuts',
360
+ icon: 'i-lucide-keyboard',
361
+ surfaces: S('command'),
362
+ action: 'shortcuts',
363
+ command: {
364
+ group: 'workspace',
365
+ order: 80,
366
+ keywordsKey: 'layout.commandBar.keywords.shortcuts',
367
+ },
368
+ },
369
+ ]
370
+
371
+ /**
372
+ * The first-party navigation module: contributes the whole catalog to the `nav`
373
+ * slot. Registered by `createAppRegistry`.
374
+ */
375
+ export const navigationModule = defineModule({
376
+ id: 'cat-factory:navigation',
377
+ version: '1.0.0',
378
+ slots: { nav: [...NAV_CONTRIBUTIONS] },
379
+ })
380
+
381
+ /**
382
+ * Reactive RBAC/availability filter over the merged `nav` slot. Reads
383
+ * `deps.gates.*` (the reactive gate service) per item, so evaluated inside
384
+ * `useReactiveSlots` it re-runs when a permission or connection flips. Passed to
385
+ * `installModularApp` as the global `slotFilter`.
386
+ *
387
+ * Typed against `AppSlots` (not the generic `SlotFilter`) so it matches the
388
+ * filter shape the runtime infers for this registry. `deps` is widened to an
389
+ * optional `gates` to avoid importing `AppDeps` (which would be circular).
390
+ */
391
+ export function navSlotFilter(slots: AppSlots, deps: { gates?: NavGates }): AppSlots {
392
+ const gates = deps.gates
393
+ const nav = slots.nav ?? []
394
+ return {
395
+ ...slots,
396
+ // No gates service wired (tests / bare install) ⇒ show everything, matching
397
+ // the dev-open "absent access allows all" backend parity.
398
+ nav: gates ? nav.filter((i) => (i.gate ? i.gate(gates) : true)) : nav,
399
+ }
400
+ }
401
+
402
+ /** Sidebar sections, in render order; each header is `nav.<group>`. */
403
+ export const SIDEBAR_GROUP_ORDER: readonly NavSidebarGroup[] = [
404
+ 'create',
405
+ 'repositories',
406
+ 'integrations',
407
+ 'infrastructure',
408
+ 'workspaceContext',
409
+ 'configuration',
410
+ ]
411
+
412
+ /** Command-palette groups, in render order; each label is `layout.commandBar.groups.<group>`. */
413
+ export const COMMAND_GROUP_ORDER: readonly NavCommandGroup[] = [
414
+ 'create',
415
+ 'repositories',
416
+ 'integrations',
417
+ 'workspace',
418
+ 'account',
419
+ ]
420
+
421
+ export interface SidebarGroup {
422
+ group: NavSidebarGroup
423
+ /** i18n key for the section header. */
424
+ labelKey: string
425
+ items: NavContribution[]
426
+ }
427
+
428
+ export interface CommandItem {
429
+ item: NavContribution
430
+ /** Resolved palette label i18n key. */
431
+ labelKey: string
432
+ /** Resolved palette keywords i18n key, if any. */
433
+ keywordsKey?: string
434
+ }
435
+
436
+ export interface CommandGroup {
437
+ group: NavCommandGroup
438
+ /** i18n key for the group label. */
439
+ labelKey: string
440
+ items: CommandItem[]
441
+ }
442
+
443
+ /**
444
+ * Pure grouping/ordering helpers over an already-gated item list. Kept here (not
445
+ * in the composable) so they're unit-testable without a Vue/Nuxt runtime — the
446
+ * composable just feeds them the reactive `nav` slot. Each returns groups in
447
+ * canonical order with empty groups dropped and items sorted by their per-surface
448
+ * `order`.
449
+ */
450
+ export function groupSidebar(items: readonly NavContribution[]): SidebarGroup[] {
451
+ return SIDEBAR_GROUP_ORDER.map((group) => ({
452
+ group,
453
+ labelKey: `nav.${group}`,
454
+ items: items
455
+ .filter((i) => i.surfaces.includes('sidebar') && i.sidebar?.group === group)
456
+ .sort((a, b) => (a.sidebar?.order ?? 0) - (b.sidebar?.order ?? 0)),
457
+ })).filter((g) => g.items.length > 0)
458
+ }
459
+
460
+ export function groupCommands(items: readonly NavContribution[]): CommandGroup[] {
461
+ return COMMAND_GROUP_ORDER.map((group) => ({
462
+ group,
463
+ labelKey: `layout.commandBar.groups.${group}`,
464
+ items: items
465
+ .filter((i) => i.surfaces.includes('command') && i.command?.group === group)
466
+ .sort((a, b) => (a.command?.order ?? 0) - (b.command?.order ?? 0))
467
+ .map<CommandItem>((item) => ({
468
+ item,
469
+ labelKey: item.command?.labelKey ?? item.labelKey,
470
+ keywordsKey: item.command?.keywordsKey,
471
+ })),
472
+ })).filter((g) => g.items.length > 0)
473
+ }
474
+
475
+ export function sortToolbar(items: readonly NavContribution[]): NavContribution[] {
476
+ return items
477
+ .filter((i) => i.surfaces.includes('toolbar'))
478
+ .sort((a, b) => (a.toolbar?.order ?? 0) - (b.toolbar?.order ?? 0))
479
+ }
@@ -0,0 +1,63 @@
1
+ import { computed } from 'vue'
2
+ import type { NavGates } from '~/modular/nav-contributions'
3
+
4
+ /**
5
+ * Build the reactive `gates` service the nav `slotFilter` reads (slice 1 of the
6
+ * modular-vue adoption). Called once from the modular install plugin, where
7
+ * Pinia + composables are available.
8
+ *
9
+ * Returns a plain object whose getters read the host's reactive RBAC /
10
+ * availability state. Passed to the registry as a `service` (by reference, not
11
+ * snapshotted), so when `navSlotFilter` reads `gates.canWriteBoard` inside the
12
+ * `useReactiveSlots` computed the underlying reactive source is tracked — a
13
+ * permission or connection flip re-gates every shell with no `recalculateSlots()`.
14
+ *
15
+ * This mirrors the exact gating the pre-slice-1 `SideBar` computeds encoded (see
16
+ * `useWorkspaceAccess` for the dev-open "absent access ⇒ allow all" parity).
17
+ */
18
+ export function createNavGates(): NavGates {
19
+ const access = useWorkspaceAccess()
20
+ const github = useGitHubStore()
21
+ const library = useFragmentLibraryStore()
22
+ const accounts = useAccountsStore()
23
+ const auth = useAuthStore()
24
+ const providerConnections = useProviderConnectionsStore()
25
+
26
+ const infrastructureAvailable = computed(
27
+ () =>
28
+ auth.infrastructure != null ||
29
+ auth.localMode?.enabled === true ||
30
+ providerConnections.isAvailable('runner-pool') ||
31
+ providerConnections.isAvailable('environment'),
32
+ )
33
+ const isAccountAdmin = computed(() => accounts.activeAccount?.roles?.includes('admin') ?? false)
34
+
35
+ return {
36
+ get canWriteBoard() {
37
+ return access.canWriteBoard.value
38
+ },
39
+ get canManageIntegrations() {
40
+ return access.canManageIntegrations.value
41
+ },
42
+ get canManageSettings() {
43
+ return access.canManageSettings.value
44
+ },
45
+ get githubAvailable() {
46
+ return github.available === true
47
+ },
48
+ get libraryAvailable() {
49
+ return library.available === true
50
+ },
51
+ get infrastructureAvailable() {
52
+ // `integrations.manage` is required to provision/manage infrastructure, so
53
+ // gate the whole section on it too (a member/viewer would only 403 inside).
54
+ return access.canManageIntegrations.value && infrastructureAvailable.value
55
+ },
56
+ get accountsEnabled() {
57
+ return accounts.enabled
58
+ },
59
+ get isAccountAdmin() {
60
+ return isAccountAdmin.value
61
+ },
62
+ }
63
+ }
@@ -1,31 +1,114 @@
1
1
  import { defineModule } from '@modular-vue/core'
2
2
  import { afterEach, describe, expect, it } from 'vitest'
3
3
  import { __resetConsumerModulesForTest, createAppRegistry, registerAppModule } from './registry'
4
+ import type { NavGates } from './nav-contributions'
5
+
6
+ const NO_GATES: NavGates = {
7
+ canWriteBoard: false,
8
+ canManageIntegrations: false,
9
+ canManageSettings: false,
10
+ githubAvailable: false,
11
+ libraryAvailable: false,
12
+ infrastructureAvailable: false,
13
+ accountsEnabled: false,
14
+ isAccountAdmin: false,
15
+ }
4
16
 
5
17
  describe('app modular registry', () => {
6
18
  afterEach(() => {
7
19
  __resetConsumerModulesForTest()
8
20
  })
9
21
 
10
- it('registers the first-party core module', () => {
11
- const manifest = createAppRegistry().resolveManifest()
12
- expect(manifest.modules.map((m) => m.id)).toContain('cat-factory:core')
22
+ it('registers the first-party navigation module', () => {
23
+ const manifest = createAppRegistry({ gates: NO_GATES }).resolveManifest()
24
+ expect(manifest.modules.map((m) => m.id)).toContain('cat-factory:navigation')
13
25
  })
14
26
 
15
27
  it('includes consumer modules contributed via registerAppModule', () => {
16
28
  registerAppModule(defineModule({ id: 'consumer:example', version: '1.0.0' }))
17
29
 
18
- const ids = createAppRegistry()
30
+ const ids = createAppRegistry({ gates: NO_GATES })
19
31
  .resolveManifest()
20
32
  .modules.map((m) => m.id)
21
33
 
22
- expect(ids).toContain('cat-factory:core')
34
+ expect(ids).toContain('cat-factory:navigation')
23
35
  expect(ids).toContain('consumer:example')
24
36
  })
25
37
 
26
38
  it('rejects a module whose id collides with an already-registered one', () => {
27
- registerAppModule(defineModule({ id: 'cat-factory:core', version: '2.0.0' }))
39
+ registerAppModule(defineModule({ id: 'cat-factory:navigation', version: '2.0.0' }))
40
+
41
+ expect(() => createAppRegistry({ gates: NO_GATES }).resolveManifest()).toThrow(/duplicate/i)
42
+ })
43
+
44
+ it('merges consumer-contributed result-view + agent-kind slots (slice-2 extensibility)', () => {
45
+ // A deployment ships its OWN dedicated window AND its custom agent kind through the
46
+ // same slots the first-party modules use — no layer fork. A fake component (plain
47
+ // object) stands in for the SFC so this stays a pure registry test.
48
+ const fakeComponent = { name: 'AcmeReportWindow' }
49
+ registerAppModule(
50
+ defineModule({
51
+ id: 'consumer:acme',
52
+ version: '1.0.0',
53
+ slots: {
54
+ resultViews: [{ id: 'acme:report', component: fakeComponent }],
55
+ agentKinds: [
56
+ {
57
+ kind: 'acme-audit',
58
+ container: true,
59
+ presentation: {
60
+ label: 'Acme Audit',
61
+ icon: 'i-lucide-shield',
62
+ color: '#fff',
63
+ description: 'd',
64
+ resultView: 'acme:report',
65
+ },
66
+ },
67
+ ],
68
+ },
69
+ }),
70
+ )
71
+
72
+ const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
73
+ resultViews: { id: string }[]
74
+ agentKinds: { kind: string; presentation: { resultView?: string } }[]
75
+ }
76
+ expect(slots.resultViews.map((v) => v.id)).toContain('acme:report')
77
+ const audit = slots.agentKinds.find((k) => k.kind === 'acme-audit')
78
+ // The custom kind's resultView id pairs against the consumer's own registered component.
79
+ expect(audit?.presentation.resultView).toBe('acme:report')
80
+ })
81
+
82
+ it('merges a consumer-contributed nav item into the resolved nav slot', () => {
83
+ // A deployment extending the layer contributes its own nav destination to the
84
+ // SAME `nav` slot the first-party module fills — it then renders in every shell
85
+ // with no shell edits (the slice-1 extensibility promise).
86
+ registerAppModule(
87
+ defineModule({
88
+ id: 'consumer:nav',
89
+ version: '1.0.0',
90
+ slots: {
91
+ nav: [
92
+ {
93
+ id: 'consumer:reports',
94
+ labelKey: 'consumer.reports',
95
+ icon: 'i-lucide-bar-chart',
96
+ surfaces: ['sidebar', 'toolbar'],
97
+ action: 'openReports',
98
+ sidebar: { group: 'configuration', order: 99 },
99
+ toolbar: { order: 10 },
100
+ },
101
+ ],
102
+ },
103
+ }),
104
+ )
28
105
 
29
- expect(() => createAppRegistry().resolveManifest()).toThrow(/duplicate/i)
106
+ const slots = createAppRegistry({ gates: NO_GATES }).resolveManifest().slots as {
107
+ nav: { id: string }[]
108
+ }
109
+ const ids = slots.nav.map((i) => i.id)
110
+ // First-party catalog + the consumer item both present in one merged slot.
111
+ expect(ids).toContain('build-pipeline')
112
+ expect(ids).toContain('consumer:reports')
30
113
  })
31
114
  })