inventory.spec.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209
  1. import { afterEach, describe, expect, it } from 'vitest'
  2. import { Context, FiberState, type Plugin } from '@deepseek-ai/cordis'
  3. import Loader, { Group } from '@deepseek-ai/cordis-plugin-loader'
  4. import { remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
  5. import type { AgentPresets } from '@deepseek-ai/dsh-agent-presets'
  6. import { ProfileRuntime, type ComposedStack, type Profile, type RowOrigin } from '@deepseek-ai/dsh-app-boot'
  7. import PluginInventoryGateway from '../src/index.ts'
  8. const contexts: Context[] = []
  9. afterEach(async () => {
  10. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  11. })
  12. const activePlugin: Plugin.Function = () => {}
  13. const pendingPlugin: Plugin.Object = {
  14. inject: ['neverReady'],
  15. apply() {},
  16. }
  17. async function harness(): Promise<{
  18. ctx: Context
  19. inventory: PluginInventoryGateway
  20. }> {
  21. const ctx = new Context()
  22. contexts.push(ctx)
  23. await ctx.plugin(Loader)
  24. ctx.loader.builtins.active = activePlugin
  25. ctx.loader.builtins.pending = pendingPlugin
  26. await ctx.plugin(PluginInventoryGateway)
  27. const inventory = ctx.get('pluginInventory') as PluginInventoryGateway
  28. return { ctx, inventory }
  29. }
  30. describe('PluginInventoryGateway', () => {
  31. it('publishes one direct list method under the pluginInventory namespace', async () => {
  32. const { inventory } = await harness()
  33. expect(inventory.typertRemote).toMatchObject({
  34. serviceKey: 'pluginInventory',
  35. namespace: 'pluginInventory',
  36. })
  37. expect(remoteMethods(inventory)).toEqual([
  38. { method: 'list', invocation: { kind: 'direct' } },
  39. ])
  40. })
  41. it('projects current non-group Loader entries without a second cache', async () => {
  42. const { ctx, inventory } = await harness()
  43. const activeId = await ctx.loader.create({ name: 'cordis:active' })
  44. const pendingId = await ctx.loader.create({ name: 'cordis:pending' })
  45. const disabledId = await ctx.loader.create({
  46. name: 'cordis:not-installed',
  47. disabled: true,
  48. })
  49. await ctx.loader.create({ name: 'cordis:active', group: true })
  50. const snapshot = await inventory.list()
  51. // No agent-preset roster is composed, so the snapshot carries no presets.
  52. expect(snapshot.agentPresets).toBeUndefined()
  53. expect(snapshot.entries).toHaveLength(3)
  54. expect(snapshot.entries).toEqual(expect.arrayContaining([
  55. {
  56. entryId: activeId,
  57. moduleName: 'cordis:active',
  58. enabled: true,
  59. fiberPhase: 'active',
  60. trust: 'builtin',
  61. },
  62. {
  63. entryId: pendingId,
  64. moduleName: 'cordis:pending',
  65. enabled: true,
  66. fiberPhase: 'pending',
  67. trust: 'builtin',
  68. failure: { stage: 'inject-pending', message: 'pending (waiting for service: neverReady)' },
  69. },
  70. {
  71. entryId: disabledId,
  72. moduleName: 'cordis:not-installed',
  73. enabled: false,
  74. fiberPhase: null,
  75. trust: 'builtin',
  76. disabledBy: 'composition',
  77. },
  78. ]))
  79. await ctx.loader.update(activeId, { disabled: true })
  80. expect((await inventory.list()).entries.find(entry => entry.entryId === activeId)).toEqual({
  81. entryId: activeId,
  82. moduleName: 'cordis:active',
  83. enabled: false,
  84. fiberPhase: null,
  85. trust: 'builtin',
  86. disabledBy: 'composition',
  87. })
  88. ctx.loader.remove(pendingId)
  89. expect((await inventory.list()).entries.some(entry => entry.entryId === pendingId)).toBe(false)
  90. })
  91. it('attributes rows to their bundle through the profile runtime and lists recorded failures and conflicts', async () => {
  92. const { ctx, inventory } = await harness()
  93. // A bare Loader assigns ids; without a root include they are also the tree-wide ids.
  94. const versioned = await ctx.loader.create({ name: 'cordis:active' })
  95. const bare = await ctx.loader.create({ name: 'cordis:active' })
  96. const off = await ctx.loader.create({ name: 'cordis:active', disabled: true })
  97. const origins = new Map<string, RowOrigin>([
  98. [versioned, { trust: 'external', stage: 'runtime', packageName: 'ext', version: '1.2.3' }],
  99. [bare, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
  100. [off, { trust: 'external', stage: 'runtime', packageName: 'ext' }],
  101. ['gone', { trust: 'external', stage: 'runtime', packageName: 'ext' }],
  102. ])
  103. ctx.provide('profileRuntime', {
  104. originOfEntry: (entry: { id: string }) => origins.get(entry.id),
  105. userDisables: (entry: { id: string }) => entry.id === off,
  106. layers: [{ packageName: 'late', version: '9.9.9' }],
  107. conflicts: [
  108. { rowId: 'tool', moduleName: 'late', layer: 'late', packageName: 'late', declaredBy: 'ext', message: 'row "tool" is already declared by ext' },
  109. { rowId: 'mine', moduleName: 'twice', layer: '/p/cordis.patch.yml', declaredBy: 'ext', message: 'row "mine" is already declared by ext' },
  110. { rowId: 'x', moduleName: 'gone/x', layer: 'gone', packageName: 'gone', declaredBy: 'gone', message: 'row "x" is declared twice by gone' },
  111. ],
  112. } as unknown as ProfileRuntime)
  113. await ctx.loader.create({ id: 'gone', name: './dsh-missing-fixture.mjs' } as Parameters<typeof ctx.loader.create>[0])
  114. await ctx.loader.create({ id: 'orphan', name: './dsh-missing-fixture.mjs' } as Parameters<typeof ctx.loader.create>[0])
  115. ctx.loader.resolve('gone').lastFailure = { stage: 'import', error: 'boom' }
  116. ctx.loader.resolve(bare).lastFailure = { stage: 'update', error: 'invalid config' }
  117. ctx.loader.resolve('orphan').lastFailure = { stage: 'import', error: 'lost' }
  118. const { entries } = await inventory.list()
  119. // Compared in id order: the Loader assigns ids, and one that comes out all
  120. // digits is listed first by the entry store regardless of creation order.
  121. const byId = (left: { entryId: string }, right: { entryId: string }): number => left.entryId.localeCompare(right.entryId)
  122. expect([...entries].sort(byId)).toEqual([
  123. { entryId: versioned, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext', version: '1.2.3' } },
  124. { entryId: bare, moduleName: 'cordis:active', enabled: true, fiberPhase: 'active', trust: 'external', package: { name: 'ext' }, failure: { stage: 'update', message: 'invalid config' } },
  125. { entryId: off, moduleName: 'cordis:active', enabled: false, fiberPhase: null, trust: 'external', package: { name: 'ext' }, disabledBy: 'user' },
  126. // A failed row the tree no longer holds is attributed through the runtime.
  127. { entryId: 'gone', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'external', package: { name: 'ext' }, failure: { stage: 'import', message: 'failed to import: boom' } },
  128. { entryId: 'orphan', moduleName: './dsh-missing-fixture.mjs', enabled: true, fiberPhase: 'failed', trust: 'builtin', failure: { stage: 'import', message: 'failed to import: lost' } },
  129. // A row the composition left out is listed from the runtime's conflicts, under the layer that lost.
  130. {
  131. entryId: 'conflict:late:tool', moduleName: 'late', enabled: true, fiberPhase: 'failed', trust: 'external',
  132. package: { name: 'late', version: '9.9.9' }, failure: { stage: 'conflict', message: 'row "tool" is already declared by ext' },
  133. },
  134. {
  135. entryId: 'conflict:/p/cordis.patch.yml:mine', moduleName: 'twice', enabled: true, fiberPhase: 'failed', trust: 'user',
  136. failure: { stage: 'conflict', message: 'row "mine" is already declared by ext' },
  137. },
  138. // A bundle the layer list no longer names keeps its package, without a version.
  139. {
  140. entryId: 'conflict:gone:x', moduleName: 'gone/x', enabled: true, fiberPhase: 'failed', trust: 'external',
  141. package: { name: 'gone' }, failure: { stage: 'conflict', message: 'row "x" is declared twice by gone' },
  142. },
  143. ].sort(byId))
  144. })
  145. it('attributes a row disabled through a group the user disabled to the user', async () => {
  146. const { ctx, inventory } = await harness()
  147. ctx.loader.builtins.group = Group
  148. const groupId = await ctx.loader.create({ name: 'cordis:group', group: true, config: [{ name: 'cordis:active' }] })
  149. const child = [...ctx.loader.entries()].find(entry => !entry.options.group && entry.parent.ctx.fiber.entry?.options.id === groupId)
  150. expect(child).toBeDefined()
  151. // The real runtime over a composition whose user layer disabled the group.
  152. const profile: Profile = { name: 'web', dir: '/p', layers: [], patchPath: '/p/cordis.patch.yml', patches: [], patchReload: 'live' }
  153. const stack: ComposedStack = {
  154. patches: [], layers: [], owners: new Map(), conflicts: [], skippedBundles: [], userDisabledRowIds: new Set([groupId]),
  155. }
  156. await ctx.plugin(ProfileRuntime, {
  157. profile, stack, installAnchor: '/app/package.json', loadProfile: () => profile, compose: () => stack, rootEntry: () => undefined,
  158. })
  159. await ctx.loader.update(groupId, { disabled: true })
  160. const listed = (await inventory.list()).entries.find(entry => entry.entryId === child?.id)
  161. expect(listed).toMatchObject({ enabled: false, disabledBy: 'user' })
  162. })
  163. it('carries each composed preset with root-fiber states mapped to phases', async () => {
  164. const { ctx, inventory } = await harness()
  165. ctx.provide('agentPresets', {
  166. compositionInventory: async () => [
  167. {
  168. id: 'standard',
  169. trust: 'system',
  170. name: '标准模式',
  171. isDefault: true,
  172. rows: [
  173. { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, fiberState: FiberState.ACTIVE },
  174. { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x' },
  175. ],
  176. },
  177. { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition file is missing', rows: [] },
  178. ],
  179. } as Partial<AgentPresets> as never)
  180. const snapshot = await inventory.list()
  181. expect(snapshot.agentPresets).toEqual([
  182. {
  183. id: 'standard',
  184. trust: 'system',
  185. name: '标准模式',
  186. isDefault: true,
  187. rows: [
  188. { entryId: 'alpha', moduleName: 'pkg-alpha', enabled: true, fiberPhase: 'active' },
  189. { entryId: null, moduleName: 'pkg-file', enabled: 'conditional', condition: 'x', fiberPhase: null },
  190. ],
  191. },
  192. { id: 'damaged', trust: 'user', isDefault: false, broken: 'the composition file is missing', rows: [] },
  193. ])
  194. })
  195. })