|
| 1 | +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; |
| 2 | +import { tmpdir } from "node:os"; |
| 3 | +import { join } from "node:path"; |
| 4 | +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
| 5 | +import { resolveTuiExecInvocation } from "../../src/headless/invocation.js"; |
| 6 | +import { resolveWslPath } from "../../src/host/wsl-path.js"; |
| 7 | +import { resolveTuiAttachment } from "../../src/tui/features/composer/attachments.js"; |
| 8 | +import { TuiComposerDraft } from "../../src/tui/features/composer/draft.js"; |
| 9 | +import { isTuiTerminalImagePaste } from "../../src/tui/features/composer/terminal-image-paste.js"; |
| 10 | + |
| 11 | +const host = vi.hoisted(() => ({ |
| 12 | + platform: vi.fn(() => "linux"), |
| 13 | + release: vi.fn(() => "6.18.33.2-microsoft-standard-WSL2"), |
| 14 | + executeFile: vi.fn(), |
| 15 | +})); |
| 16 | + |
| 17 | +vi.mock("node:os", async (importOriginal) => ({ |
| 18 | + ...(await importOriginal<typeof import("node:os")>()), |
| 19 | + platform: host.platform, |
| 20 | + release: host.release, |
| 21 | +})); |
| 22 | +vi.mock("node:child_process", async (importOriginal) => ({ |
| 23 | + ...(await importOriginal<typeof import("node:child_process")>()), |
| 24 | + execFile: Object.assign(vi.fn(), { |
| 25 | + [Symbol.for("nodejs.util.promisify.custom")]: host.executeFile, |
| 26 | + }), |
| 27 | +})); |
| 28 | + |
| 29 | +let workspaceDir: string; |
| 30 | +let imagePath: string; |
| 31 | +const windowsPath = String.raw`D:\Users\demo\Documents\Screen shots\截图.png`; |
| 32 | + |
| 33 | +beforeEach(async () => { |
| 34 | + host.platform.mockReturnValue("linux"); |
| 35 | + host.release.mockReturnValue("6.18.33.2-microsoft-standard-WSL2"); |
| 36 | + host.executeFile.mockReset(); |
| 37 | + vi.stubEnv("WSL_DISTRO_NAME", ""); |
| 38 | + vi.stubEnv("WSL_INTEROP", ""); |
| 39 | + vi.stubEnv("WSLENV", ""); |
| 40 | + workspaceDir = await mkdtemp(join(tmpdir(), "mcode-wsl-path-")); |
| 41 | + imagePath = join(workspaceDir, "截图.png"); |
| 42 | + await writeFile(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); |
| 43 | + host.executeFile.mockResolvedValue({ stdout: `${imagePath}\n`, stderr: "" }); |
| 44 | +}); |
| 45 | + |
| 46 | +afterEach(async () => { |
| 47 | + vi.unstubAllEnvs(); |
| 48 | + await rm(workspaceDir, { recursive: true, force: true }); |
| 49 | +}); |
| 50 | + |
| 51 | +describe("WSL attachment paths", () => { |
| 52 | + it.each([ |
| 53 | + windowsPath, |
| 54 | + `"${windowsPath}"`, |
| 55 | + `'${windowsPath}'`, |
| 56 | + windowsPath.replaceAll("\\", "/"), |
| 57 | + ])( |
| 58 | + "queues a Windows image path as an image placeholder: %s", |
| 59 | + async (reference) => { |
| 60 | + const placeholders = vi.fn(); |
| 61 | + const draft = new TuiComposerDraft({ |
| 62 | + workspaceDir, |
| 63 | + resolveAttachment: resolveTuiAttachment, |
| 64 | + append: vi.fn(), |
| 65 | + onChanged: vi.fn(), |
| 66 | + onAttachmentPlaceholdersChanged: placeholders, |
| 67 | + }); |
| 68 | + expect(isTuiTerminalImagePaste(reference)).toBe(true); |
| 69 | + await draft.queueAttachment(reference, { source: "terminal-paste" }); |
| 70 | + expect(draft.snapshot().attachments).toEqual([ |
| 71 | + { |
| 72 | + type: "image", |
| 73 | + filePath: imagePath, |
| 74 | + fileName: "截图.png", |
| 75 | + mimeType: "image/png", |
| 76 | + sizeBytes: 4, |
| 77 | + }, |
| 78 | + ]); |
| 79 | + expect(placeholders).toHaveBeenLastCalledWith([ |
| 80 | + { id: imagePath, label: "[Image #1]" }, |
| 81 | + ]); |
| 82 | + expect(host.executeFile).toHaveBeenCalledWith( |
| 83 | + "wslpath", |
| 84 | + ["-a", "-u", reference.replace(/^['"]|['"]$/gu, "")], |
| 85 | + expect.objectContaining({ |
| 86 | + encoding: "utf8", |
| 87 | + timeout: 1_000, |
| 88 | + maxBuffer: 64 * 1024, |
| 89 | + }), |
| 90 | + ); |
| 91 | + }, |
| 92 | + ); |
| 93 | + |
| 94 | + it("resolves headless --file through the same conversion before realpath", async () => { |
| 95 | + const invocation = await resolveTuiExecInvocation( |
| 96 | + "describe", |
| 97 | + { cwd: workspaceDir, file: [windowsPath] }, |
| 98 | + async () => "", |
| 99 | + ); |
| 100 | + expect(invocation.attachments).toEqual([ |
| 101 | + { |
| 102 | + type: "image", |
| 103 | + filePath: await realpath(imagePath), |
| 104 | + fileName: "截图.png", |
| 105 | + mimeType: "image/png", |
| 106 | + sizeBytes: 4, |
| 107 | + }, |
| 108 | + ]); |
| 109 | + expect(host.executeFile).toHaveBeenCalledOnce(); |
| 110 | + }); |
| 111 | + |
| 112 | + it.each(["WSL_DISTRO_NAME", "WSL_INTEROP", "WSLENV"])( |
| 113 | + "detects WSL via %s even without a Microsoft kernel name", |
| 114 | + async (name) => { |
| 115 | + host.release.mockReturnValue("custom-kernel"); |
| 116 | + vi.stubEnv(name, "synthetic-wsl"); |
| 117 | + await expect(resolveWslPath(windowsPath)).resolves.toBe(imagePath); |
| 118 | + }, |
| 119 | + ); |
| 120 | + |
| 121 | + it.each(["darwin", "win32", "linux"])( |
| 122 | + "preserves Windows path syntax on a non-WSL %s host", |
| 123 | + async (platform) => { |
| 124 | + host.platform.mockReturnValue(platform); |
| 125 | + host.release.mockReturnValue("generic-kernel"); |
| 126 | + await expect(resolveWslPath(windowsPath)).resolves.toBe(windowsPath); |
| 127 | + expect(host.executeFile).not.toHaveBeenCalled(); |
| 128 | + }, |
| 129 | + ); |
| 130 | + |
| 131 | + it.each([ |
| 132 | + "/mnt/d/Screen shots/截图.png", |
| 133 | + "/tmp/literal\\name.png", |
| 134 | + "//tmp/image.png", |
| 135 | + "image.png", |
| 136 | + "~/image.png", |
| 137 | + "D:relative.png", |
| 138 | + ])( |
| 139 | + "leaves native and relative paths unchanged in WSL: %s", |
| 140 | + async (reference) => { |
| 141 | + await expect(resolveWslPath(reference)).resolves.toBe(reference); |
| 142 | + expect(host.executeFile).not.toHaveBeenCalled(); |
| 143 | + }, |
| 144 | + ); |
| 145 | + |
| 146 | + it("keeps native attachment resolution working in WSL without a subprocess", async () => { |
| 147 | + await expect( |
| 148 | + resolveTuiAttachment("截图.png", { workspaceDir }), |
| 149 | + ).resolves.toMatchObject({ filePath: imagePath }); |
| 150 | + await expect( |
| 151 | + resolveTuiAttachment("~/截图.png", { |
| 152 | + workspaceDir, |
| 153 | + homeDir: workspaceDir, |
| 154 | + }), |
| 155 | + ).resolves.toMatchObject({ filePath: imagePath }); |
| 156 | + expect(host.executeFile).not.toHaveBeenCalled(); |
| 157 | + }); |
| 158 | + |
| 159 | + it.each([ |
| 160 | + String.raw`d:\Screen shots\$(touch marker);'截图'.png`, |
| 161 | + String.raw`\\server\share\截图.png`, |
| 162 | + ])("passes Windows paths as a literal argument: %s", async (reference) => { |
| 163 | + await expect(resolveWslPath(reference)).resolves.toBe(imagePath); |
| 164 | + expect(host.executeFile.mock.calls[0]?.slice(0, 2)).toEqual([ |
| 165 | + "wslpath", |
| 166 | + ["-a", "-u", reference], |
| 167 | + ]); |
| 168 | + expect(host.executeFile.mock.calls[0]?.[2]).not.toHaveProperty("shell"); |
| 169 | + }); |
| 170 | + |
| 171 | + it("honors the converter output for custom mount roots and preserves trailing spaces", async () => { |
| 172 | + host.executeFile.mockResolvedValue({ |
| 173 | + stdout: "/custom/windows/d/Screen shots/image.png \n", |
| 174 | + }); |
| 175 | + await expect(resolveWslPath(windowsPath)).resolves.toBe( |
| 176 | + "/custom/windows/d/Screen shots/image.png ", |
| 177 | + ); |
| 178 | + }); |
| 179 | + |
| 180 | + it.each([ |
| 181 | + "", |
| 182 | + "\n", |
| 183 | + "relative/image.png\n", |
| 184 | + "D:\\image.png\n", |
| 185 | + "/tmp/image.png\nextra\n", |
| 186 | + "/tmp/image\0.png\n", |
| 187 | + ])( |
| 188 | + "rejects invalid converter output instead of treating it as a local path: %j", |
| 189 | + async (stdout) => { |
| 190 | + host.executeFile.mockResolvedValue({ stdout }); |
| 191 | + await expect( |
| 192 | + resolveTuiAttachment(windowsPath, { workspaceDir }), |
| 193 | + ).rejects.toThrow("Could not convert the Windows path with wslpath"); |
| 194 | + }, |
| 195 | + ); |
| 196 | + |
| 197 | + it.each(["ENOENT", "EACCES", "ETIMEDOUT"])( |
| 198 | + "reports converter failure %s without falling back to a workspace filename", |
| 199 | + async (code) => { |
| 200 | + if (process.platform !== "win32") |
| 201 | + await writeFile(join(workspaceDir, windowsPath), "wrong image"); |
| 202 | + host.executeFile.mockRejectedValue( |
| 203 | + Object.assign(new Error("converter failed"), { code }), |
| 204 | + ); |
| 205 | + await expect( |
| 206 | + resolveTuiAttachment(windowsPath, { |
| 207 | + workspaceDir, |
| 208 | + source: "terminal-paste", |
| 209 | + }), |
| 210 | + ).rejects.toThrow("Use an accessible Linux path instead"); |
| 211 | + await expect( |
| 212 | + resolveTuiExecInvocation( |
| 213 | + "describe", |
| 214 | + { cwd: workspaceDir, file: [windowsPath] }, |
| 215 | + async () => "", |
| 216 | + ), |
| 217 | + ).rejects.toMatchObject({ |
| 218 | + kind: "invocation", |
| 219 | + message: expect.stringContaining("wslpath"), |
| 220 | + }); |
| 221 | + }, |
| 222 | + ); |
| 223 | + |
| 224 | + it("still rejects missing files and directories after conversion", async () => { |
| 225 | + host.executeFile.mockResolvedValue({ |
| 226 | + stdout: `${join(workspaceDir, "missing.png")}\n`, |
| 227 | + }); |
| 228 | + await expect( |
| 229 | + resolveTuiAttachment(windowsPath, { workspaceDir }), |
| 230 | + ).rejects.toThrow("ENOENT"); |
| 231 | + host.executeFile.mockResolvedValue({ stdout: `${workspaceDir}\n` }); |
| 232 | + await expect( |
| 233 | + resolveTuiAttachment(windowsPath, { workspaceDir }), |
| 234 | + ).rejects.toThrow("not a file"); |
| 235 | + }); |
| 236 | + |
| 237 | + it("preserves headless cancellation while converting a path", async () => { |
| 238 | + const controller = new AbortController(); |
| 239 | + host.executeFile.mockImplementation(async (_file, _args, options) => { |
| 240 | + expect(options.signal).toBe(controller.signal); |
| 241 | + controller.abort(); |
| 242 | + throw new Error("aborted"); |
| 243 | + }); |
| 244 | + await expect( |
| 245 | + resolveTuiExecInvocation( |
| 246 | + "describe", |
| 247 | + { cwd: workspaceDir, file: [windowsPath] }, |
| 248 | + async () => "", |
| 249 | + controller.signal, |
| 250 | + ), |
| 251 | + ).rejects.toMatchObject({ kind: "cancelled" }); |
| 252 | + }); |
| 253 | +}); |
0 commit comments