client-bundle.client.spec.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. // @vitest-environment jsdom
  2. /**
  3. * Real tsdown artifact shape: lib/client.js hands off through
  4. * window.__ModuleLoader__.load, resolves externals through the injected
  5. * require, returns the exports (apply + inject), and a mounted apply
  6. * registers the view tab into a real SlotRegistry ring. Skips when dist/ is
  7. * not built (`pnpm --filter @deepseek-ai/dsh-client-ui-trajectory bundle`).
  8. */
  9. import { readFileSync } from 'node:fs'
  10. import { resolve } from 'node:path'
  11. import { Context } from '@deepseek-ai/cordis'
  12. import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
  13. import { afterEach, describe, expect, it } from 'vitest'
  14. import { UiConversation } from '@deepseek-ai/dsh-client-ui-conversation/client'
  15. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  16. const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
  17. interface Handoff { id: string; factory: (require: (spec: string) => unknown) => Record<string, unknown> }
  18. type Win = { __ModuleLoader__?: { load(h: Handoff): void } }
  19. function readBundle(): string | undefined {
  20. try {
  21. // import.meta.url is http-scheme in the jsdom pool; vitest runs from the
  22. // repo root, so resolve the artifact repo-relatively instead.
  23. return readFileSync(resolve('packages/client/ui-trajectory/lib/client.js'), 'utf8')
  24. } catch {
  25. return undefined
  26. }
  27. }
  28. afterEach(() => {
  29. delete (window as Win).__ModuleLoader__
  30. for (const el of document.querySelectorAll('style')) el.remove()
  31. })
  32. describe('tsdown client artifact', () => {
  33. const code = readBundle()
  34. async function loadArtifact() {
  35. let handoff: Handoff | undefined
  36. ;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
  37. // The implied-eval ban targets accidental string execution, not this
  38. // deliberate built-bundle fixture running in the window scope.
  39. // oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
  40. new Function(code!)()
  41. expect(handoff).toBeDefined()
  42. const modules = new Map<string, unknown>([
  43. ['react', await import('react')],
  44. ['react/jsx-runtime', await import('react/jsx-runtime')],
  45. ['react-dom', await import('react-dom')],
  46. ['@deepseek-ai/dsh-client-store', await import('@deepseek-ai/dsh-client-store')],
  47. ['@deepseek-ai/dsh-client-ui-conversation/client', await import('@deepseek-ai/dsh-client-ui-conversation/client')],
  48. ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
  49. ])
  50. const exports = handoff!.factory((spec) => {
  51. if (!modules.has(spec)) throw new Error(`unexpected require: ${spec}`)
  52. return modules.get(spec)
  53. })
  54. return { handoff: handoff!, exports }
  55. }
  56. it.skipIf(code === undefined)('hands off with the manifest id and a DI-require factory', async () => {
  57. const { handoff, exports } = await loadArtifact()
  58. expect(handoff.id).toBe(PLUGIN_ID)
  59. expect(exports.apply).toBeTypeOf('function')
  60. expect(exports.inject).toEqual([
  61. 'slots', 'sessions', 'uiSession', 'uiConversation', 'locale',
  62. ])
  63. })
  64. it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
  65. const { exports } = await loadArtifact()
  66. const ctx = new Context()
  67. const slots = new SlotRegistry(ctx)
  68. ctx.provide('uiSession', { provide: () => () => {} } as never)
  69. // The conversation entry's role: the ring must be declared before riders land.
  70. slots.register({
  71. name: 'root',
  72. children: { 'conversation.view': { kind: 'list', scope: 'session' } },
  73. }, (_p: { renderSlot?: unknown }) => null)
  74. // Paging is session-owned; this registration-only probe never renders the
  75. // entry, so the binding stays deliberately empty. The locale plugin backs
  76. // the locale-aware view tab label (its settings scope needs a connection
  77. // handle and the Host-facing settings/remote seams).
  78. const sessions = { binding: () => undefined }
  79. ctx.provide('sessions', sessions)
  80. const uiConversation = new UiConversation(ctx, sessions as never)
  81. const { events, views } = uiConversation
  82. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never)
  83. ctx.provide('remote', { $on: () => () => {} } as never)
  84. ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never)
  85. const locale = await import('@deepseek-ai/dsh-client-locale/client')
  86. ctx.plugin({ inject: [...locale.inject], apply: locale.apply })
  87. const fiber = ctx.plugin(exports as { apply: (ctx: Context) => void })
  88. await fiber.await()
  89. expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
  90. expect(events.entries().length).toBeGreaterThan(0)
  91. expect(views.entries()).toHaveLength(1)
  92. await fiber.dispose()
  93. expect(slots.entries('conversation.view')).toHaveLength(0)
  94. expect(events.entries()).toEqual([])
  95. expect(views.entries()).toEqual([])
  96. })
  97. it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {
  98. await loadArtifact()
  99. const tags = document.querySelectorAll(`style[data-plugin=${JSON.stringify(PLUGIN_ID)}]`)
  100. expect(tags.length).toBeGreaterThan(0)
  101. })
  102. })