browser-plugin.client.spec.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * Browser-half lifecycle over the real SlotRegistry: the dictionary and
  3. * header-slot registrations with fiber teardown proving removal (HMR safety)
  4. * and the injected controller face.
  5. */
  6. import { Context } from '@deepseek-ai/cordis'
  7. import { afterEach, describe, expect, it, vi } from 'vitest'
  8. import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
  9. import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
  10. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
  11. import { apply, inject, type OpenInAppActionInjected } from '../src/client/index.ts'
  12. import { apply as nodeApply } from '../src/index.ts'
  13. import { OpenInAppAction } from '../src/client/OpenInAppAction.tsx'
  14. import { en, NS, zh } from '../src/client/locales.ts'
  15. afterEach(() => {
  16. vi.unstubAllGlobals()
  17. })
  18. /** Boot the browser half over a real slot tree that declares the header list. */
  19. async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
  20. const ctx = new Context()
  21. await ctx.plugin(SlotRegistry).await()
  22. ctx.slots.register({
  23. name: 'root',
  24. children: {
  25. 'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
  26. },
  27. } as never, () => null)
  28. ctx.provide('sessions', {})
  29. ctx.provide('locale', new LocaleRuntime(ctx))
  30. const fiber = ctx.plugin({ inject: [...inject], apply })
  31. await fiber.await()
  32. return { ctx, fiber }
  33. }
  34. function headerEntryIds(ctx: Context): (string | undefined)[] {
  35. return ctx.slots.entries('conversation.session.header.utilities').map(entry => entry.options.id)
  36. }
  37. describe('open-in-app browser half', () => {
  38. it('declares the services it binds', () => {
  39. expect(inject).toEqual(['sessions', 'slots', 'locale'])
  40. })
  41. it('registers the header split button, and fiber teardown removes it (HMR safety)', async () => {
  42. vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
  43. const { ctx, fiber } = await bench()
  44. const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
  45. expect(entry?.component).toBe(OpenInAppAction)
  46. expect(entry?.options).toMatchObject({ id: 'open-in-app' })
  47. await fiber.dispose()
  48. expect(headerEntryIds(ctx)).not.toContain('open-in-app')
  49. })
  50. it('injects the controller face: availability sources, launch carrier, choice, and icon URLs', async () => {
  51. const fetcher = vi.fn(async (input: string | URL, init?: RequestInit) => {
  52. void init
  53. const url = String(input)
  54. if (url.includes('/open-in-app/apps')) {
  55. return new Response(JSON.stringify({ apps: ['finder', 'cursor', 7] }), { status: 200 })
  56. }
  57. return new Response(JSON.stringify({ ok: true }), { status: 200 })
  58. })
  59. vi.stubGlobal('fetch', fetcher)
  60. const { ctx, fiber } = await bench()
  61. const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
  62. const injected = (entry?.inject as unknown as () => OpenInAppActionInjected)()
  63. await vi.waitFor(() => {
  64. expect(injected.hooks.openInAppApps.getSnapshot()).toEqual(['finder', 'cursor'])
  65. })
  66. expect(injected.iconUrl('cursor')).toBe('/open-in-app/icon/cursor')
  67. injected.choose('cursor')
  68. expect(injected.hooks.openInAppChoice.getSnapshot()).toBe('cursor')
  69. await injected.launch('cursor', '/w/dir')
  70. const openCall = fetcher.mock.calls.find(call => String(call[0]).includes('/open-in-app/open'))
  71. expect(openCall?.[1]).toMatchObject({
  72. method: 'POST',
  73. headers: { 'content-type': 'application/json' },
  74. body: JSON.stringify({ app: 'cursor', path: '/w/dir' }),
  75. })
  76. await fiber.dispose()
  77. })
  78. it('publishes an empty availability list when the host read fails, and launches reject on HTTP errors', async () => {
  79. vi.stubGlobal('fetch', vi.fn(async (input: string | URL) => {
  80. if (String(input).includes('/open-in-app/apps')) throw new Error('down')
  81. return new Response('', { status: 502 })
  82. }))
  83. const { ctx, fiber } = await bench()
  84. const entry = ctx.slots.entries('conversation.session.header.utilities')[0]
  85. const injected = (entry?.inject as unknown as () => OpenInAppActionInjected)()
  86. await vi.waitFor(() => {
  87. expect(injected.hooks.openInAppApps.getSnapshot()).toEqual([])
  88. })
  89. await expect(injected.launch('finder', '/w/dir')).rejects.toThrow('open failed: HTTP 502')
  90. await fiber.dispose()
  91. })
  92. it('registers both dictionaries under its own namespace and releases them with the fiber', async () => {
  93. vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ apps: [] }), { status: 200 })))
  94. const { ctx, fiber } = await bench()
  95. ctx.locale.setLocale('zh')
  96. const translate = ctx.locale.bind(NS)
  97. expect(translate('menu.aria')).toBe(zh['menu.aria'])
  98. ctx.locale.setLocale('en')
  99. expect(translate('menu.aria')).toBe(en['menu.aria'])
  100. await fiber.dispose()
  101. expect(translate('menu.aria')).not.toBe(en['menu.aria'])
  102. })
  103. it('keeps the English dictionary key-identical to the Chinese source of truth', () => {
  104. expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort())
  105. })
  106. })
  107. describe('ui-open-in-app node half', () => {
  108. it('the node apply is an inert loader seat', () => {
  109. expect(() => { nodeApply() }).not.toThrow()
  110. })
  111. })