directory-picker.host.spec.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import { DirectoryPicker, DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  4. import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
  5. import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
  6. import { DirectoryPickerController } from '../src/directory-picker.ts'
  7. const roots: Context[] = []
  8. afterEach(async () => {
  9. await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose()))
  10. })
  11. /** A backend serving exactly the capability one case is about. */
  12. class StubPicker extends DirectoryPicker {
  13. static capabilityStub: DirectoryPickerCapability = { kind: 'native', pick: async () => null }
  14. capability(): DirectoryPickerCapability {
  15. return StubPicker.capabilityStub
  16. }
  17. }
  18. const NATIVE_STUB: DirectoryPickerCapability = { kind: 'native', pick: async () => null }
  19. const BROWSE_STUB: DirectoryPickerCapability = {
  20. kind: 'browse',
  21. list: async (path) => {
  22. if (path === '/denied') {
  23. throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
  24. }
  25. const target = path ?? '/home/user'
  26. return {
  27. path: target,
  28. home: '/home/user',
  29. crumbs: [{ name: '/', path: '/', hidden: false }],
  30. entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
  31. truncated: false,
  32. }
  33. },
  34. createDirectory: async (path, name) => {
  35. if (name === 'taken') {
  36. throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
  37. }
  38. if (name === 'unwritable') throw new Error('disk detached')
  39. if (name === 'gone') throw 'the volume vanished'
  40. return `${path}/${name}`
  41. },
  42. }
  43. async function harness(capability: DirectoryPickerCapability = NATIVE_STUB) {
  44. StubPicker.capabilityStub = capability
  45. const ctx = new Context()
  46. roots.push(ctx)
  47. await ctx.plugin(StubPicker).await()
  48. return new DirectoryPickerController(ctx)
  49. }
  50. /** The failure payload a refused wire verb carries. */
  51. async function refused(call: Promise<unknown>): Promise<{ code: string; message: string; details: object }> {
  52. try {
  53. await call
  54. } catch (error: unknown) {
  55. const failure = remoteErrorOf(error)
  56. if (failure === undefined) throw error
  57. return { code: failure.code, message: failure.message, details: failure.details }
  58. }
  59. throw new Error('the call was expected to be refused')
  60. }
  61. describe('directoryPicker pick Remote', () => {
  62. it('answers the selected path or the operator\'s cancellation', async () => {
  63. const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' })
  64. expect(await selected.pick(new AbortController().signal)).toBe('/tmp/project')
  65. const cancelled = await harness(NATIVE_STUB)
  66. expect(await cancelled.pick(new AbortController().signal)).toBeNull()
  67. })
  68. it('reports an aborted chooser as cancelled and any other failure as internal', async () => {
  69. const picker = await harness({
  70. kind: 'native',
  71. pick: signal => new Promise((_resolve, reject) => {
  72. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  73. }),
  74. })
  75. const abort = new AbortController()
  76. const pending = refused(picker.pick(abort.signal))
  77. abort.abort()
  78. expect((await pending).code).toBe('gateway/cancelled')
  79. const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
  80. const failure = await refused(broken.pick(new AbortController().signal))
  81. expect(failure.code).toBe('gateway/internal')
  82. expect(failure.message).toContain('no chooser installed')
  83. })
  84. it('refuses the native verb under a browse composition', async () => {
  85. const picker = await harness(BROWSE_STUB)
  86. const failure = await refused(picker.pick(new AbortController().signal))
  87. expect(failure.code).toBe('directory-picker/unavailable')
  88. expect(failure.message).toContain('needs the native capability')
  89. expect(failure.details).toEqual({ capability: 'browse' })
  90. })
  91. })
  92. describe('directoryPicker browse Remotes', () => {
  93. it('serves listings and creation, defaulting to the home directory', async () => {
  94. const picker = await harness(BROWSE_STUB)
  95. const signal = new AbortController().signal
  96. expect(await picker.list(undefined, signal)).toMatchObject({ path: '/home/user', home: '/home/user' })
  97. expect(await picker.list('/home/user/projects', signal))
  98. .toMatchObject({ path: '/home/user/projects' })
  99. expect(await picker.createDirectory('/home/user', 'fresh')).toBe('/home/user/fresh')
  100. })
  101. it('maps the seam\'s typed failures and folds unknown throws to internal', async () => {
  102. const picker = await harness(BROWSE_STUB)
  103. expect(await refused(picker.list('/denied', new AbortController().signal)))
  104. .toMatchObject({ code: 'directory-picker/unreadable', details: { path: '/denied' } })
  105. expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-picker/exists')
  106. expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('gateway/internal')
  107. const thrown = await refused(picker.createDirectory('/home/user', 'gone'))
  108. expect(thrown).toMatchObject({ code: 'gateway/internal', message: 'the volume vanished' })
  109. })
  110. it('rejects invalid child names before capability dispatch', async () => {
  111. const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
  112. const picker = await harness({
  113. kind: 'browse',
  114. list: (path, signal) => BROWSE_STUB.list(path, signal),
  115. createDirectory,
  116. })
  117. for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
  118. const failure = await refused(picker.createDirectory('/home/user', name))
  119. expect(failure).toMatchObject({
  120. code: 'gateway/bad-request',
  121. message: 'invalid payload for host.createDirectory',
  122. })
  123. expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true)
  124. }
  125. expect(createDirectory).not.toHaveBeenCalled()
  126. })
  127. it('reports an aborted listing as cancelled', async () => {
  128. const picker = await harness({
  129. kind: 'browse',
  130. list: (_path, signal) => new Promise((_resolve, reject) => {
  131. signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
  132. }),
  133. createDirectory: async () => '/never',
  134. })
  135. const abort = new AbortController()
  136. const pending = refused(picker.list(undefined, abort.signal))
  137. abort.abort()
  138. expect((await pending).code).toBe('gateway/cancelled')
  139. })
  140. it('refuses the browse verbs under a native composition', async () => {
  141. const picker = await harness()
  142. expect(await refused(picker.list(undefined, new AbortController().signal)))
  143. .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } })
  144. expect(await refused(picker.createDirectory('/x', 'y')))
  145. .toMatchObject({ code: 'directory-picker/unavailable', details: { capability: 'native' } })
  146. })
  147. })