changes-open.host.spec.ts 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. /** Changed-file and common-folder native opens resolve the viewed Session's current workspace. */
  2. import { mkdtemp, rm, writeFile, mkdir, realpath, unlink } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
  6. import { WorkspaceFiles } from '@deepseek-ai/dsh-api-workspace-files'
  7. import { Context } from '@deepseek-ai/cordis'
  8. import { HostConnectionService } from '@deepseek-ai/dsh-client-connection'
  9. import type { BrowserAuth } from '@deepseek-ai/dsh-client-connection/src/browser-auth.ts'
  10. import { SessionId } from '@deepseek-ai/dsh-session'
  11. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  12. import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
  13. import type { SessionEventReadRequest } from '@deepseek-ai/dsh-session-query'
  14. import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types'
  15. import { afterEach, describe, expect, it, vi } from 'vitest'
  16. import { commonChangedFolder, registerPresentOpen } from '../src/present-open.ts'
  17. import { changedFileUrl, CHANGES_OPEN_PATH, isChangedFile, isChangesData } from '../src/changes.ts'
  18. const cleanups: Array<() => Promise<unknown>> = []
  19. afterEach(async () => {
  20. for (const cleanup of cleanups.reverse()) await cleanup()
  21. cleanups.length = 0
  22. vi.restoreAllMocks()
  23. })
  24. const changed = (path: string, display = path): WorkspaceChangedFile => ({ path, display, added: 1, deleted: 0 })
  25. async function fixture() {
  26. const root = await mkdtemp(join(tmpdir(), 'dsh-changes-open-'))
  27. cleanups.push(() => rm(root, { recursive: true, force: true }))
  28. const cwd = join(root, 'workspace')
  29. await mkdir(join(cwd, 'src', 'lib'), { recursive: true })
  30. await writeFile(join(cwd, 'src', 'lib', 'a.ts'), 'a')
  31. await writeFile(join(cwd, 'src', 'b.ts'), 'b')
  32. const outside = join(root, 'outside.txt')
  33. await writeFile(outside, 'outside')
  34. const data = { turn: 1, total: 3, snapshot: { before: 'a'.repeat(40), after: 'b'.repeat(40) }, files: [changed('src/lib/a.ts'), changed('src/b.ts'), changed(outside, '~/outside.txt')] }
  35. const ctx = new Context()
  36. cleanups.push(() => ctx.fiber.dispose())
  37. const session: { cwd?: string } = { cwd }
  38. await ctx.plugin(LocalFileSystem, { cwd })
  39. ctx.provide('sandboxPolicy', { workspaceRoot: cwd } as never)
  40. await ctx.plugin({
  41. inject: ['fs', 'sandboxPolicy'],
  42. apply: (scope) => { new WorkspaceFiles(scope, { maxBytes: 1024, maxFileBytes: 1024, maxLines: 100, maxEntries: 100 }) },
  43. })
  44. const readEvent = vi.fn(async (request: SessionEventReadRequest) => {
  45. if (request.sessionId !== 'owner') throw new SessionQueryError('missing', 'SESSION_QUERY_SESSION_NOT_FOUND')
  46. if (request.seq !== 9) throw new SessionQueryError('missing', 'SESSION_QUERY_EVENT_NOT_FOUND')
  47. return { session, target: { type: 'workspace/changes', data } as SessionEvent }
  48. })
  49. ctx.provide('sessionQuery', { readEvent } as never)
  50. const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const }))
  51. ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never)
  52. const connection = new HostConnectionService(ctx, [], {} as BrowserAuth)
  53. await ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen })
  54. const handler = connection.createSharedFetchHandler('/api')
  55. const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' }))
  56. return { root, cwd, ctx, data, session, readEvent, open, opener, outside }
  57. }
  58. describe('changed files native open route', () => {
  59. it('opens a listed file inside or outside the workspace with its verified Host path', async () => {
  60. const { cwd, open, opener, outside } = await fixture()
  61. expect(changedFileUrl(SessionId('owner'), 9, 0)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9&index=0`)
  62. expect(changedFileUrl(SessionId('owner'), 9, null)).toBe(`${CHANGES_OPEN_PATH}?sessionId=owner&seq=9`)
  63. const response = await open()
  64. expect(response.status).toBe(204)
  65. expect(response.headers.get('cache-control')).toBe('no-store')
  66. expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src', 'lib', 'a.ts')) }, expect.any(AbortSignal))
  67. expect((await open('?sessionId=owner&seq=9&index=2')).status).toBe(204)
  68. expect(opener.mock.lastCall?.[0].path).toBe(await realpath(outside))
  69. })
  70. it('opens the deepest folder containing the workspace files, falling back to the workspace', async () => {
  71. const { cwd, open, opener, data } = await fixture()
  72. expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
  73. expect(opener).toHaveBeenLastCalledWith({ path: await realpath(join(cwd, 'src')) }, expect.any(AbortSignal))
  74. data.files = [changed('../escaped.ts', '../escaped.ts'), changed('/etc/hosts', '/etc/hosts')]
  75. expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
  76. expect(opener.mock.lastCall?.[0].path).toBe(await realpath(cwd))
  77. expect(commonChangedFolder('/w', [changed('a/b/c.ts'), changed('a/d.ts'), changed('/x/y.ts')])).toBe('/w/a')
  78. expect(commonChangedFolder('/w', [changed('/x/y.ts')])).toBe('/w')
  79. expect(commonChangedFolder('/w', [changed('../up.ts')])).toBe('/w')
  80. })
  81. it.each(['', '?seq=9', '?sessionId=owner', '?sessionId=owner&seq=9&index=-1', '?sessionId=owner&seq=9&index=1.5', '?sessionId=owner&seq=x'])(
  82. 'rejects invalid coordinates before reading: %s', async (query) => {
  83. const { open, readEvent } = await fixture()
  84. expect((await open(query)).status).toBe(400)
  85. expect(readEvent).not.toHaveBeenCalled()
  86. })
  87. it('refuses unrelated Sessions, other events, malformed data, unknown indices, and missing files', async () => {
  88. const { open, readEvent, session, opener, cwd, data } = await fixture()
  89. expect((await open('?sessionId=other&seq=9&index=0')).status).toBe(404)
  90. expect((await open('?sessionId=owner&seq=8&index=0')).status).toBe(404)
  91. expect((await open('?sessionId=owner&seq=9&index=5')).status).toBe(404)
  92. readEvent.mockResolvedValueOnce({ session, target: { type: 'deliverables/presented', data } as unknown as SessionEvent })
  93. expect((await open()).status).toBe(404)
  94. for (const bad of [null, { turn: 0, files: [], total: 0 }, { turn: 1, files: null, total: 0 }, { turn: 1, files: [{ path: 'a' }], total: 1 }, { turn: 1, files: [], total: 1.5 }]) {
  95. readEvent.mockResolvedValueOnce({ session, target: { type: 'workspace/changes', data: bad } as unknown as SessionEvent })
  96. expect((await open()).status).toBe(404)
  97. }
  98. await unlink(join(cwd, 'src', 'lib', 'a.ts'))
  99. expect((await open()).status).toBe(404)
  100. await rm(join(cwd, 'src'), { recursive: true })
  101. expect((await open('?sessionId=owner&seq=9')).status).toBe(404)
  102. expect(opener).not.toHaveBeenCalled()
  103. })
  104. it('refuses opening without a desktop or a verified Host mapping and reports launcher failures', async () => {
  105. const { ctx, open, opener } = await fixture()
  106. const desktop = vi.spyOn(ctx.sessionController, 'workspaceDesktop').mockReturnValue({ name: 'desktop', available: false, fileManager: null })
  107. expect((await open()).status).toBe(409)
  108. desktop.mockRestore()
  109. const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined)
  110. expect((await open()).status).toBe(422)
  111. expect((await open('?sessionId=owner&seq=9')).status).toBe(422)
  112. mapping.mockRestore()
  113. opener.mockRejectedValueOnce(new Error('/private/host/path'))
  114. const failed = await open()
  115. expect(failed.status).toBe(500)
  116. expect(await failed.text()).not.toContain('/private/host/path')
  117. expect((await open()).status).toBe(204)
  118. })
  119. it('uses the deployment workspace root when the viewed Session has no cwd', async () => {
  120. const { session, open, opener, cwd } = await fixture()
  121. delete session.cwd
  122. expect((await open('?sessionId=owner&seq=9')).status).toBe(204)
  123. expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, 'src')))
  124. })
  125. it('validates recorded change records', () => {
  126. expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: true })).toBe(true)
  127. expect(isChangedFile({ path: 'a', display: 'a', added: 1, deleted: 2, binary: false })).toBe(false)
  128. expect(isChangedFile({ path: '', display: 'a', added: 1, deleted: 2 })).toBe(false)
  129. expect(isChangedFile({ path: 'a', display: '', added: 1, deleted: 2 })).toBe(false)
  130. expect(isChangedFile({ path: 'a', display: 'a', added: 1.5, deleted: 2 })).toBe(false)
  131. expect(isChangedFile([])).toBe(false)
  132. expect(isChangesData({ turn: 1, total: 0, files: [] })).toBe(true)
  133. expect(isChangesData({ turn: '1', total: 0, files: [] })).toBe(false)
  134. expect(isChangesData([])).toBe(false)
  135. })
  136. })