ui-renderer.client.spec.tsx 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. // @vitest-environment jsdom
  2. import { afterEach, describe, expect, it, vi } from 'vitest'
  3. import { act, cleanup } from '@testing-library/react'
  4. import { Context } from '@deepseek-ai/cordis'
  5. import { SlotRegistry } from '../src/client/registry.ts'
  6. import type { SlotScopeAdapter, StandardSourceBinding } from '../src/client/index.ts'
  7. import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-renderer'
  8. import * as UiRenderer from '../src/client/index.ts'
  9. const mounted: (() => void)[] = []
  10. afterEach(() => {
  11. act(() => { for (const unmount of mounted.splice(0)) unmount() })
  12. vi.restoreAllMocks()
  13. cleanup()
  14. document.body.innerHTML = ''
  15. })
  16. const stabilize = async (fn: () => void | Promise<void>): Promise<void> => {
  17. await act(async () => { await fn() })
  18. }
  19. async function bench() {
  20. const ctx = new Context()
  21. const fiber = ctx.plugin({ inject: [...UiRenderer.inject], apply: UiRenderer.apply })
  22. await fiber.await()
  23. const slots = ctx.get('slots') as SlotRegistry
  24. const absentBinding: StandardSourceBinding = {
  25. key: undefined,
  26. hooks: {},
  27. keyedHooks: {},
  28. props: {},
  29. }
  30. const current = {
  31. getSnapshot: () => absentBinding,
  32. subscribe: () => () => {},
  33. }
  34. const adapter: SlotScopeAdapter = {
  35. current,
  36. bindingSource: () => current,
  37. }
  38. slots.installScope('session', adapter)
  39. return { ctx, slots, fiber }
  40. }
  41. function container(): HTMLElement {
  42. const el = document.createElement('div')
  43. document.body.append(el)
  44. return el
  45. }
  46. describe('UI renderer plugin', () => {
  47. it('provides no host-side behavior', () => {
  48. expect(() => { nodeApply() }).not.toThrow()
  49. })
  50. it('installs the renderer and mounts the assembled application', async () => {
  51. const { ctx, slots } = await bench()
  52. slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
  53. const renderer = ctx.get('uiRenderer')
  54. expect(renderer).toBeDefined()
  55. const el = container()
  56. act(() => { mounted.push(renderer!.mount(el)) })
  57. expect(el.querySelector('[data-testid="root-probe"]')).toBeTruthy()
  58. })
  59. it('hydrates the boot page before switching to the assembled application', async () => {
  60. const { ctx, slots } = await bench()
  61. slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
  62. const el = container()
  63. el.innerHTML = '<div class="boot" data-dsh-boot=""><div><div class="spinner" data-dsh-boot-spinner="" style="--dsh-boot-arc: 180deg"></div><div>Loading plugins…</div></div></div>'
  64. const boot = el.firstElementChild
  65. const observer = new MutationObserver(() => {})
  66. observer.observe(el, { childList: true, subtree: true })
  67. const error = vi.spyOn(console, 'error').mockImplementation(() => {})
  68. act(() => { mounted.push(ctx.get('uiRenderer')!.mount(el)) })
  69. const records = observer.takeRecords()
  70. observer.disconnect()
  71. expect(error).not.toHaveBeenCalled()
  72. error.mockRestore()
  73. expect(el.querySelector('[data-testid="root-probe"]')).toBeTruthy()
  74. expect(records.some(record => record.target === boot)).toBe(false)
  75. })
  76. it('returns an unmount disposer', async () => {
  77. const { ctx, slots } = await bench()
  78. slots.register({ name: 'root' }, () => <div data-testid="root-probe" />)
  79. const el = container()
  80. let unmount: () => void = () => {}
  81. act(() => { unmount = ctx.get('uiRenderer')!.mount(el) })
  82. act(() => { unmount() })
  83. expect(el.querySelector('[data-testid="root-probe"]')).toBeNull()
  84. })
  85. it('retracts the service and renderer with its fiber', async () => {
  86. const { ctx, slots, fiber } = await bench()
  87. await stabilize(() => fiber.dispose())
  88. expect(ctx.get('uiRenderer')).toBeUndefined()
  89. expect(() => slots.renderSlot('root', {})).toThrow('not installed')
  90. })
  91. })