path.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. import { describe, expect, test } from "bun:test"
  2. import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path"
  3. describe("file path helpers", () => {
  4. test("normalizes file inputs against workspace root", () => {
  5. const path = createPathHelpers(() => "/repo")
  6. expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
  7. expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
  8. expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
  9. expect(path.normalizeDir("src/components///")).toBe("src/components")
  10. expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
  11. expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
  12. expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
  13. })
  14. test("keeps query/hash stripping behavior stable", () => {
  15. expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
  16. expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
  17. expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
  18. })
  19. test("unquotes git escaped octal path strings", () => {
  20. expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
  21. expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
  22. expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
  23. })
  24. })
  25. describe("encodeFilePath", () => {
  26. describe("Linux/Unix paths", () => {
  27. test("should handle Linux absolute path", () => {
  28. const linuxPath = "/home/user/project/README.md"
  29. const result = encodeFilePath(linuxPath)
  30. const fileUrl = `file://${result}`
  31. // Should create a valid URL
  32. expect(() => new URL(fileUrl)).not.toThrow()
  33. expect(result).toBe("/home/user/project/README.md")
  34. const url = new URL(fileUrl)
  35. expect(url.protocol).toBe("file:")
  36. expect(url.pathname).toBe("/home/user/project/README.md")
  37. })
  38. test("should handle Linux path with special characters", () => {
  39. const linuxPath = "/home/user/file#name with spaces.txt"
  40. const result = encodeFilePath(linuxPath)
  41. const fileUrl = `file://${result}`
  42. expect(() => new URL(fileUrl)).not.toThrow()
  43. expect(result).toBe("/home/user/file%23name%20with%20spaces.txt")
  44. })
  45. test("should handle Linux relative path", () => {
  46. const relativePath = "src/components/App.tsx"
  47. const result = encodeFilePath(relativePath)
  48. expect(result).toBe("src/components/App.tsx")
  49. })
  50. test("should handle Linux root directory", () => {
  51. const result = encodeFilePath("/")
  52. expect(result).toBe("/")
  53. })
  54. test("should handle Linux path with all special chars", () => {
  55. const path = "/path/to/file#with?special%chars&more.txt"
  56. const result = encodeFilePath(path)
  57. const fileUrl = `file://${result}`
  58. expect(() => new URL(fileUrl)).not.toThrow()
  59. expect(result).toContain("%23") // #
  60. expect(result).toContain("%3F") // ?
  61. expect(result).toContain("%25") // %
  62. expect(result).toContain("%26") // &
  63. })
  64. })
  65. describe("macOS paths", () => {
  66. test("should handle macOS absolute path", () => {
  67. const macPath = "/Users/kelvin/Projects/opencode/README.md"
  68. const result = encodeFilePath(macPath)
  69. const fileUrl = `file://${result}`
  70. expect(() => new URL(fileUrl)).not.toThrow()
  71. expect(result).toBe("/Users/kelvin/Projects/opencode/README.md")
  72. })
  73. test("should handle macOS path with spaces", () => {
  74. const macPath = "/Users/kelvin/My Documents/file.txt"
  75. const result = encodeFilePath(macPath)
  76. const fileUrl = `file://${result}`
  77. expect(() => new URL(fileUrl)).not.toThrow()
  78. expect(result).toContain("My%20Documents")
  79. })
  80. })
  81. describe("Windows paths", () => {
  82. test("should handle Windows absolute path with backslashes", () => {
  83. const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
  84. const result = encodeFilePath(windowsPath)
  85. const fileUrl = `file://${result}`
  86. // Should create a valid, parseable URL
  87. expect(() => new URL(fileUrl)).not.toThrow()
  88. const url = new URL(fileUrl)
  89. expect(url.protocol).toBe("file:")
  90. expect(url.pathname).toContain("README.bs.md")
  91. expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
  92. })
  93. test("should handle mixed separator path (Windows + Unix)", () => {
  94. // This is what happens in build-request-parts.ts when concatenating paths
  95. const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md"
  96. const result = encodeFilePath(mixedPath)
  97. const fileUrl = `file://${result}`
  98. expect(() => new URL(fileUrl)).not.toThrow()
  99. expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
  100. })
  101. test("should handle Windows path with spaces", () => {
  102. const windowsPath = "C:\\Program Files\\MyApp\\file with spaces.txt"
  103. const result = encodeFilePath(windowsPath)
  104. const fileUrl = `file://${result}`
  105. expect(() => new URL(fileUrl)).not.toThrow()
  106. expect(result).toContain("Program%20Files")
  107. expect(result).toContain("file%20with%20spaces.txt")
  108. })
  109. test("should handle Windows path with special chars in filename", () => {
  110. const windowsPath = "D:\\projects\\file#name with ?marks.txt"
  111. const result = encodeFilePath(windowsPath)
  112. const fileUrl = `file://${result}`
  113. expect(() => new URL(fileUrl)).not.toThrow()
  114. expect(result).toContain("file%23name%20with%20%3Fmarks.txt")
  115. })
  116. test("should handle Windows root directory", () => {
  117. const windowsPath = "C:\\"
  118. const result = encodeFilePath(windowsPath)
  119. const fileUrl = `file://${result}`
  120. expect(() => new URL(fileUrl)).not.toThrow()
  121. expect(result).toBe("/C%3A/")
  122. })
  123. test("should handle Windows relative path with backslashes", () => {
  124. const windowsPath = "src\\components\\App.tsx"
  125. const result = encodeFilePath(windowsPath)
  126. // Relative paths shouldn't get the leading slash
  127. expect(result).toBe("src/components/App.tsx")
  128. })
  129. test("should NOT create invalid URL like the bug report", () => {
  130. // This is the exact scenario from bug report by @alexyaroshuk
  131. const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md"
  132. const result = encodeFilePath(windowsPath)
  133. const fileUrl = `file://${result}`
  134. // The bug was creating: file://D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md
  135. expect(result).not.toContain("%5C") // Should not have encoded backslashes
  136. expect(result).not.toBe("D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md")
  137. // Should be valid
  138. expect(() => new URL(fileUrl)).not.toThrow()
  139. })
  140. test("should handle lowercase drive letters", () => {
  141. const windowsPath = "c:\\users\\test\\file.txt"
  142. const result = encodeFilePath(windowsPath)
  143. const fileUrl = `file://${result}`
  144. expect(() => new URL(fileUrl)).not.toThrow()
  145. expect(result).toBe("/c%3A/users/test/file.txt")
  146. })
  147. })
  148. describe("Cross-platform compatibility", () => {
  149. test("should preserve Unix paths unchanged (except encoding)", () => {
  150. const unixPath = "/usr/local/bin/app"
  151. const result = encodeFilePath(unixPath)
  152. expect(result).toBe("/usr/local/bin/app")
  153. })
  154. test("should normalize Windows paths for cross-platform use", () => {
  155. const windowsPath = "C:\\Users\\test\\file.txt"
  156. const result = encodeFilePath(windowsPath)
  157. // Should convert to forward slashes and add leading /
  158. expect(result).not.toContain("\\")
  159. expect(result).toMatch(/^\/[A-Za-z]%3A\//)
  160. })
  161. test("should handle relative paths the same on all platforms", () => {
  162. const unixRelative = "src/app.ts"
  163. const windowsRelative = "src\\app.ts"
  164. const unixResult = encodeFilePath(unixRelative)
  165. const windowsResult = encodeFilePath(windowsRelative)
  166. // Both should normalize to forward slashes
  167. expect(unixResult).toBe("src/app.ts")
  168. expect(windowsResult).toBe("src/app.ts")
  169. })
  170. })
  171. describe("Edge cases", () => {
  172. test("should handle empty path", () => {
  173. const result = encodeFilePath("")
  174. expect(result).toBe("")
  175. })
  176. test("should handle path with multiple consecutive slashes", () => {
  177. const result = encodeFilePath("//path//to///file.txt")
  178. // Multiple slashes should be preserved (backend handles normalization)
  179. expect(result).toBe("//path//to///file.txt")
  180. })
  181. test("should encode Unicode characters", () => {
  182. const unicodePath = "/home/user/文档/README.md"
  183. const result = encodeFilePath(unicodePath)
  184. const fileUrl = `file://${result}`
  185. expect(() => new URL(fileUrl)).not.toThrow()
  186. // Unicode should be encoded
  187. expect(result).toContain("%E6%96%87%E6%A1%A3")
  188. })
  189. test("should handle already normalized Windows path", () => {
  190. // Path that's already been normalized (has / before drive letter)
  191. const alreadyNormalized = "/D:/path/file.txt"
  192. const result = encodeFilePath(alreadyNormalized)
  193. // Should not add another leading slash
  194. expect(result).toBe("/D%3A/path/file.txt")
  195. expect(result).not.toContain("//D")
  196. })
  197. test("should handle just drive letter", () => {
  198. const justDrive = "D:"
  199. const result = encodeFilePath(justDrive)
  200. const fileUrl = `file://${result}`
  201. expect(result).toBe("/D%3A")
  202. expect(() => new URL(fileUrl)).not.toThrow()
  203. })
  204. test("should handle Windows path with trailing backslash", () => {
  205. const trailingBackslash = "C:\\Users\\test\\"
  206. const result = encodeFilePath(trailingBackslash)
  207. const fileUrl = `file://${result}`
  208. expect(() => new URL(fileUrl)).not.toThrow()
  209. expect(result).toBe("/C%3A/Users/test/")
  210. })
  211. test("should handle very long paths", () => {
  212. const longPath = "C:\\Users\\test\\" + "verylongdirectoryname\\".repeat(20) + "file.txt"
  213. const result = encodeFilePath(longPath)
  214. const fileUrl = `file://${result}`
  215. expect(() => new URL(fileUrl)).not.toThrow()
  216. expect(result).not.toContain("\\")
  217. })
  218. test("should handle paths with dots", () => {
  219. const pathWithDots = "C:\\Users\\..\\test\\.\\file.txt"
  220. const result = encodeFilePath(pathWithDots)
  221. const fileUrl = `file://${result}`
  222. expect(() => new URL(fileUrl)).not.toThrow()
  223. // Dots should be preserved (backend normalizes)
  224. expect(result).toContain("..")
  225. expect(result).toContain("/./")
  226. })
  227. })
  228. describe("Regression tests for PR #12424", () => {
  229. test("should handle file with # in name", () => {
  230. const path = "/path/to/file#name.txt"
  231. const result = encodeFilePath(path)
  232. const fileUrl = `file://${result}`
  233. expect(() => new URL(fileUrl)).not.toThrow()
  234. expect(result).toBe("/path/to/file%23name.txt")
  235. })
  236. test("should handle file with ? in name", () => {
  237. const path = "/path/to/file?name.txt"
  238. const result = encodeFilePath(path)
  239. const fileUrl = `file://${result}`
  240. expect(() => new URL(fileUrl)).not.toThrow()
  241. expect(result).toBe("/path/to/file%3Fname.txt")
  242. })
  243. test("should handle file with % in name", () => {
  244. const path = "/path/to/file%name.txt"
  245. const result = encodeFilePath(path)
  246. const fileUrl = `file://${result}`
  247. expect(() => new URL(fileUrl)).not.toThrow()
  248. expect(result).toBe("/path/to/file%25name.txt")
  249. })
  250. })
  251. describe("Integration with file:// URL construction", () => {
  252. test("should work with query parameters (Linux)", () => {
  253. const path = "/home/user/file.txt"
  254. const encoded = encodeFilePath(path)
  255. const fileUrl = `file://${encoded}?start=10&end=20`
  256. const url = new URL(fileUrl)
  257. expect(url.searchParams.get("start")).toBe("10")
  258. expect(url.searchParams.get("end")).toBe("20")
  259. expect(url.pathname).toBe("/home/user/file.txt")
  260. })
  261. test("should work with query parameters (Windows)", () => {
  262. const path = "C:\\Users\\test\\file.txt"
  263. const encoded = encodeFilePath(path)
  264. const fileUrl = `file://${encoded}?start=10&end=20`
  265. const url = new URL(fileUrl)
  266. expect(url.searchParams.get("start")).toBe("10")
  267. expect(url.searchParams.get("end")).toBe("20")
  268. })
  269. test("should parse correctly in URL constructor (Linux)", () => {
  270. const path = "/var/log/app.log"
  271. const fileUrl = `file://${encodeFilePath(path)}`
  272. const url = new URL(fileUrl)
  273. expect(url.protocol).toBe("file:")
  274. expect(url.pathname).toBe("/var/log/app.log")
  275. })
  276. test("should parse correctly in URL constructor (Windows)", () => {
  277. const path = "D:\\logs\\app.log"
  278. const fileUrl = `file://${encodeFilePath(path)}`
  279. const url = new URL(fileUrl)
  280. expect(url.protocol).toBe("file:")
  281. expect(url.pathname).toContain("app.log")
  282. })
  283. })
  284. })