directory-picker.host.spec.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166
  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 { TypertRemoteFailure } 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. if (!(error instanceof TypertRemoteFailure)) throw error
  56. return { ...error.failure }
  57. }
  58. throw new Error('the call was expected to be refused')
  59. }
  60. describe('directoryPicker pick Remote', () => {
  61. it('answers the selected path or the operator\'s cancellation', async () => {
  62. const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' })
  63. expect(await selected.pick(new AbortController().signal)).toBe('/tmp/project')
  64. const cancelled = await harness(NATIVE_STUB)
  65. expect(await cancelled.pick(new AbortController().signal)).toBeNull()
  66. })
  67. it('reports an aborted chooser as cancelled and any other failure as internal', async () => {
  68. const picker = await harness({
  69. kind: 'native',
  70. pick: signal => new Promise((_resolve, reject) => {
  71. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  72. }),
  73. })
  74. const abort = new AbortController()
  75. const pending = refused(picker.pick(abort.signal))
  76. abort.abort()
  77. expect((await pending).code).toBe('cancelled')
  78. const broken = await harness({ kind: 'native', pick: async () => { throw new Error('no chooser installed') } })
  79. const failure = await refused(broken.pick(new AbortController().signal))
  80. expect(failure.code).toBe('internal')
  81. expect(failure.message).toContain('no chooser installed')
  82. })
  83. it('refuses the native verb under a browse composition', async () => {
  84. const picker = await harness(BROWSE_STUB)
  85. const failure = await refused(picker.pick(new AbortController().signal))
  86. expect(failure.code).toBe('directory-picker-unavailable')
  87. expect(failure.message).toContain('needs the native capability')
  88. expect(failure.details).toEqual({ capability: 'browse' })
  89. })
  90. })
  91. describe('directoryPicker browse Remotes', () => {
  92. it('serves listings and creation, defaulting to the home directory', async () => {
  93. const picker = await harness(BROWSE_STUB)
  94. const signal = new AbortController().signal
  95. expect(await picker.list(undefined, signal)).toMatchObject({ path: '/home/user', home: '/home/user' })
  96. expect(await picker.list('/home/user/projects', signal))
  97. .toMatchObject({ path: '/home/user/projects' })
  98. expect(await picker.createDirectory('/home/user', 'fresh')).toBe('/home/user/fresh')
  99. })
  100. it('maps the seam\'s typed failures and folds unknown throws to internal', async () => {
  101. const picker = await harness(BROWSE_STUB)
  102. expect(await refused(picker.list('/denied', new AbortController().signal)))
  103. .toMatchObject({ code: 'directory-unreadable', details: { path: '/denied' } })
  104. expect((await refused(picker.createDirectory('/home/user', 'taken'))).code).toBe('directory-exists')
  105. expect((await refused(picker.createDirectory('/home/user', 'unwritable'))).code).toBe('internal')
  106. const thrown = await refused(picker.createDirectory('/home/user', 'gone'))
  107. expect(thrown).toMatchObject({ code: 'internal', message: 'the volume vanished' })
  108. })
  109. it('rejects invalid child names before capability dispatch', async () => {
  110. const createDirectory = vi.fn(async (path: string, name: string) => `${path}/${name}`)
  111. const picker = await harness({
  112. kind: 'browse',
  113. list: (path, signal) => BROWSE_STUB.list(path, signal),
  114. createDirectory,
  115. })
  116. for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
  117. const failure = await refused(picker.createDirectory('/home/user', name))
  118. expect(failure).toMatchObject({
  119. code: 'bad-request',
  120. message: 'invalid payload for host.createDirectory',
  121. })
  122. expect(Array.isArray(Reflect.get(failure.details, 'issues'))).toBe(true)
  123. }
  124. expect(createDirectory).not.toHaveBeenCalled()
  125. })
  126. it('reports an aborted listing as cancelled', async () => {
  127. const picker = await harness({
  128. kind: 'browse',
  129. list: (_path, signal) => new Promise((_resolve, reject) => {
  130. signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
  131. }),
  132. createDirectory: async () => '/never',
  133. })
  134. const abort = new AbortController()
  135. const pending = refused(picker.list(undefined, abort.signal))
  136. abort.abort()
  137. expect((await pending).code).toBe('cancelled')
  138. })
  139. it('refuses the browse verbs under a native composition', async () => {
  140. const picker = await harness()
  141. expect(await refused(picker.list(undefined, new AbortController().signal)))
  142. .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
  143. expect(await refused(picker.createDirectory('/x', 'y')))
  144. .toMatchObject({ code: 'directory-picker-unavailable', details: { capability: 'native' } })
  145. })
  146. })