client-bundle.spec.ts 3.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  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 export surface (apply + inject), and a mounted apply
  6. * registers the view tab into a real SlotsService 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 'cordis'
  12. import { afterEach, describe, expect, it } from 'vitest'
  13. import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
  14. const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory'
  15. interface Handoff { id: string; factory: (require: (spec: string) => unknown) => Record<string, unknown> }
  16. type Win = { __ModuleLoader__?: { load(h: Handoff): void } }
  17. function readBundle(): string | undefined {
  18. try {
  19. // import.meta.url is http-scheme in the jsdom pool; vitest runs from the
  20. // repo root, so resolve the artifact repo-relatively instead.
  21. return readFileSync(resolve('packages/client/ui-trajectory/lib/client.js'), 'utf8')
  22. } catch {
  23. return undefined
  24. }
  25. }
  26. afterEach(() => {
  27. delete (window as Win).__ModuleLoader__
  28. for (const el of document.querySelectorAll('style')) el.remove()
  29. })
  30. describe('tsdown client artifact', () => {
  31. const code = readBundle()
  32. async function loadArtifact() {
  33. let handoff: Handoff | undefined
  34. ;(window as Win).__ModuleLoader__ = { load: (h) => { handoff = h } }
  35. // Same execution form the loader uses (inline script eval, window scope) —
  36. // the implied-eval ban targets accidental string execution, not this
  37. // deliberate bundle-execution fixture.
  38. // oxlint-disable-next-line typescript/no-implied-eval, typescript/no-unsafe-call
  39. new Function(code!)()
  40. expect(handoff).toBeDefined()
  41. const modules = new Map<string, unknown>([
  42. ['react', await import('react')],
  43. ['react/jsx-runtime', await import('react/jsx-runtime')],
  44. ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
  45. ])
  46. const surface = handoff!.factory((spec) => {
  47. if (!modules.has(spec)) throw new Error(`unexpected require: ${spec}`)
  48. return modules.get(spec)
  49. })
  50. return { handoff: handoff!, surface }
  51. }
  52. it.skipIf(code === undefined)('hands off with the manifest id and a DI-require factory', async () => {
  53. const { handoff, surface } = await loadArtifact()
  54. expect(handoff.id).toBe(PLUGIN_ID)
  55. expect(surface.apply).toBeTypeOf('function')
  56. expect(surface.inject).toEqual(['slots', 'conversation', 'sessionHistory'])
  57. })
  58. it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => {
  59. const { surface } = await loadArtifact()
  60. const ctx = new Context()
  61. const slots = new SlotsService(ctx)
  62. // The conversation entry's role: the ring must be declared before riders land.
  63. slots.register({
  64. name: 'root',
  65. children: { 'conversation.view': { kind: 'list', scope: 'session' } },
  66. }, (_p: { renderSlot?: unknown }) => null)
  67. // The plugin injects 'conversation' as an ordering edge and
  68. // 'sessionHistory' for its per-session history source; this bench
  69. // supplies both.
  70. ctx.provide('conversation', {})
  71. ctx.provide('sessionHistory', {})
  72. const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void })
  73. await fiber.await()
  74. expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory'])
  75. await fiber.dispose()
  76. expect(slots.entries('conversation.view')).toHaveLength(0)
  77. })
  78. it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => {
  79. await loadArtifact()
  80. const tags = document.querySelectorAll(`style[data-plugin=${JSON.stringify(PLUGIN_ID)}]`)
  81. expect(tags.length).toBeGreaterThan(0)
  82. })
  83. })