browser-plugin.spec.tsx 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. // @vitest-environment jsdom
  2. /**
  3. * ui-goal browser half on a real cordis Context with fake slots/connection/
  4. * sessions faces: the plugin registers the GoalBar dock entry at
  5. * conversation.input.dock, the inject face's three verbs read the CAS ref
  6. * from the session's CURRENT projected value at call time (no fence — the
  7. * RPC's compare-and-set is the guard), a missing projection short-circuits
  8. * to the no-current-goal error without touching the wire, and RPC errors
  9. * map onto the inline-render result shape. Registration disposal rides the
  10. * plugin fiber (HMR safety). The node half and the invariant companion are
  11. * exercised over the same Context.
  12. */
  13. import { Context } from 'cordis'
  14. import { describe, expect, it, vi } from 'vitest'
  15. import { cleanup, render } from '@testing-library/react'
  16. import { afterEach } from 'vitest'
  17. import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  18. import type { GoalProjection } from '@deepseek-ai/dsh-goal/client'
  19. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  20. import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
  21. import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
  22. import type { GoalBarActions } from '../src/client/slots.ts'
  23. import { apply, inject } from '../src/client/index.ts'
  24. import { GoalDock } from '../src/client/GoalBar.tsx'
  25. import { zh } from '../src/client/locales.ts'
  26. import { apply as nodeApply } from '../src/index.ts'
  27. afterEach(cleanup)
  28. const sid = (k: string): SessionId => k as SessionId
  29. function makeProjection(revision = 3): GoalProjection {
  30. return {
  31. goal: {
  32. id: 'g-1' as GoalProjection['goal']['id'],
  33. revision,
  34. objective: 'Ship it',
  35. phase: 'active',
  36. maxGoalRounds: 8,
  37. },
  38. roundsStarted: 1,
  39. createdAt: 10,
  40. updatedAt: 20,
  41. }
  42. }
  43. /** Boot the plugin over fake faces; goals verbs record payloads and answer per the script. */
  44. function bench(options: { projection?: GoalProjection | null | undefined; failWith?: { code: string; message: string } } = {}) {
  45. const ctx = new Context()
  46. const calls: { method: string; payload: unknown }[] = []
  47. function answer<T>(method: string, value: T) {
  48. return (payload: unknown) => {
  49. calls.push({ method, payload })
  50. return Promise.resolve({
  51. result: options.failWith === undefined
  52. ? { ok: true as const, value }
  53. : { ok: false as const, error: { ...options.failWith, details: {} } },
  54. })
  55. }
  56. }
  57. const ref = { id: 'g-1', revision: 3 }
  58. ctx.provide('connection', { api: { goals: {
  59. edit: answer('goal.edit', { ref }),
  60. pause: answer('goal.pause', { ref }),
  61. resume: answer('goal.resume', { ref }),
  62. clear: answer('goal.clear', { cleared: true as const }),
  63. } } })
  64. const entries = new Map<string, { id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }>()
  65. ctx.provide('slots', {
  66. register(reg: { name: string; id?: string; order?: number; locale?: string; inject?: (sessionId: SessionId) => GoalBarActions }) {
  67. entries.set(reg.name, reg)
  68. return () => { entries.delete(reg.name) }
  69. },
  70. })
  71. ctx.provide('conversation', {})
  72. ctx.provide('locale', new LocaleService(ctx))
  73. ctx.provide('sessions', {
  74. binding: (id: SessionId) => ({
  75. sessionId: id,
  76. session: { projections: { faceOf: (key: string) => ({
  77. getSnapshot: () => (key === 'goal' ? options.projection : undefined),
  78. subscribe: () => () => {},
  79. }) } },
  80. ctx,
  81. }),
  82. })
  83. const fiber = ctx.plugin({ inject: [...inject], apply })
  84. return {
  85. ctx,
  86. fiber,
  87. calls,
  88. entry: () => entries.get('conversation.input.dock'),
  89. }
  90. }
  91. describe('ui-goal browser plugin', () => {
  92. it('registers the GoalBar dock entry with the documented id and order', async () => {
  93. const b = bench()
  94. await b.fiber.await()
  95. expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
  96. expect(b.entry()?.inject).toBeTypeOf('function')
  97. })
  98. it('verbs read the CAS ref from the current projected value at call time', async () => {
  99. const b = bench({ projection: makeProjection(5) })
  100. await b.fiber.await()
  101. const verbs = b.entry()!.inject!(sid('s1'))
  102. expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
  103. expect(await verbs.onPause()).toEqual({ ok: true })
  104. expect(await verbs.onResume()).toEqual({ ok: true })
  105. expect(await verbs.onClear()).toEqual({ ok: true })
  106. expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear'])
  107. const ref = { id: 'g-1', revision: 5 }
  108. expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' })
  109. expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref })
  110. expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref })
  111. expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref })
  112. })
  113. it('a null or absent projection short-circuits every verb without touching the wire', async () => {
  114. for (const projection of [null, undefined]) {
  115. const b = bench({ projection })
  116. await b.fiber.await()
  117. const verbs = b.entry()!.inject!(sid('s1'))
  118. for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
  119. expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } })
  120. }
  121. expect(b.calls).toHaveLength(0)
  122. }
  123. })
  124. it('maps a settled RPC error onto the inline-render shape', async () => {
  125. const b = bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
  126. await b.fiber.await()
  127. const verbs = b.entry()!.inject!(sid('s1'))
  128. expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
  129. })
  130. it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
  131. const b = bench()
  132. await b.fiber.await()
  133. expect(b.entry()).toBeDefined()
  134. await b.fiber.dispose()
  135. expect(b.entry()).toBeUndefined()
  136. })
  137. })
  138. describe('GoalDock adapter', () => {
  139. it('renders the projected goal snapshot and nothing for absent/null', () => {
  140. const projection = makeProjection()
  141. const useProjection = vi.fn(() => projection)
  142. const actions: GoalBarActions = {
  143. onEdit: () => Promise.resolve({ ok: true }),
  144. onPause: () => Promise.resolve({ ok: true }),
  145. onResume: () => Promise.resolve({ ok: true }),
  146. onClear: () => Promise.resolve({ ok: true }),
  147. }
  148. const t = makeTranslate(zh, commonZh)
  149. const dockProps = (up: () => GoalProjection | null | undefined) =>
  150. ({ useProjection: up, ...actions, t }) as unknown as Parameters<typeof GoalDock>[0]
  151. const shown = render(<GoalDock {...dockProps(useProjection)} />)
  152. expect(shown.getByText('Ship it')).toBeTruthy()
  153. cleanup()
  154. const empty = render(<GoalDock {...dockProps(() => null)} />)
  155. expect(empty.container.firstChild).toBeNull()
  156. cleanup()
  157. const absent = render(<GoalDock {...dockProps(() => undefined)} />)
  158. expect(absent.container.firstChild).toBeNull()
  159. })
  160. })
  161. describe('ui-goal node half', () => {
  162. // The invariant companion is mounted by the vitest-wide invariant host on
  163. // every Context this suite creates; its registration is covered there.
  164. it('the node apply is an inert loader seat', () => {
  165. expect(() => { nodeApply() }).not.toThrow()
  166. })
  167. })