session-open-workspace-path.host.spec.ts 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. import * as nativeCommand from '@deepseek-ai/dsh-native-command'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import AgentRegistry from '@deepseek-ai/dsh-agent'
  4. import SessionStore from '@deepseek-ai/dsh-session'
  5. import { describe, expect, it, vi } from 'vitest'
  6. import {
  7. createSessionTestController,
  8. createSessionTestRemote,
  9. } from './test-remote.ts'
  10. async function context(): Promise<Context> {
  11. const ctx = new Context()
  12. await ctx.plugin(SessionStore)
  13. await ctx.plugin(AgentRegistry)
  14. return ctx
  15. }
  16. describe('session/openWorkspacePath', () => {
  17. it('reports the deployment opener capability independently of a Session', async () => {
  18. const ctx = await context()
  19. const remote = createSessionTestRemote(ctx, {
  20. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  21. cwd: '/default',
  22. canOpenPath: () => false,
  23. })
  24. await expect(remote.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false })
  25. })
  26. it('derives opener availability from config, an injected opener, or the platform probe', async () => {
  27. const configured = createSessionTestRemote(await context(), {
  28. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  29. cwd: '/default',
  30. nativeOpen: false,
  31. })
  32. await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false })
  33. const injected = createSessionTestRemote(await context(), {
  34. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  35. cwd: '/default',
  36. openPath: () => Promise.resolve(),
  37. })
  38. await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true })
  39. const detected = createSessionTestRemote(await context(), {
  40. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  41. cwd: '/default',
  42. })
  43. await expect(detected.canOpenWorkspacePath()).resolves.toMatchObject({ ok: true })
  44. })
  45. it('hands a Client-resolved workspace path to the Host opener unchanged', async () => {
  46. const ctx = await context()
  47. const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
  48. const remote = createSessionTestRemote(ctx, {
  49. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  50. cwd: '/default',
  51. openPath,
  52. })
  53. const signal = new AbortController().signal
  54. await expect(remote.openWorkspacePath({ path: '/workspace/project/src/a.ts' }, signal))
  55. .resolves.toEqual({ ok: true, value: { opened: true } })
  56. expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal)
  57. expect(ctx.agents.list()).toEqual([])
  58. })
  59. it('preserves relative and absolute Host-resolvable paths', async () => {
  60. const ctx = await context()
  61. const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
  62. const remote = createSessionTestRemote(ctx, {
  63. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  64. cwd: '/default',
  65. openPath,
  66. })
  67. await remote.openWorkspacePath({ path: '/tmp/result.html' })
  68. await remote.openWorkspacePath({ path: 'result.html' })
  69. expect(openPath.mock.calls.map(call => call[0])).toEqual(['/tmp/result.html', 'result.html'])
  70. })
  71. it('rejects empty paths before opening anything', async () => {
  72. const ctx = await context()
  73. const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve())
  74. const remote = createSessionTestRemote(ctx, {
  75. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  76. cwd: '/default',
  77. openPath,
  78. })
  79. await expect(remote.openWorkspacePath({ path: '' }))
  80. .resolves.toMatchObject({ ok: false, error: { code: 'gateway/bad-request' } })
  81. expect(openPath).not.toHaveBeenCalled()
  82. })
  83. it('preserves native opener failure and cancellation results', async () => {
  84. const ctx = await context()
  85. const openPath = vi.fn((_path: string, _signal: AbortSignal) =>
  86. Promise.reject(new Error('desktop unavailable')))
  87. const remote = createSessionTestRemote(ctx, {
  88. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  89. cwd: '/default',
  90. openPath,
  91. })
  92. await expect(remote.openWorkspacePath({ path: 'result.html' }))
  93. .resolves.toMatchObject({
  94. ok: false,
  95. error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' },
  96. })
  97. const aborted = new AbortController()
  98. aborted.abort(new Error('gateway/cancelled'))
  99. await expect(remote.openWorkspacePath({ path: 'result.html' }, aborted.signal))
  100. .resolves.toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } })
  101. })
  102. it('classifies opener cancellation and non-Error failures', async () => {
  103. const ctx = await context()
  104. const aborted = new AbortController()
  105. const openPath = vi.fn()
  106. .mockImplementationOnce(async () => {
  107. aborted.abort(new Error('gateway/cancelled'))
  108. throw new Error('opening stopped')
  109. })
  110. .mockRejectedValueOnce('desktop unavailable')
  111. const controller = createSessionTestController(ctx, {
  112. defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
  113. cwd: '/default',
  114. openPath,
  115. })
  116. await expect(controller.openWorkspacePath({ path: 'first.html' }, aborted.signal))
  117. .rejects.toMatchObject({ code: 'gateway/cancelled' })
  118. await expect(controller.openWorkspacePath({
  119. path: 'second.html',
  120. }, new AbortController().signal)).rejects.toMatchObject({
  121. code: 'gateway/internal', message: 'path open failed: desktop unavailable',
  122. })
  123. })
  124. })
  125. it('reports Host file-manager metadata and dispatches reveal separately from default-app open', async () => {
  126. const ctx = await context()
  127. const revealPath = vi.fn(async (_path: string, _signal: AbortSignal) => {})
  128. const openPath = vi.fn(async (_path: string, _signal: AbortSignal) => {})
  129. const controller = createSessionTestController(ctx, {
  130. defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath,
  131. })
  132. try {
  133. expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string })
  134. const signal = new AbortController().signal
  135. await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal)
  136. expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal)
  137. expect(openPath).not.toHaveBeenCalled()
  138. } finally { await ctx.fiber.dispose() }
  139. })
  140. it('uses the native reveal adapter without a test override and respects unsupported desktop metadata', async () => {
  141. const ctx = await context()
  142. const reveal = vi.spyOn(nativeCommand, 'revealNativePath').mockResolvedValue(undefined)
  143. const manager = vi.spyOn(nativeCommand, 'nativeFileManager').mockReturnValue(null)
  144. try {
  145. const controller = createSessionTestController(ctx, {
  146. defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true,
  147. })
  148. expect(controller.workspaceDesktop()).toMatchObject({ available: false, fileManager: null })
  149. await controller.openWorkspacePath({ path: '/report.txt', action: 'reveal' }, new AbortController().signal)
  150. expect(reveal).toHaveBeenCalledOnce()
  151. } finally { manager.mockRestore(); reveal.mockRestore(); await ctx.fiber.dispose() }
  152. })