1
0

browser-plugin.spec.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. /**
  2. * ui-permission browser half on a real cordis Context with fake command/
  3. * sessions faces: the plugin hangs the /permission popup decoration on the
  4. * host command; options flatten the session's permissions projection with
  5. * the current value active and `custom` excluded; availability follows the
  6. * projection key's presence; a pick submits the /permission line through
  7. * Session.command and surfaces rejection/unmatched as thrown errors; fiber
  8. * disposal removes the contribution (HMR safety). The same plugin registers
  9. * its Settings row and invalidates that row on host settings changes.
  10. */
  11. import { Context } from 'cordis'
  12. import { describe, expect, it } from 'vitest'
  13. import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client'
  14. import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
  15. import type { CommandDecoration } from '@deepseek-ai/dsh-client-ui-command/client'
  16. import type { PermissionSelect } from '@deepseek-ai/dsh-permission/client'
  17. import {
  18. PermissionRow, type PermissionRowInjected,
  19. } from '../src/client/PermissionRow.tsx'
  20. import { apply, inject } from '../src/client/index.ts'
  21. import { accessEn } from '../src/client/locales.ts'
  22. const sid = (k: string): SessionId => k as SessionId
  23. const SELECT: PermissionSelect = {
  24. options: [
  25. { value: 'read-only', name: 'read-only', description: 'Reads only.' },
  26. { value: 'workspace-write', name: 'workspace-write' },
  27. { value: 'danger-full-access', name: 'danger-full-access' },
  28. ],
  29. currentValue: 'workspace-write',
  30. }
  31. async function bench() {
  32. const ctx = new Context()
  33. await ctx.plugin(SlotsService)
  34. const locale = new LocaleService(ctx)
  35. locale.setLocale('en')
  36. ctx.provide('locale', locale)
  37. ctx.slots.register({
  38. name: 'root',
  39. children: {
  40. 'settings.general.item': { kind: 'list', scope: 'root' },
  41. },
  42. } as never, () => null)
  43. ctx.provide('connection', {
  44. api: {
  45. settings: {
  46. describe: () => Promise.resolve({
  47. rpcId: 'describe',
  48. result: { ok: true as const, value: { writable: true, hasDocument: false, namespaces: [] } },
  49. }),
  50. mutate: () => Promise.reject(new Error('settings mutation is not exercised')),
  51. },
  52. },
  53. } as never)
  54. let decoration: CommandDecoration | undefined
  55. ctx.provide('command', {
  56. decorate(c: CommandDecoration) {
  57. decoration = c
  58. return () => { decoration = undefined }
  59. },
  60. })
  61. const values = new Map<SessionId, PermissionSelect>()
  62. const commands: string[] = []
  63. let commandResult: { ok: boolean; matched?: boolean } = { ok: true, matched: true }
  64. const session = (id: SessionId) => ({
  65. projections: {
  66. faceOf: (key: string) => ({
  67. getSnapshot: () => (key === 'permissions' ? values.get(id) : undefined),
  68. subscribe: () => () => {},
  69. }),
  70. },
  71. command: (line: string) => {
  72. commands.push(line)
  73. return Promise.resolve(commandResult.ok
  74. ? { ok: true as const, value: { matched: commandResult.matched ?? true } }
  75. : { ok: false as const, error: { code: 'internal', message: 'boom' } })
  76. },
  77. })
  78. ctx.provide('sessions', {
  79. binding: (id: SessionId) => (values.has(id) ? { sessionId: id, session: session(id) } : undefined),
  80. })
  81. const fiber = ctx.plugin({ inject: [...inject], apply })
  82. await fiber.await()
  83. return {
  84. ctx, fiber, values, commands,
  85. setResult: (r: { ok: boolean; matched?: boolean }) => { commandResult = r },
  86. decoration: () => decoration,
  87. permissionRow: () => ctx.slots.entries('settings.general.item')
  88. .find(entry => entry.component === PermissionRow),
  89. }
  90. }
  91. describe('ui-permission browser plugin', () => {
  92. it('hangs the /permission popup decoration on the host command', async () => {
  93. const b = await bench()
  94. const c = b.decoration()!
  95. expect(c.name).toBe('permission')
  96. expect(c.ui.kind).toBe('popupSelect')
  97. const row = b.permissionRow()!
  98. expect(row.options).toEqual({ id: 'permission', order: -20 })
  99. const injected = row.inject?.() as PermissionRowInjected | undefined
  100. expect(injected?.hooks.permission).toBeDefined()
  101. expect(typeof injected?.load).toBe('function')
  102. expect(typeof injected?.select).toBe('function')
  103. await injected!.load()
  104. await injected!.select('read-only')
  105. })
  106. it('availability follows the projection key; options mark the current value active and exclude custom', async () => {
  107. const b = await bench()
  108. const c = b.decoration()!
  109. const proj = { sessionId: sid('s1') }
  110. expect(c.available(proj)).toBe(false)
  111. b.values.set(sid('s1'), { ...SELECT, options: [...SELECT.options, { value: 'custom', name: 'Custom' }], currentValue: 'custom' })
  112. expect(c.available(proj)).toBe(true)
  113. const options = await c.ui.options(proj, new AbortController().signal)
  114. expect(options.map(option => option.id)).toEqual(['read-only', 'workspace-write', 'danger-full-access'])
  115. expect(options.every(option => option.active !== true)).toBe(true)
  116. b.values.set(sid('s1'), SELECT)
  117. const again = await c.ui.options(proj, new AbortController().signal)
  118. expect(again.find(option => option.id === 'workspace-write')?.active).toBe(true)
  119. expect(again.find(option => option.id === 'read-only')?.detail).toBe('Reads only.')
  120. // Kebab-case names title-case; non-kebab host-configured names pass through.
  121. expect(again.map(option => option.label)).toEqual(['Read Only', 'Workspace Write', 'Full access'])
  122. expect(again.find(option => option.id === 'danger-full-access')?.confirmation).toEqual({
  123. title: 'Enable Full access?',
  124. description: accessEn['confirm.description'],
  125. acknowledgeLabel: 'I understand the risks and want to continue',
  126. cancelLabel: 'Cancel',
  127. confirmLabel: 'Enable Full access',
  128. })
  129. b.values.set(sid('s1'), { ...SELECT, options: [{ value: 'plain', name: 'Ask Every Time' }] })
  130. const passthrough = await c.ui.options(proj, new AbortController().signal)
  131. expect(passthrough[0]?.label).toBe('Ask Every Time')
  132. // A projection that vanished between availability and open throws.
  133. expect(() => c.ui.options({ sessionId: sid('ghost') }, new AbortController().signal))
  134. .toThrow(/not available on this host/)
  135. })
  136. it('a pick submits the /permission line; rejection and unmatched throw', async () => {
  137. const b = await bench()
  138. const c = b.decoration()!
  139. const proj = { sessionId: sid('s1') }
  140. b.values.set(sid('s1'), SELECT)
  141. await c.ui.onSelect({ id: 'danger-full-access', label: 'danger-full-access' }, proj)
  142. expect(b.commands).toEqual(['/permission danger-full-access'])
  143. b.setResult({ ok: false })
  144. await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/permission switch failed/)
  145. b.setResult({ ok: true, matched: false })
  146. await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, proj)).rejects.toThrow(/no \/permission command/)
  147. // An unmaterialized session throws before any submit.
  148. await expect(c.ui.onSelect({ id: 'read-only', label: 'read-only' }, { sessionId: sid('ghost') }))
  149. .rejects.toThrow(/not materialized/)
  150. })
  151. it('disposal removes the decoration (HMR safety)', async () => {
  152. const b = await bench()
  153. expect(b.decoration()).toBeDefined()
  154. b.ctx.emit('settings/changed', 'another')
  155. b.ctx.emit('settings/changed', 'permission')
  156. b.ctx.emit('connection/reset')
  157. await b.fiber.dispose()
  158. expect(b.decoration()).toBeUndefined()
  159. expect(b.permissionRow()).toBeUndefined()
  160. })
  161. })