browser-plugin.spec.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184
  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 { SlotsService, 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. async 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. await ctx.plugin(SlotsService).await()
  65. ctx.slots.register({
  66. name: 'root', children: { 'conversation.input.dock': { kind: 'list', scope: 'session' } },
  67. } as never, (() => null) as never)
  68. ctx.provide('locale', new LocaleService(ctx))
  69. ctx.provide('sessions', {
  70. binding: (id: SessionId) => ({
  71. sessionId: id,
  72. session: { projections: { faceOf: (key: string) => ({
  73. getSnapshot: () => (key === 'goal' ? options.projection : undefined),
  74. subscribe: () => () => {},
  75. }) } },
  76. ctx,
  77. }),
  78. })
  79. const fiber = ctx.plugin({ inject: [...inject], apply })
  80. return {
  81. ctx,
  82. fiber,
  83. calls,
  84. entry: () => {
  85. const entry = ctx.slots.entries('conversation.input.dock')[0]
  86. if (entry === undefined) return undefined
  87. return {
  88. ...entry.options,
  89. locale: entry.locale,
  90. inject: entry.inject as unknown as ((sessionId: SessionId) => GoalBarActions) | undefined,
  91. }
  92. },
  93. }
  94. }
  95. describe('ui-goal browser plugin', () => {
  96. it('registers the GoalBar dock entry with the documented id and order', async () => {
  97. const b = await bench()
  98. await b.fiber.await()
  99. expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
  100. expect(b.entry()?.inject).toBeTypeOf('function')
  101. })
  102. it('verbs read the CAS ref from the current projected value at call time', async () => {
  103. const b = await bench({ projection: makeProjection(5) })
  104. await b.fiber.await()
  105. const verbs = b.entry()!.inject!(sid('s1'))
  106. expect(await verbs.onEdit('New objective')).toEqual({ ok: true })
  107. expect(await verbs.onPause()).toEqual({ ok: true })
  108. expect(await verbs.onResume()).toEqual({ ok: true })
  109. expect(await verbs.onClear()).toEqual({ ok: true })
  110. expect(b.calls.map(c => c.method)).toEqual(['goal.edit', 'goal.pause', 'goal.resume', 'goal.clear'])
  111. const ref = { id: 'g-1', revision: 5 }
  112. expect(b.calls[0]?.payload).toEqual({ sessionId: 's1', ref, objective: 'New objective' })
  113. expect(b.calls[1]?.payload).toEqual({ sessionId: 's1', ref })
  114. expect(b.calls[2]?.payload).toEqual({ sessionId: 's1', ref })
  115. expect(b.calls[3]?.payload).toEqual({ sessionId: 's1', ref })
  116. })
  117. it('a null or absent projection short-circuits every verb without touching the wire', async () => {
  118. for (const projection of [null, undefined]) {
  119. const b = await bench({ projection })
  120. await b.fiber.await()
  121. const verbs = b.entry()!.inject!(sid('s1'))
  122. for (const result of [await verbs.onEdit('x'), await verbs.onPause(), await verbs.onResume(), await verbs.onClear()]) {
  123. expect(result).toEqual({ ok: false, error: { code: 'no-current-goal', message: 'no current goal to mutate' } })
  124. }
  125. expect(b.calls).toHaveLength(0)
  126. }
  127. })
  128. it('maps a settled RPC error onto the inline-render shape', async () => {
  129. const b = await bench({ projection: makeProjection(), failWith: { code: 'internal', message: 'stale revision' } })
  130. await b.fiber.await()
  131. const verbs = b.entry()!.inject!(sid('s1'))
  132. expect(await verbs.onEdit('x')).toEqual({ ok: false, error: { code: 'internal', message: 'stale revision' } })
  133. })
  134. it('drops the dock entry when the plugin fiber unloads (HMR safety)', async () => {
  135. const b = await bench()
  136. await b.fiber.await()
  137. expect(b.entry()).toBeDefined()
  138. await b.fiber.dispose()
  139. expect(b.entry()).toBeUndefined()
  140. })
  141. })
  142. describe('GoalDock adapter', () => {
  143. it('renders the projected goal snapshot and nothing for absent/null', () => {
  144. const projection = makeProjection()
  145. const useProjection = vi.fn(() => projection)
  146. const actions: GoalBarActions = {
  147. onEdit: () => Promise.resolve({ ok: true }),
  148. onPause: () => Promise.resolve({ ok: true }),
  149. onResume: () => Promise.resolve({ ok: true }),
  150. onClear: () => Promise.resolve({ ok: true }),
  151. }
  152. const t = makeTranslate(zh, commonZh)
  153. const dockProps = (up: () => GoalProjection | null | undefined) =>
  154. ({ useProjection: up, ...actions, t }) as unknown as Parameters<typeof GoalDock>[0]
  155. const shown = render(<GoalDock {...dockProps(useProjection)} />)
  156. expect(shown.getByText('Ship it')).toBeTruthy()
  157. cleanup()
  158. const empty = render(<GoalDock {...dockProps(() => null)} />)
  159. expect(empty.container.firstChild).toBeNull()
  160. cleanup()
  161. const absent = render(<GoalDock {...dockProps(() => undefined)} />)
  162. expect(absent.container.firstChild).toBeNull()
  163. })
  164. })
  165. describe('ui-goal node half', () => {
  166. // The invariant companion is mounted by the vitest-wide invariant host on
  167. // every Context this suite creates; its registration is covered there.
  168. it('the node apply is an inert loader seat', () => {
  169. expect(() => { nodeApply() }).not.toThrow()
  170. })
  171. })