web-plugins.spec.ts 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. /**
  2. * mountWebPlugins unit coverage (keyless; the real nine-package walk is the
  3. * built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
  4. * creation with idempotent reuse, the fiber-less fail-loud sweep, and the
  5. * resolver seam — is exercised against a stubbed loader service so it runs
  6. * without built lib/ artifacts.
  7. */
  8. import { Context } from 'cordis'
  9. import { afterEach, describe, expect, it } from 'vitest'
  10. import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
  11. interface FakeEntry {
  12. options: { name: string }
  13. fiber?: unknown
  14. disabled: boolean
  15. }
  16. /** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
  17. class FakeLoader {
  18. readonly created: string[] = []
  19. awaited = 0
  20. constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
  21. entries(): Iterable<FakeEntry> {
  22. return this.entriesList
  23. }
  24. async create(options: { name: string }): Promise<void> {
  25. this.created.push(options.name)
  26. this.onCreate?.(options.name)
  27. }
  28. async await(): Promise<void> {
  29. this.awaited += 1
  30. }
  31. }
  32. let root: Context | undefined
  33. afterEach(async () => {
  34. await root?.fiber.dispose()
  35. root = undefined
  36. })
  37. function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
  38. root = new Context()
  39. const loader = new FakeLoader(entriesList, onCreate)
  40. root.reflect.provide('loader', loader)
  41. return { ctx: root, loader }
  42. }
  43. describe('mountWebPlugins (stubbed loader)', () => {
  44. it('creates one entry per UI plugin, awaits the tree, and returns the loader view + resolver', async () => {
  45. const entriesList: FakeEntry[] = []
  46. const { ctx, loader } = withLoader(entriesList, (name) => {
  47. entriesList.push({ options: { name }, fiber: {}, disabled: false })
  48. })
  49. const mounted = await mountWebPlugins(ctx)
  50. expect(loader.created).toEqual([...WEB_UI_PLUGINS])
  51. expect(loader.awaited).toBe(1)
  52. expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...WEB_UI_PLUGINS])
  53. // The resolver resolves this package's own manifest through real module resolution.
  54. expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
  55. expect(ctx.baseUrl).toBeDefined()
  56. })
  57. it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
  58. const preexisting: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: {}, disabled: false }))
  59. const { ctx, loader } = withLoader(preexisting)
  60. await mountWebPlugins(ctx)
  61. expect(loader.created).toEqual([])
  62. })
  63. it('throws listing every fiber-less entry (silent import failure must not drop a UI plugin)', async () => {
  64. const entriesList: FakeEntry[] = []
  65. const { ctx } = withLoader(entriesList, (name) => {
  66. // First two load; the rest stay fiber-less (import failed silently).
  67. entriesList.push({ options: { name }, fiber: entriesList.length < 2 ? {} : undefined, disabled: false })
  68. })
  69. await expect(mountWebPlugins(ctx)).rejects.toThrow(/UI plugin\(s\) failed to load: .*dsh-client-ui-theme/)
  70. })
  71. it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
  72. const entriesList: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
  73. const { ctx } = withLoader(entriesList)
  74. await expect(mountWebPlugins(ctx)).resolves.toBeDefined()
  75. })
  76. it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
  77. root = new Context()
  78. // Environment-dependent outcome: with built lib/ the nine imports load
  79. // and the mount resolves; without them every entry stays fiber-less and
  80. // the sweep throws its loud list. Either way the branch under test is the
  81. // Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
  82. // expect()'s formatting path (pretty-format probes throw on them).
  83. // Plain string: the success sentinel and error text share one channel.
  84. let outcome: string
  85. try {
  86. await mountWebPlugins(root)
  87. outcome = 'resolved'
  88. } catch (error) {
  89. outcome = error instanceof Error ? error.message : String(error)
  90. }
  91. expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
  92. expect(root.get('loader') !== undefined).toBe(true)
  93. }, 30_000) // built-env run imports nine real plugin packages through the Loader
  94. it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
  95. const entriesList: FakeEntry[] = []
  96. const { ctx } = withLoader(entriesList, (name) => {
  97. entriesList.push({ options: { name }, fiber: {}, disabled: false })
  98. })
  99. ctx.baseUrl = 'file:///caller/anchor/'
  100. await mountWebPlugins(ctx)
  101. expect(ctx.baseUrl).toBe('file:///caller/anchor/')
  102. })
  103. })